4. Test doubles
We reach the point we left pending. Testing your code is easy when it only depends on itself. The problem appears when the outside world comes into play: a database, an API, the system clock, a random number generator. Slow, fragile or unpredictable things.
The solution is to replace those dependencies with test doubles: fake pieces that imitate the real ones but that you control. Just like a stunt double replaces the actor in dangerous scenes.
The underlying error: testing implementations instead of abstractions
A very common, and mistaken, pattern is to call the real dependency directly inside the test:
import requests
def test_get_current_weather():
response = requests.get("https://api.open-meteo.com/v1/forecast?latitude=35&longitude=139")
data = response.json()
assert data["current_weather"]["temperature"] > 0
What happens if the API isn't available? If the response format changes? If the temperature really does drop below 0? The test becomes fragile and unreliable, and any change in the API forces you to touch all the tests that depend on it.
The key is to depend on an abstraction, not on the concrete implementation. You define a contract (an interface) that describes what the dependency does, without tying yourself to how it does it:
from typing import Protocol
class WeatherGateway(Protocol):
def get_temperature(self, latitude: float, longitude: float) -> float | None:
...
In production you use the real implementation, which talks to the API. In the tests you use a double. The code that consumes the dependency doesn't notice the difference, because both fulfill the same contract.
{: .advice } This pattern is called dependency injection: instead of creating the dependency inside the function, you pass it from outside. That way you can change it in the tests without touching the production code. It's portable to any language.
The three most used doubles
- Mock: a fake object that records whether it was called and with what arguments. You use it when you want to verify that something happened, for example "the user was saved".
- Stub: returns fixed responses that you prepare. You use it when you only care about the data that comes in, for example "the API returns 30 degrees".
- Fake: a fake but functional implementation, like an in-memory database instead of the real one.
In Python you have unittest.mock in the standard library. With create_autospec you build a double that respects the signature of the abstraction, so the test breaks if you change the contract:
from unittest.mock import create_autospec
def test_recommends_swimming_when_hot():
# Given: a double that always returns heat
gateway = create_autospec(WeatherGateway)
gateway.get_temperature.return_value = 30.0
recommender = ActivityRecommender(gateway)
# When
result = recommender.recommend(latitude=40.4, longitude=-3.7)
# Then
assert result == "Swimming"
We never touch the real API. The test is fast, deterministic and doesn't depend on the network.
Wrappers: taming the unpredictable
There are things that change on their own and ruin a test: the date and time, random numbers, hashes. If your function calls datetime.now(), tomorrow the result will be different.
The technique is the same: wrap that variable value in something you can replace. In the tests you give it a fixed value:
class FixedClock:
def __init__(self, fixed_time):
self._fixed_time = fixed_time
def now(self):
return self._fixed_time
Now your code asks the clock you inject for the time, and in the tests you pass it a FixedClock that always returns the same instant. The test stops depending on the calendar.
{: .advice } The underlying idea recurs throughout the whole topic: isolate the external and unpredictable behind an abstraction, and replace it with a double in the tests. The syntax changes depending on the language, not the idea.
Activity 1
You have a function send_welcome_email(user, mailer) that sends a welcome email through mailer.
1. Define an abstraction Mailer with a method send(to, subject, body).
2. Write a test with a mock that verifies that send was called once with the correct recipient.
3. Don't send any real email.
Activity 2
You have a dice game that wins if a 6 comes up.
1. Isolate the generation of the random number behind an abstraction.
2. With a double that always returns 6, test the winning case.
3. With a double that always returns 2, test the losing case.
This work is under a Attribution-NonCommercial-NoDerivatives 4.0 International license.
Desafíos de programación atemporales y multiparadigmáticos
Te encuentras ante un librillo de actividades, divididas en 2 niveles de dificultad. Te enfrentarás a los casos más comunes que te puedes encontrar en pruebas técnicas o aprender conceptos elementales de programación.
Buy the bookWill you buy me a coffee?
This is how I keep writing without ads or paywalls.
Sure, it's on me!
Comments
There are no comments yet.