This content originally appeared on Level Up Coding – Medium and was authored by Shane Nolan
How to write SOLID Python tests
Recently, I was integrating a Python application with a queue-based notification system. We were using AWS Simple Queue Service (SQS) as the backend, and AWS provides boto3, a Python package for interacting with it. It’s a handy library, but there’s a catch: if you’re not careful, your code and your tests can become tightly coupled. And once that happens, testing becomes tedious and time-consuming.

The typical approach
Here’s a common implementation using patching that you will find online when testing code that interacts with SQS:
import boto3
class SlackQueueNotificationService:
def __init__(self, queue_client: boto3.client) -> None:
self._queue_client = queue_client
self._queue_url = "aws-queue-url"
def send_notification(self, message: str) -> None:
self._queue_client.send_message(
QueueUrl=self._queue_url,
MessageBody=message,
)
from unittest.mock import patch, MagicMock
@patch('project.notifications.slack.boto3.client')
def test_slack_queue_notification_service_send_message(
mock_boto_client,
) -> None:
mock_sqs_client = MagicMock()
mock_boto_client.return_value = mock_sqs_client
notification_service = SlackQueueNotificationService(
mock_boto_client,
)
notification_service.send_message('test')
mock_sqs_client.send_message.assert_called_once_with(
QueueUrl='aws-queue-url',
MessageBody='test'
)
At first glance, it looks fine and works. We’re patching boto3.client and using MagicMock to avoid hitting AWS in tests. But there are hidden problems:
- Still coupled to boto3 — the test depends on boto3’s API and assumes exactly how boto3 is used inside your code.
- Brittle tests — tiny refactor, like adding or changing a single parameter, can break the test even though the business logic hasn’t changed.
- Implementation-driven — you’re testing how the code sends the message, instead of checking that a message was sent.
Let me show you with a code example what will happen to the test if we add a parameter to the send_message method.
import boto3
class SlackQueueNotificationService:
def __init__(self):
self._client = boto3.client("sqs")
self._queue_url = "aws-queue-url"
def send_notification(self, message: str) -> None:
self._client.send_message(
QueueUrl=self._queue_url,
MessageBody=message,
MessageGroupId="notifications",
)
Our existing test breaks because the send_message function call includes an extra argument. This should not happen because that’s an implementation detail of the boto3.client, not the service’s behaviour. This seems minor, but if you have hundreds of these tests, a small change turns into a lot of unnecessary rework.
The real problem isn’t boto3, it’s the tight coupling between your service, your tests, and boto3’s API. Adding mocks on top doesn’t solve this; in fact, it often makes the problem worse by coupling your tests to boto3’s internals. Therefore, we should implement our code so rarely use mocks. That’s where interfaces and dependency injection come in.
How to write SOLID tests
Instead of tying the service directly to boto3, we introduce an interface (IQueueClient). This lets us write code that depends on what we want (sending messages) without caring how it happens.
import abc
class IQueueClient(abc.ABC):
@abc.abstractmethod
def send_message(self, message: str) -> None: ...
Then we create a concrete class that implements the IQueueClient interface and passes the boto3.client in via dependency injection. This is known as the Adapter pattern. Next, we make our notification service require an IQueueClient parameter.
class SqsClient(IQueueClient):
def __init__(self, sqs_client: boto3.client, queue_url: str) -> None:
self._client = client
self._queue_url = queue_url
def send_message(self, message: str) -> None:
self._queue_client.send_message(
QueueUrl=self._queue_url,
MessageBody=message,
)
class SlackQueueNotificationService:
def __init__(self, queue_client: IQueueClient) -> None:
self._queue_client = queue_client
def send_notification(self, message: str) -> None:
self._queue_client.send_message(message)
The beauty of this design is that the Slack Queue Notification service doesn’t know or care about the underlying queue implementation, i.e. it has no idea we’re using boto3. It just loosely depends on the interface, simplifying testing by allowing us to pass in a stub or spy implementation of the queue client.
class SpyQueueClient(IQueueClient):
def __init__(self):
self.sent_messages = []
def send_message(self, message: str) -> None:
self.sent_messages.append(message)
def test_slack_queue_notification_service_sends_message():
queue_spy = SpyQueueClient()
service = SlackQueueNotificationService(queue_spy)
service.send_notification("shanenullain.dev")
assert queue_spy.sent_messages == ["shanenullain.dev"]
Our test now becomes self-explanatory with no patching or hidden magic. It’s no longer coupled to boto3, adding or changing a parameter won't break the test, and we are testing the real behaviour, which is asserting that a message was sent. The best part is if you switch from SQS to RabbitMQ, Kafka, or any other queue, your test still passes — without changing a single line.
By using interfaces and adapters, you eliminate most of the headaches that come with mock-heavy tests. Your tests focus on behaviour, which makes them cleaner, easier to maintain, and far less brittle. Simple refactors won’t break hundreds of tests, and swapping components becomes painless. The takeaway is simple: if you design your code around interfaces and use stubs or spies where needed, you’ll write tests that are faster to maintain, easier to read, and more reliable. Good tests don’t just verify code — they teach you how your system behaves.
Thanks for reading . You can find me on Linkedin or Github if you want to connect with me.
Stop writing ugly Python tests was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.
This content originally appeared on Level Up Coding – Medium and was authored by Shane Nolan