The earlier items in this track gave you design rules, such as "the domain layer never imports the database". Nothing enforces a rule you write only in a README. A test does, because it fails on the commit that breaks the rule.
Start with a specific rule. "The design is good" is too vague to test. "Computing an order total must work without SQLite" tells you what to run and what a failure means. Here are seven ways to turn rules like that into checks.
1. Pass in a fake dependency
When code receives a dependency as an argument, a test can pass in one it controls. A fake clock lets you test exactly when a token expires:
from collections.abc import Callable
def expired(deadline: float, now: Callable[[], float]) -> bool:
return now() >= deadline
assert not expired(10.0, lambda: 9.0)
assert expired(10.0, lambda: 10.0)
assert expired(10.0, lambda: 11.0)
The same idea gives you a store that returns nothing, or a service that fails on its third call. Make the fake produce the cases your code has to handle, and no more.
2. Block a module and run the code
To show that some code runs without a dependency, run it in a fresh Python
process where importing that dependency fails. This script writes a small
module to a temporary folder, then tests it in a child process with sqlite3
blocked:
from pathlib import Path
import subprocess
import sys
import tempfile
source = (
'__all__ = ["total"]\n'
'def total(prices: list[int]) -> int:\n'
' return sum(prices)\n'
)
check = (
'import sys\n'
'sys.path.insert(0, sys.argv[1])\n'
'sys.modules["sqlite3"] = None\n'
'import pricing_domain as domain\n'
'assert domain.total([120, 80]) == 200\n'
'assert domain.total([]) == 0\n'
)
with tempfile.TemporaryDirectory() as directory:
Path(directory, "pricing_domain.py").write_text(source, encoding="utf-8")
subprocess.run(
[sys.executable, "-I", "-c", check, directory],
check=True,
timeout=10,
)
Setting sys.modules["sqlite3"] = None makes every later import sqlite3
raise ModuleNotFoundError. That includes an import inside total, which
runs only when something calls total. Add import sqlite3 above the
return line in source and run the script again. The check fails.
The -I flag starts Python in isolated mode. It ignores PYTHON* environment
variables. It also leaves the current folder and your user site-packages off
sys.path, the list of folders Python searches when you import a module. So
the child process imports pricing_domain only from the folder that the
check adds.
The check blocks one name and runs two calls. Block every module the rule names, and make one call for each branch you want to test.
3. Check which modules import which
An import graph records which modules import which others. A rule can forbid
a direct import, or any chain of imports between two parts of the code. In
the chain domain -> helpers -> database, the domain depends on the database
even though it never imports it directly.
The layering and forbidden-chain exercises earlier in this track build this
check by hand. In a real project, a tool such as
Import Linter
runs it from a configuration file. You decide which modules belong to each
layer and whether indirect imports count. A graph built from the source
misses an import whose module name the code builds at runtime, such as
importlib.import_module(name).
4. Pin the public names
A module's __all__ lists its public names. Add these lines to the check
string in section 2, after the line that imports domain:
assert set(domain.__all__) == {"total"}
assert callable(domain.total)
The first line fails when someone adds or removes an export. The second
checks that the exported function exists. Neither checks what total
returns, so keep the calculation tests too. __all__ decides what
from pricing_domain import * brings in, and it tells readers what is
public. Other names in the module stay importable.
5. Keep a bad call a type error
Types can rule out a call that makes no sense. In this example, you must seal a draft before you can publish it:
from dataclasses import dataclass
@dataclass(frozen=True)
class Draft:
text: str
@dataclass(frozen=True)
class Sealed:
text: str
def seal(draft: Draft) -> Sealed:
return Sealed(draft.text)
def publish(document: Sealed) -> str:
return document.text
draft = Draft("hello")
publish(seal(draft)) # Valid call.
publish(draft) # type: ignore[arg-type]
Save it as type_contract.py and run mypy --strict type_contract.py. The
comment on the last line silences the expected arg-type error, so mypy
passes. --strict includes --warn-unused-ignores. If someone changes
publish to accept Draft | Sealed, the comment silences nothing, and mypy
reports
unused-ignore.
The loosened type now fails CI.
Run this file with mypy, not pytest. Python runs publish(draft) without
complaint, because it does not check annotations at runtime. Write the error
code in each ignore comment, as in [arg-type], so the comment cannot hide a
different error. Keep a valid call next to each invalid one, so the file also
checks that the right call still passes.
6. Scan the source with the AST
The AST, or abstract syntax tree, is the tree of node objects that
ast.parse builds from source text without running the code. You used it in
the ast exercises earlier in this track. It suits rules about what the
source says, such as "no bare except:":
import ast
source = '''
try:
risky()
except:
pass
'''
bare = [
node.lineno
for node in ast.walk(ast.parse(source))
if isinstance(node, ast.ExceptHandler) and node.type is None
]
assert bare == [4]
Rules about what runs at import time need more care. A function body runs
only when something calls the function, but its decorators and default
values run when the def statement runs. A class body runs when Python
creates the class. Python 3.14 evaluates annotations only when something
asks for them. A plain name match also misses aliases, as in
from threading import Thread as T. Test a
scanner on code it should accept as well as code it should reject.
7. Compare two implementations
Differential testing runs the same cases through two implementations and compares the results. For example, give an in-memory store and a SQLite store the same missing keys, repeated writes and empty results. Check that they agree on the results, their order and the exceptions. Both versions can share a bug, so add a few cases whose answers you worked out by hand.
Metamorphic testing changes the input in a known way and checks how the
output changes. For the total function above, shuffling the prices must
give the same total, and adding a free item must not change it. You get many
tests without writing out each expected answer. Pick relations that hold for
every input. Shuffling a list of floats can change its sum in the last digit.
Check your understanding How would you check that importing a library does not start any threads? Show answer Hide answer
Use two checks. A scan of the AST flags known thread-starting calls that run
at import time, including calls in default values, decorators and class
bodies. A test in a fresh process replaces threading.Thread.start with a
wrapper that records each call, then imports the library. The wrapper also
sees a thread that finishes before the import returns, which a count of
running threads taken afterwards would miss. Neither check sees a thread that
a C extension starts.