Home / Courses / Testing for Beginners / II — Hands-On Practice
II — Hands-On Practice
Article 6 of 9

Hands-On: Mocking & Test Doubles

Why external services must never be called for real during a test, mocking with pytest-mock, the "patch where it's used" rule, and verifying interactions with assert_called_once_with.

July 10, 2026

So far, every dependency we've "replaced" during a test (database, Redis) has been swapped for a version that ACTUALLY WORKS — in-memory SQLite genuinely stores data, FakeRedis genuinely counts values. But there's one kind of dependency that can't (and shouldn't) be replaced with a working version: calls to an EXTERNAL service outside our control. In this article we cover mocking — a term we touched on in the article on test anatomy, and now put into real practice.

A New Feature: Notifications When a Task Is Completed

task-tracker-api now has a new feature — sending a notification (a simulated webhook) whenever a task is marked done:

# app/notifications.py
import httpx

NOTIFICATION_ENDPOINT = "https://notifications.example-service.test/notify"


def send_task_completed_notification(task_title: str) -> None:
    response = httpx.post(
        NOTIFICATION_ENDPOINT,
        json={"message": f"Task completed: {task_title}"},
        timeout=5.0,
    )
    response.raise_for_status()

Called from the update endpoint:

# app/routers/tasks.py
from app.notifications import send_task_completed_notification


@router.patch("/{task_id}", response_model=TaskRead)
def update_task(...):
    ...
    if not was_done and task.done:
        send_task_completed_notification(task.title)
    return task

Proving Why We CAN'T Call It for Real During a Test

NOTIFICATION_ENDPOINT deliberately points at a domain that doesn't actually exist. Try running a test WITHOUT any mocking:

def test_without_mock_real_notification_call_fails(client: TestClient):
    token = register_and_login(client)
    created = client.post("/api/tasks", json={"title": "Without a mock"}, headers=auth_headers(token))
    task_id = created.json()["id"]

    with pytest.raises(httpx.HTTPError):
        client.patch(f"/api/tasks/{task_id}", json={"done": True}, headers=auth_headers(token))

This test PASSES precisely because we EXPECT an error — pytest.raises(httpx.HTTPError) verifies that a connection error genuinely occurs. This proves the problem is real: if another test calls the same endpoint WITHOUT anticipating this, that test will fail (or, in the case of a domain that actually responds slowly, just be slow) because it's trying to reach a service that will never respond correctly. Imagine if this were a real payment API — we obviously don't want our tests to actually process a payment every time they run.

Mocking with pytest-mock

The fix: replace the send_task_completed_notification function with a FAKE version just for this test, one that never actually touches the network:

def test_completing_task_sends_notification(client: TestClient, mocker):
    mock_send = mocker.patch("app.routers.tasks.send_task_completed_notification")

    token = register_and_login(client)
    created = client.post("/api/tasks", json={"title": "Learning mocking"}, headers=auth_headers(token))
    task_id = created.json()["id"]

    client.patch(f"/api/tasks/{task_id}", json={"done": True}, headers=auth_headers(token))

    mock_send.assert_called_once_with("Learning mocking")

mocker is a fixture from the pytest-mock plugin (install with pip install pytest-mock), a more convenient wrapper around Python's built-in unittest.mock. mocker.patch(...) replaces the target function with a Mock object — an object that accepts any call without actually doing anything, BUT records every one of those calls (exactly the definition of "mock" from the test anatomy article, as opposed to a "fake" that genuinely works).

mock_send.assert_called_once_with("Learning mocking") is the core idea of mocking: we're not just verifying the FINAL RESULT (the API response), we're verifying the INTERACTION — that the notification function was actually called, EXACTLY ONCE, with the right argument.

A Crucial Detail: "Patch Where It's Used," Not Where It's Defined

Look carefully at the path being patched: "app.routers.tasks.send_task_completed_notification" — NOT "app.notifications.send_task_completed_notification", even though that function is ACTUALLY defined in app/notifications.py.

This is the single most common mistake when learning to mock in Python. It comes down to how Python imports work: the line from app.notifications import send_task_completed_notification in tasks.py creates a NEW NAME send_task_completed_notification inside the app.routers.tasks module's namespace, pointing at the same function. If we patch it in app.notifications, we only replace the name at its ORIGINAL location — the name already imported into app.routers.tasks still points at the ORIGINAL function, unaffected.

Rule of thumb: always patch in the module WHERE the function is USED (called), not where it's defined.

Verifying the Negative Case Too

def test_creating_task_does_not_send_notification(client: TestClient, mocker):
    mock_send = mocker.patch("app.routers.tasks.send_task_completed_notification")

    token = register_and_login(client)
    client.post("/api/tasks", json={"title": "New task"}, headers=auth_headers(token))

    mock_send.assert_not_called()


def test_marking_already_done_task_does_not_notify_again(client: TestClient, mocker):
    mock_send = mocker.patch("app.routers.tasks.send_task_completed_notification")

    token = register_and_login(client)
    created = client.post("/api/tasks", json={"title": "Learning mocking"}, headers=auth_headers(token))
    task_id = created.json()["id"]

    client.patch(f"/api/tasks/{task_id}", json={"done": True}, headers=auth_headers(token))
    client.patch(f"/api/tasks/{task_id}", json={"done": True}, headers=auth_headers(token))  # PATCH again, still "done"

    mock_send.assert_called_once()  # not twice

This second test proves the if not was_done and task.done: logic in our endpoint works correctly — the notification only fires when a task TRANSITIONS from not-done to done, not on every call to the update endpoint.

Summary

  • External services (payment APIs, notifications, email) must NEVER be called for real during a test — slow, inconsistent, and can have real side effects.
  • mocker.patch(...) from pytest-mock replaces a function with a Mock object that records every call made to it.
  • assert_called_once_with(...) and assert_not_called() verify the INTERACTION, not just the final result.
  • Patch where it's used, not where it's defined — the most common mistake when mocking in Python.
  • Topics

    TestingMocking