Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Replacing External Services with Focused Test Doubles

Hello! In the previous lesson, you kept tests independent of the filesystem and environment by using tmp_path and monkeypatch. An external service is another source of variability: a test of incident logic should not require an on-call service to be available, correctly configured, or even reachable.

This lesson shows how to replace that collaborator with a test double that supplies a known answer. You’ll first pass the double directly to the code under test, then use monkeypatch for an entry point that already imports the real collaborator.


Choose the boundary you want to test

Imagine an incident notice that includes the current on-call owner. The notice formatting belongs to your application; retrieving the owner belongs to an external service. If a formatting test makes a real HTTP request, an outage or authentication problem can fail the test even when formatting is correct.

A stub supplies grades to an `averageGrades` function while the real grades system and its data store are bypassed. For our incident notice, a stub will supply an on-call owner while the real service is bypassed.

Watch two excerpts from Please Learn How To Write Tests in Python… • Pytest Tutorial by Tech With Tim. The first establishes why an external dependency is replaced; the second shows a substitute passed into the code being tested. The presenter uses pytest-mock, but our example will use a small handwritten double, so you do not need to install that plugin.

Please Learn How To Write Tests in Python… • Pytest Tutorial

Watch these excerpts for the isolation principle and the shape of an injected collaborator.

Watch the motivation for testing application logic without relying on an API. Then watch the injected client: focus on how the real service logic still runs while its client supplies a controlled answer.

The pytest guide offers a smaller example of the same idea. Its substitute response provides only the behavior the caller needs, rather than reproducing an entire HTTP library.

How to monkeypatch/mock modules and environments

Read the pytest documentation’s example of replacing a network response with a small class. It illustrates what makes a test double focused.

In “Monkeypatching returned objects: building mock classes,” begin at the paragraph explaining why the response object must be replaced. Read the response example, including its test and the explanation immediately below it. Notice that the substitute implements the .json() behavior used by the caller; it does not attempt to emulate a whole service.

A stub primarily supplies a predetermined result. A double can also record how it was called, which lets the test check an important interaction. Neither feature requires a mocking library when the interface is small.


Pass a focused double to incident logic

Keep the real service adapter separate. For a runnable illustration, create src/incident_app/on_call.py:

import json
import os
from urllib.request import urlopen


def lookup_owner(incident_id: int) -> str:
    base_url = os.environ["ON_CALL_BASE_URL"].rstrip("/")
    url = f"{base_url}/incidents/{incident_id}/owner"

    with urlopen(url, timeout=3) as response:
        data = json.load(response)

    owner = data["owner"]
    if not isinstance(owner, str):
        raise ValueError("Invalid owner response")
    return owner

This adapter assumes an example service contract: its JSON response contains a string owner. Adapt that contract to a real service if you have one. No test in this lesson will call this function. Importing the module does not read the environment or open a connection; those actions occur only when lookup_owner() runs.

Now create src/incident_app/notice.py:

from collections.abc import Callable

from incident_app.on_call import lookup_owner


def format_incident_notice(
    incident_id: int,
    summary: str,
    owner_lookup: Callable[[int], str],
) -> str:
    owner = owner_lookup(incident_id)
    return f"Incident {incident_id}: {summary} (on call: {owner})"


def create_incident_notice(incident_id: int, summary: str) -> str:
    return format_incident_notice(
        incident_id,
        summary,
        lookup_owner,
    )

format_incident_notice() depends on a capability—“given an incident ID, return an owner”—rather than on HTTP details. The Callable[[int], str] annotation expresses that small contract. create_incident_notice() is the entry point that supplies the real lookup during normal operation.

Create tests/test_notice.py with a double and a unit test:

from incident_app import notice


class StubOnCall:
    def __init__(self) -> None:
        self.requested_ids: list[int] = []

    def __call__(self, incident_id: int) -> str:
        self.requested_ids.append(incident_id)
        return "primary"


def test_formats_notice_with_on_call_owner() -> None:
    on_call = StubOnCall()

    result = notice.format_incident_notice(
        42,
        "Investigating",
        on_call,
    )

    assert result == "Incident 42: Investigating (on call: primary)"
    assert on_call.requested_ids == [42]

The test calls the real formatting function. Only its owner lookup is substituted. Because StubOnCall is callable and accepts an integer, it fits the narrow contract without implementing an HTTP client. The result assertion checks the notice; the recorded-ID assertion checks that the notice asked for the owner of the correct incident. Both checks are relevant here. Recording every internal step would make the test unnecessarily sensitive to refactoring.

Direct injection is the preferred boundary when you control the function’s design: the dependency is visible in its signature, and the test needs no global replacement.


Replace a name used by an existing entry point

You may also need to test create_incident_notice(), which supplies lookup_owner itself. This is where the monkeypatch technique from the previous lesson applies. First, read pytest’s guidance on limiting a patch to the reference your code uses.

How to monkeypatch/mock modules and environments

Return to the pytest guide for its warning about patching too broadly and its preference for explicit dependencies.

In “Global patch example: preventing ‘requests’ from remote operations,” start at the explanation of disabling HTTP requests for all tests. Read the example and guidance, stopping after the paragraph recommending explicit dependencies. Focus on the distinction between replacing a reference used by your code and changing a library globally.

Append this test to tests/test_notice.py, adding the pytest import at the top:

import pytest


def test_entry_point_uses_on_call_lookup(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    on_call = StubOnCall()
    monkeypatch.setattr(notice, "lookup_owner", on_call)

    result = notice.create_incident_notice(42, "Investigating")

    assert result == "Incident 42: Investigating (on call: primary)"
    assert on_call.requested_ids == [42]

The location of the patch matters. notice.py uses from incident_app.on_call import lookup_owner, creating a name in the notice module. When create_incident_notice() runs, it looks up notice.lookup_owner. Patching on_call.lookup_owner after notice was imported would not replace that already-bound name. Patching urllib.request.urlopen instead would reach much farther than this test needs.

Apply the patch before calling the entry point. Pytest restores it after the test, so another test will not inherit the stub. The test makes no HTTP request and does not need ON_CALL_BASE_URL to be set:

python -m pytest -v tests/test_notice.py

These tests establish that the notice uses an owner lookup correctly under controlled conditions. They do not establish that the real adapter speaks to an actual on-call service correctly. That boundary needs a separate integration test when such a service or suitable test environment is available; keeping the two questions separate makes failures easier to diagnose.


Takeaways

Replace only the external behavior your test needs. Pass a small double directly when the dependency can be an argument; for an existing imported dependency, patch the name where the code under test looks it up. Assert the application’s result and, when it matters, the arguments sent across that boundary—without invoking the real service.

Next, you’ll test how code responds when an operation fails, including checking an expected exception and its relevant details.

Can't find a good explanation? Sign up and we'll make it for you

Sign up