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
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.