Mocking, coverage & modern tooling
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')]
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:
| You inspect | It tells you | Careful |
|---|---|---|
| m.call_count | how many times it was called | counts calls, not distinct arguments |
| m.called | whether it was called at all | True even for a single call with wrong arguments |
| m.assert_any_call(x) | x appears somewhere in the history | says nothing about order or count |
| m.assert_called_once_with(x) | exactly one call, and it was x | the strictest of these, and usually the one you want |
| m.call_args_list | the full ordered history | compare against [call(a), call(b)] to pin order |
| m.return_value | what the mock hands back | defaults to another Mock, never to None |
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.
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:
The modern tool stack
ruff: one very fast tool that lints and formats, replacingflake8,isort, andblack.mypy(orty): 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 ongit 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.
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) # 2Apply
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.