Every change can break code you are not looking at. A test states what the code should do and reports the moment that stops being true.
Unit testing is one of the most valuable habits in modern programming. You build tests into the code you write and run them on every change. A type checker verifies the claims you can write as annotations. Tests verify the rest: that a withdrawal reduces the balance, that the account refuses an overdraft.
Tests give you a safety net. With them you can refactor boldly, change designs, and clean up code. Tests also push back on the design. A function you cannot test easily usually goes looking for the clock, the filesystem, or the network instead of receiving them.
Perhaps more importantly, tests tell you immediately if a change you’ve made causes a failure. Immediate feedback saves an enormous amount of time. If the problem surfaces only after multiple changes, you have no idea which change caused the bug.
When a bug does get through, the patch is only half the job. Start by writing a test that reproduces the bug, and watch it fail. The fix then turns that test green, and the bug cannot return unnoticed. The other half is asking what let the bug through: a case no test covered, a claim no annotation stated, an interface that invites misuse. Put out the fire, and also find what is starting fires. A team that stops at the patch fixes the same kind of bug forever.
Writing the code, getting it working, and intending to write the tests later seems easy. Later rarely comes. Once the code works, the tests feel less important.
One solution is to write the tests before writing the code. When you write the tests first, you:
Testing then becomes a design tool, not a verification step you skip when the code looks right to you.
For example, here is a test for is_palindrome(),
written before that function exists anywhere in the project:
# test_palindrome.py
from palindrome import is_palindrome
def test_empty_string_is_a_palindrome() -> None:
assert is_palindrome("")
def test_racecar_is_a_palindrome() -> None:
assert is_palindrome("racecar")
def test_hello_is_not_a_palindrome() -> None:
assert not is_palindrome("hello")At this point palindrome.py does not
exist, so running this file fails before a single assertion
runs. pytest cannot import a missing module, so it
reports a ModuleNotFoundError as a collection
error, with no test run and no assertion failed. That error,
rather than a failing assertion, confirms the test catches a
missing implementation.
# palindrome.py
def is_palindrome(s: str) -> bool:
return s == s[::-1]The test written first stays the same. Only the code changes to satisfy it. That is TDD working as a design tool. The test defines what “done” means before any implementation exists to shape that definition.
That said, TDD requires that you know what you are creating. It assumes you are confident the design is correct, so that only implementation remains. Often, however, you are still experimenting to find the direction the program will take. When you are discovering the design rather than producing code, TDD is wasteful.
Python has a testing framework in the standard library,
unittest, modeled on Java’s JUnit.
unittest works, but it carries the class-based
boilerplate of its heritage. The wider Python world has settled
on pytest, and so does this book.
pytest rests on two ideas that keep tests short.
A test is just a function whose name starts with
test_. A check is just Python’s built-in
assert statement. No base class needs inheriting,
and no special assertion methods need memorizing.
pytest rewrites assert so that a
failure still shows you both sides of the comparison.
Most of this chapter’s tests check the following
Account class, a @dataclass:
# account.py
from dataclasses import dataclass
class InsufficientFunds(Exception):
def __init__(self,
balance: float, amount: float) -> None:
super().__init__(
f"balance {balance} is less than {amount}")
@dataclass
class Account:
balance: float = 0.0
def deposit(self, amount: float) -> None:
if amount <= 0:
raise ValueError("deposit must be positive")
self.balance += amount
def withdraw(self, amount: float) -> None:
if amount > self.balance:
raise InsufficientFunds(self.balance, amount)
self.balance -= amount
def add_interest(self, rate: float) -> None:
self.balance += self.balance * rateBy convention, tests live in a file whose name starts with
test_. The test file confirms that:
ValueError.InsufficientFunds.# test_account.py
import pytest
from account import Account, InsufficientFunds
def test_new_account_is_empty() -> None:
assert Account().balance == 0.0
def test_deposit_increases_balance() -> None:
account = Account()
account.deposit(100)
assert account.balance == 100
# Make three tests, replacing "bad" with each list value
@pytest.mark.parametrize("bad", [0, -1, -100])
def test_nonpositive_deposit_raises(bad: float) -> None:
with pytest.raises(ValueError):
Account().deposit(bad)
@pytest.fixture
def funded() -> Account:
account = Account()
account.deposit(100)
return account
def test_withdraw_reduces_balance(funded: Account) -> None:
funded.withdraw(40)
assert funded.balance == 60
def test_overdraft_raises(funded: Account) -> None:
with pytest.raises(InsufficientFunds):
funded.withdraw(1000)
def test_interest_adds_to_balance(funded: Account) -> None:
funded.add_interest(0.05)
assert funded.balance == pytest.approx(105.0)This file previews two features that get their own sections
later in this chapter: the parametrize1 mark runs one test over
several inputs, and the funded fixture builds a
prepared account for each test that names it as a parameter. The
@ lines apply decorators. Here they
mark the functions for pytest.
Run the test suite by typing pytest in the
project directory.2
pytest puts each test file’s own directory at the
front of sys.path, so
from account import Account works with no packaging
and no path setup, as long as account.py sits beside test_account.py.
pytest discovers every test_*.py file,
collects every test_ function, runs them, and
reports successes and failures. A failing assert
prints the expression and the actual values, so you rarely need
a debugger to see what went wrong.
pytest -k overdraft runs only the tests whose
names contain “overdraft”, pytest -x stops at the
first failure, and pytest --lf reruns just the
tests that failed last time. Those three cover most of a working
day.
If you change the expected value in
test_deposit_increases_balance() to
10, the report names the line, the expression, and
both sides:
______________ test_deposit_increases_balance ______________
def test_deposit_increases_balance() -> None:
account = Account()
account.deposit(100)
> assert account.balance == 10
E assert 100.0 == 10
E + where 100.0 = Account(balance=100.0).balance
test_account.py:11: AssertionError
The where line is the rewriting at work.
pytest keeps the sub-expression
account.balance and its value, which a bare
assert statement discards.
Account is a @dataclass, so its
generated __repr__() names the field values. A
hand-written class with no __repr__() prints
<account.Account object at 0x...> there
instead.
test_overdraft_raises() uses
pytest.raises() as a context manager. The test
passes only if the block raises the expected exception.
Keep the with block down to the single call that
should fail. Any statement inside it that raises the expected
type passes the test, including one that fails for an unrelated
reason. match= takes a regular expression that
pytest checks against the exception’s message, so
the test can confirm which failure occurred and not just its
type:
# test_overdraft_message.py
import pytest
from account import Account, InsufficientFunds
def test_overdraft_names_amount_keeps_balance() -> None:
account = Account(100)
with pytest.raises(InsufficientFunds,
match="less than 250"):
account.withdraw(250)
assert account.balance == 100The assertion after the block belongs outside it. A failed
withdrawal must leave the balance alone, and that check has
nothing to do with the exception. Inside the block the assertion
never runs. The exception from withdraw() skips the
rest of the block, and pytest.raises() then absorbs
it.
Testing floating-point results for exact equality is
unreliable. test_interest_adds_to_balance()
compares with pytest.approx(), which allows a small
tolerance: a relative difference of 1e-6, unless you pass
rel= or abs=. Code outside a test has
no approx(). Its standard-library counterpart is
math.isclose(), whose default relative tolerance is
a tighter 1e-9, adjustable through its rel_tol and
abs_tol arguments.
That test passes with == as well.
100.0 + 100.0 * 0.05 is exactly 105.0
on any IEEE double, and every other whole-percent rate on this
starting balance gives an exact result too. The trouble starts
once error accumulates:
# test_compounding.py
import pytest
from account import Account
def test_interest_compounds() -> None:
account = Account(100)
for _ in range(5):
account.add_interest(0.05)
assert account.balance == pytest.approx(127.62815625)Applying 5% five times produces
127.62815624999999, so the same assertion written
with == against 127.62815625 fails.
Use approx() by default rather than adding it after
a comparison fails. test_interest_adds_to_balance()
does not need it, and you cannot tell by looking which tests
do.
When the same logic should run against several inputs, do not
copy the test. If you mark the test with
parametrize, as
test_nonpositive_deposit_raises() does,
pytest runs it once per case and reports each
separately. That single function becomes three independent
tests, and a failure names the case that failed.
A parametrize mark can carry several names,
followed by a list of tuples, one tuple per case:
# test_balances.py
import pytest
from account import Account
@pytest.mark.parametrize("start, spend, expected", [
(100, 40, 60),
(50, 50, 0),
(200, 1, 199),
])
def test_withdraw_leaves_expected_balance(
start: float, spend: float, expected: float
) -> None:
account = Account(start)
account.withdraw(spend)
assert account.balance == expectedThe names in the string line up, in order, with the values in each tuple.
parametrize is a mark, and three others
turn up in any existing suite. @pytest.mark.skip
and @pytest.mark.skipif leave a test out,
unconditionally or on a condition such as the platform.
@pytest.mark.xfail records a known bug. The test
still runs, and pytest counts its failure as
expected rather than as a new break. The suite stays green, and
the test that proves the bug stays in place.
JUnit-style frameworks give each test class a
setUp() and tearDown().
pytest replaces both with fixtures:
functions that build what a test needs. You declare fixtures as
parameters to a test, and each parameter name tells
pytest to call that fixture and pass its result to
the test.
The funded() function in test_account.py is a
fixture. Only pytest calls it. A test that calls
funded() fails with
Fixture "funded" called directly.
Each test gets its own freshly built funded
account, so tests cannot leak state into each other.
If a fixture needs cleanup, it can yield the
value and run teardown code after the yield. A
function containing yield is a generator, and
a fixture needs only its shape.
# test_teardown.py
from collections.abc import Iterator
import pytest
from account import Account
@pytest.fixture
def open_account() -> Iterator[Account]:
account = Account()
account.deposit(100)
yield account
account.withdraw(account.balance)
assert account.balance == 0
def test_spend_some(open_account: Account) -> None:
open_account.withdraw(30)
assert open_account.balance == 70Everything before the yield is setup. Everything
after it runs once the test finishes, whether the test passes or
fails. Close files, release locks, or check a final invariant
after the yield. A failing check there surfaces as
an error, not as a test failure. pytest prints
1 passed, 1 error, so read the error to see which
invariant broke.
A fixture marked @pytest.fixture(autouse=True)
runs for every test in its scope without any test naming it.
Autouse suits a fixture whose value is a side effect rather than
an object: resetting a global registry, or installing a
monkeypatch every test needs.
Autouse runs the fixture, and only a parameter delivers its
value. If you mark funded autouse and leave it out
of the parameter list, funded.withdraw(40) raises
an AttributeError. The bare name finds the fixture
object the decorator left behind, not the Account
it returns.
Fixtures eliminate duplicated setup. A test that names
funded states what it needs and nothing about how
to build it.
Any test in conftest.py’s directory,
or below it, can use a fixture defined there, with no import.
Place shared setup in conftest.py.
A directory has one conftest.py, so its
fixtures accumulate in that file. This one holds a
session-scoped fixture and a parametrized one:
# conftest.py
import pytest
from account import Account
@pytest.fixture(scope="session")
def bank_name() -> str:
return "Crunchy Frog Credit Union"
@pytest.fixture(params=[0.0, 100.0, 1_000_000.0])
def preloaded(request: pytest.FixtureRequest) -> Account:
return Account(request.param)pytest builds the session-scoped
bank_name fixture once and reuses it, and that
reuse suits expensive resources. The reuse is the risk as well
as the point. Because every test receives the same object, one
test that mutates it changes what the next test sees. A session
fixture that returns a mutable object shows the leak:
# test_session_leak.py
import pytest
@pytest.fixture(scope="session")
def shared_cache() -> dict[str, int]:
return {}
def test_first_write(
shared_cache: dict[str, int]
) -> None:
shared_cache["seen"] = 1
assert shared_cache == {"seen": 1}
def test_second_sees_leftover(
shared_cache: dict[str, int]
) -> None:
assert shared_cache == {"seen": 1}Both tests pass, and that is the problem.
test_second_sees_leftover() passes for one reason:
test_first_write() ran first and left its entry
behind. If you swap the two functions’ order in the file,
test_second_sees_leftover() fails, since nothing
has written "seen" yet. Keep session fixtures to
immutable values, like bank_name, or to a resource
with its own reset, and leave anything a test mutates at the
default per-test scope.
preloaded shows the other feature: you can
parametrize a fixture. Every test that requests it runs once for
each parameter value. request is a built-in
fixture, which preloaded receives by naming it as a
parameter, and request.param holds the value for
that run. pytest rebuilds preloaded
for each parameter, so a test that uses it automatically runs at
every starting balance:
# test_fixtures.py
from account import Account
def test_deposit_on_any_balance(preloaded: Account) -> None:
start = preloaded.balance
preloaded.deposit(1)
assert preloaded.balance == start + 1
def test_bank_name_from_conftest(bank_name: str) -> None:
assert bank_name == "Crunchy Frog Credit Union"Nothing imports either fixture. pytest finds
them in conftest.py and supplies
them by name.
parametrize multiplies the one test it
decorates. A parametrized fixture multiplies every test that
requests it, in this file and every other file that can see the
conftest.py. Use the mark
when the cases belong to one test, and the fixture when the same
variation should sweep across a whole suite.
A white-box test reaches into the internals of the code it checks. A black-box test treats the code as an opaque box and exercises only its public interface, the way a client does.
In a language with access control, the compiler enforces the
difference. Python has no access control, so every attribute is
reachable. A single leading underscore, as in
self._balance, changes nothing at the language
level. Python stores _balance under that name,
reachable like any other attribute. Only convention says, “this
is private, do not rely on it.”
A leading double underscore changes the name Python stores,
though it is still not access control. Python’s compiler
rewrites self.__pin, written inside a class body,
into self._ClassName__pin, a transformation called
name mangling. ty does not model this
rewriting, so its report on the code below disagrees with what
runs:
# name_mangling.py
class Vault:
def __init__(self) -> None:
# Single underscore: convention only
self._balance = 0
# Double underscore: gets mangled
self.__pin = "1234"
v = Vault()
print(vars(v))
#: {'_balance': 0, '_Vault__pin': '1234'}
# ty: unresolved attribute "_Vault__pin":
print(v._Vault__pin) # type: ignore
#: 1234vars(v) shows what Python stored:
_balance under its own name, and __pin
rewritten to _Vault__pin the moment the class body
compiled. The rewritten name is a real attribute like any other,
so v._Vault__pin reads it successfully.
Mangling exists to stop a subclass from accidentally colliding with a base class’s private-looking name, not to hide the attribute. In Python the distinction between white-box and black-box remains one of discipline, not of compiler enforcement.
That makes black-box testing the sensible default. If you
test the public surface, the methods a caller should use, you
can change the internals without rewriting the tests. The
Account tests are black-box. They read no private
attributes. When you do need a white-box test for a tricky
internal, Python lets you write one, but treat each one as a
test that may break when you refactor.
Good tests stay independent of the real filesystem,
environment, random number generation, clock, and network.
pytest includes built-in fixtures for this.
tmp_path gives each test a private temporary
directory. monkeypatch sets and restores
environment variables and attributes, undoing every change when
the test ends.
storage.py saves and loads
files in the directory that the APP_DATA
environment variable names:
# storage.py
import os
from pathlib import Path
def data_dir() -> Path:
return Path(os.environ.get("APP_DATA", "."))
def save(name: str, value: str) -> None:
(data_dir() / name).write_text(value, encoding="utf-8")
def load(name: str) -> str:
return (data_dir() / name).read_text(encoding="utf-8")Path() keeps a leading ~ as
written, so a variable that may hold ~/data needs
.expanduser() on the result.
The tests point APP_DATA at a throwaway
directory, so they leave real data untouched and cannot collide
with each other. test_round_trip() loads back what
it saved, and test_missing_file_raises() confirms
that loading an absent file raises a
FileNotFoundError:
# test_storage.py
from pathlib import Path
import pytest
import storage
def test_round_trip(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("APP_DATA", str(tmp_path))
storage.save("greeting.txt", "hello")
assert storage.load("greeting.txt") == "hello"
def test_missing_file_raises(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("APP_DATA", str(tmp_path))
with pytest.raises(FileNotFoundError):
storage.load("absent.txt")data_dir() reads APP_DATA on every
call, so monkeypatch.setenv() can change the
directory data_dir() returns. A module-level
DATA_DIR = Path(os.environ.get("APP_DATA", "."))
reads the variable once, at import time, before any test body
runs, so patching the environment afterward changes nothing.
Reading a setting where you use it, rather than caching it at
import, is most of what makes a module testable.
Code that calls random produces a different
value each run, so a test cannot assert a known result:
# dice.py
import random
def roll() -> int:
return random.randint(1, 6)monkeypatch replaces the random call with one
that returns a known value, so the result stays predictable
while the test runs:
# test_dice.py
import dice
import pytest
def test_roll_returns_known_value(
monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(dice.random, "randint",
lambda a, b: 4)
assert dice.roll() == 4import random binds the one module object every
importer shares, so dice.random and
random are the same object and the patch replaces
randint() process-wide. monkeypatch
restores the original when the test ends, and that restoration
makes a process-wide patch safe.
Seeding the generator with random.seed(0) makes
the sequence repeatable, though you must record the values it
produces rather than choose them. Better still, have the code
accept a random.Random instance, so a test passes a
seeded random.Random(0) and needs no patching:
# dice_rng.py
import random
def roll(rng: random.Random) -> int:
return rng.randint(1, 6)# test_dice_rng.py
import random
import dice_rng
def test_roll_with_seeded_rng() -> None:
assert dice_rng.roll(random.Random(0)) == 4Because the function takes its source of randomness as an
argument, production code hands it a fresh
random.Random() while the test hands it a seeded
one. The randomness is now an input, not a hidden dependency.
This technique is dependency injection: the caller
hands the function its dependencies, and the function fetches
nothing on its own.
The 4 in test_dice_rng.py is what
Random(0) produces first, and its match with the
stubbed value in test_dice.py is a
coincidence. As with any seed, you record the value it gives you
rather than pick one.
Code that reads time.time() gives a different
answer every run:
# stopwatch.py
import time
def elapsed(start: float) -> float:
return time.time() - startmonkeypatch pins time.time() to a
known value the same way it does for randint():
# test_stopwatch.py
import time
import pytest
import stopwatch
def test_elapsed(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(time, "time", lambda: 100.0)
assert stopwatch.elapsed(40.0) == 60.0As with randomness, injecting the clock is cleaner still. The
same computation takes now as an argument instead
of going looking for it:
# clock_injected.py
from collections.abc import Callable
def elapsed(start: float,
now: Callable[[], float]) -> float:
return now() - startThe test hands it a function that returns the same time on every call:
# test_clock_injected.py
import clock_injected
def test_elapsed() -> None:
assert clock_injected.elapsed(
40.0, lambda: 100.0) == 60.0Both tests check the same arithmetic. The injected one runs
with no monkeypatch, and its signature names the
source of the time.
The rng or now parameter must
appear on every function between the caller and the code that
needs it. When that code sits several calls deep in a real
codebase, you widen every signature along the way, or introduce
a context object to carry the parameter.
monkeypatch skips that plumbing. It patches the
name in place, at the cost of a process-wide patch that stands
until teardown restores the name. Choose injection when the
parameter sits near the boundary. Choose
monkeypatch when threading it through touches more
code than the test is worth.
datetime.now() is harder to patch.
datetime is an immutable C type that rejects
attribute assignment. That justifies the effort of
injection.
If you cannot change the code, the library time-machine
freezes every source of wall-clock time at once, including
datetime.now(), with no monkeypatching on your
part:
# event.py
from datetime import datetime
def current_year() -> int:
return datetime.now().year# test_event.py
from datetime import datetime
import event
import time_machine
@time_machine.travel("2030-06-15", tick=False)
def test_current_year_is_frozen() -> None:
assert event.current_year() == 2030
assert datetime.now() == datetime.now()travel sets the clock to the given moment for
the test, and tick=False holds it there so every
reading is equal, as the test’s second assertion shows.
time.monotonic() and
time.perf_counter() keep running, because they
measure elapsed intervals rather than dates. Unlike
monkeypatch, which comes with pytest,
time-machine is a separate dependency, but it is
the standard answer for code built around
datetime.
A test must not use a real network. The call is slow, it
fails whenever the service or the connection does, and it ties
the test to data you do not control. monkeypatch
replaces the function that fetches data with one that returns a
canned response, so the test runs offline and gives the same
answer every time. Here a function reads a URL:
# weather.py
from urllib.request import urlopen
def current_temp(city: str) -> str:
with urlopen(f"https://example.com/{city}") as response:
return response.read().decode()The test swaps urlopen() for a stub that returns
bytes from memory, so no request leaves the machine:
# test_weather.py
import io
import pytest
import weather
def test_current_temp(
monkeypatch: pytest.MonkeyPatch
) -> None:
def fake_urlopen(url: str) -> io.BytesIO:
return io.BytesIO(b"21C")
monkeypatch.setattr(weather, "urlopen", fake_urlopen)
assert weather.current_temp("denver") == "21C"weather imports the function with
from urllib.request import urlopen, and that
statement binds urlopen in weather’s
own namespace, so weather.urlopen is the name the
call site reads and the name to patch. Patching
urllib.request.urlopen instead leaves
weather’s copy untouched. One rule covers both
import forms: patch the name the calling code looks up. The same
approach isolates a database, a message queue, or any other
service. Replace the boundary function with a stand-in, and
assert against what the calling code returns.
A stand-in like fake_urlopen() is a
stub: it answers with a canned value and records
nothing. The standard library’s unittest.mock turns
up in most existing code. It builds stubs for you, and it builds
mocks. A Mock goes further than a stub. It
remembers every call it received, so a test can check the call,
not just what the call returned.
# notifier.py
from collections.abc import Callable
def notify_low_balance(
balance: float,
send: Callable[[str], None],
) -> None:
if balance < 0:
send(f"balance is negative: {balance}")# test_notifier.py
from unittest.mock import Mock
import notifier
def test_negative_balance_sends_message() -> None:
send = Mock()
notifier.notify_low_balance(-5, send)
send.assert_called_once_with(
"balance is negative: -5")Mock() accepts any call and returns another
Mock unless told otherwise, recording every call as
it goes. assert_called_once_with() checks two
things at once: that send ran exactly once, and
that it ran with this argument. A plain stub cannot make that
check. fake_urlopen() remembers nothing about the
calls it receives.
A Mock accepts a call the real function would
reject, so a test keeps passing after the real signature
changes. create_autospec(), also in
unittest.mock, builds a mock from the real function
and rejects a call that does not fit its signature.
This book patches with monkeypatch and prefers
injection where you can change the code, because a function that
takes its clock or its fetcher as an argument needs no patching
library.
The tests in this chapter check specific examples: this input produces that output. A property-based test instead states a law the code must always obey, and lets a tool generate the inputs that try to break it. Confidence shows the technique, including the Hypothesis library that automates it.
You can now take an untested function and make it testable.
Name its dependencies: the clock, randomness, the filesystem,
the environment, the network. Replace those at the boundary, by
injection where you can change the code and with
monkeypatch where you cannot. Then pin the
remaining behavior with a handful of parametrized cases. The
work is mostly in the first step, since a function that is hard
to test usually goes looking for something no caller handed
it.
To find out what you have not tested, run the suite under
coverage.py, which the pytest-cov
plugin wires up when you pass --cov. Read the
result as a list of lines nothing exercised, not as a score. A
line a test happened to execute differs from a line a test
checks.
This chapter’s solutions give a hint, usually the shape of the code, and a full answer for each exercise.
transfer(other: Account, amount: float)
method to Account and write its tests first: a
successful transfer, and an overdraft that leaves both accounts
unchanged.parametrize to test
add_interest() at several rates, comparing with
pytest.approx().yields an
Account and asserts, after the yield,
that the balance is not negative. Use it in two tests.settings_path(), which returns
Path(os.environ["APP_CONFIG"]) / "settings.ini",
and test it with monkeypatch and
tmp_path. Then rewrite the function to take the
directory as an argument and test it again. Which test survives
a change to the environment variable’s name?weather.current_temp() calls
urlopen(). Write a second function that takes a
fetcher as an argument instead, and test both: one with
monkeypatch, one with a plain function passed in.
Then rename weather.urlopen to
weather.fetch and see which test still passes.test_notifier.py checks
that a negative balance sends a message. Write the test for the
other branch: a balance of zero or more sends nothing. Then
write the same test with a hand-written stub in place of the
Mock. What must the stub gain to make the
check?