Skip to main content

Mocking, coverage & modern tooling

Level 4: Level 4: Engineeringhard20 minmockingtestingruffmypy

Design code for testability, mock its dependencies, and know the modern tool stack.

Why fake a dependency

A test that reaches out to the network, a database, or datetime.now() is slow, flaky, and non-deterministic. It goes red when the wifi drops, not when your code is wrong. The fix is a design choice, not a testing trick: pass collaborators in as arguments so a test can hand your function a stand-in it fully controls. That is dependency injection, and it is the single habit that makes code testable.

Compare a send_all that hard-codes its sender:

import smtplib

def send_all(messages):
    server = smtplib.SMTP("smtp.example.com")   # untestable: opens a real socket
    ...

with the injectable version you build here, where sender arrives as a parameter. Now a test can supply any callable, including a mock.

Mock: a recorder you assert against

unittest.mock.Mock is a stand-in that answers every attribute access and every call, and records what happened. You do not tell it what to return; you inspect it afterward. The demo below runs send_all(sender, ["a", "b"]), which calls sender("a") then sender("b") and returns 2. That return is an ordinary int (len(messages)), because the mock is never asked for its own result.

After the run, the mock carries the whole call history:

sender.call_count            # 2
sender.assert_any_call("a")  # passes: "a" appears in the history
sender.call_args_list        # [call('a'), call('b')]
Check yourself
The code under test does: result = client.fetch(user_id), then if result: save(result). Your test passes in a plain Mock() and never sets return_value. Does the save branch run?

This is why the Apply and Practice steps keep sender as a parameter: the driver passes in a recorder (Apply) or a real Mock (Practice), then asserts how you called it.

The recorder answers a handful of different questions, and mixing them up is why mock assertions pass when they should not:

Table
The last row causes the quietest bugs: an unconfigured mock returns another Mock, which is truthy, so a test asserting 'if result:' passes no matter what your code did. Any assert_called* method you misspell is also silently a no-op, because Mock happily invents attributes.
You inspectIt tells youCareful
m.call_counthow many times it was calledcounts calls, not distinct arguments
m.calledwhether it was called at allTrue even for a single call with wrong arguments
m.assert_any_call(x)x appears somewhere in the historysays nothing about order or count
m.assert_called_once_with(x)exactly one call, and it was xthe strictest of these, and usually the one you want
m.call_args_listthe full ordered historycompare against [call(a), call(b)] to pin order
m.return_valuewhat the mock hands backdefaults to another Mock, never to None
The last row causes the quietest bugs: an unconfigured mock returns another Mock, which is truthy, so a test asserting 'if result:' passes no matter what your code did. Any assert_called* method you misspell is also silently a no-op, because Mock happily invents attributes.

Pitfalls

A plain Mock answers to anything. Call a method that does not exist and it silently hands back another Mock instead of failing:

m = Mock()
m.snd("hi")   # typo of .send: no error, and the test still "passes"

So a test can be green while production crashes. Two fixes: Mock(spec=Emailer) rejects attributes the real class does not have (m.snd now raises AttributeError), and create_autospec(Emailer) additionally checks call signatures, so calling send() with the wrong arguments raises TypeError inside the test.

Check yourself
send_all(sender, ['a', 'b']) has just run, so the mock recorded two calls. Your test then asserts sender.assert_called_with('a'). What happens?

Also watch assert_called_with: it checks only the most recent call. After the demo, sender.assert_called_with("a") fails because the last call was sender("b"). Use assert_any_call when you mean "somewhere in the history."

When a collaborator cannot be injected, the fallback is unittest.mock.patch, and it has one rule that trips up nearly everybody:

Check yourself
myapp/service.py starts with: from myapp.clients import fetch_user, and get_profile() calls fetch_user(uid). Your test patches myapp.clients.fetch_user. What happens?

The modern tool stack

  • ruff: one very fast tool that lints and formats, replacing flake8, isort, and black.
  • mypy (or ty): reads your type hints and flags mismatches before runtime.
  • pytest --cov (coverage.py): reports which lines your tests exercised. Cover the branches that matter, not a 100% badge.
  • pre-commit: runs all of these on git commit, so nothing broken lands.

Interview nuance: know what a coverage number does not tell you. pytest --cov reports line coverage by default, so a line counts as covered the moment it runs once. An if with no else can show 100% while the case where the condition is false never executes. Add --cov-branch to require both directions of each branch, and remember that even full branch coverage only proves the lines ran, not that your assertions checked the right thing.

Check yourself
Your PR reports 100 percent line coverage on the module you changed. What has that actually established?
Worked example (Python)
from unittest.mock import Mock


def send_all(sender, messages):
    for m in messages:
        sender(m)
    return len(messages)


sender = Mock()
print(send_all(sender, ["a", "b"]))   # 2
print(sender.call_count)              # 2

Apply

Your turn

The task this lesson builds to.

Warm-up (one file): implement send_all(sender, messages) to call the injected sender once per message and return how many were sent. The provided run driver passes a recorder in as the sender, so this rehearses the dependency injection the workspace step then verifies with a mock.

run(["a", "b", "c"]) is 3.

3 hints and 3 automated checks are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, so it survives past today.

Implement send_all(sender, messages) in notify/service.py: call the injected sender once per message and return the number sent. The tests pass a Mock and assert how it was called, so keep sender as an injected parameter. Some tests are hidden.

3 hints and 2 automated checks are waiting in the workspace.