1

pytest / test harness field card

glossary: the basics · glossary: fixtures · glossary: running and selecting · glossary: test doubles · glossary: kinds of test · glossary: test quality · glossary: an AI author · what gets collected
1 / 6sheet 1 front
01 glossary: the basics
testa function whose name starts with test. It passes if it does not raise
assertiona line stating what must be true. assert x == y
test suiteall your tests together
test runnerthe tool that finds and runs them. pytest is one
collectionpytest finding your tests, before running any
fail vs errorfail = an assertion was false. error = it blew up before reaching one
AAAarrange, act, assert. The three parts of most tests, in that order
TDDwrite the failing test first, then the code that makes it pass
02 glossary: fixtures
fixturenamed setup a test asks for by putting its name in the arguments
factory fixturea fixture returning a function, so a test can make several things
setup / teardownbefore and after. In pytest, teardown is whatever follows yield
scopehow often a fixture is rebuilt: function, class, module, package, session
autousea fixture applied to every test without being asked for
conftest.pyshared fixtures for a folder and everything under it. No import needed
overridea fixture in a test file shadowing one of the same name from conftest
tmp_pathbuilt in: an empty folder, new per test, cleaned up for you
capsys / capfdbuilt in: captured output. capfd when the write bypasses Python
caplogbuilt in: log records. Set the level or you capture nothing
monkeypatchbuilt in: change something temporarily, put back automatically
03 glossary: running and selecting
test idthe name in the report, e.g. test_naming[2-many]
parametrizerun one test many times with different inputs, each reported separately
indirectsend a parametrize value to a fixture, so each case gets setup and teardown
markera label on a test: @pytest.mark.slow. Select with -m
skip / skipifdoes not apply here. skipif decides at collection
xfailknown broken. Needs strict=True or it stays green once fixed
xpassan xfail that unexpectedly passed. Usually means delete the marker
deselectexcluded from this run by -k or -m. Not the same as skipped
04 glossary: test doubles
test doubleany stand-in used instead of the real thing
dummypassed only to fill a signature. Never actually used
stubalways returns the same canned answer
fakea real but simplified implementation. A dict instead of a database
spyworks, and records how it was called
mocka spy that also asserts it was called correctly
patchingreplacing something in place, by name
injectionpassing the collaborator in, rather than the code fetching it itself
seama place you can change behaviour without editing there. An argument is one
mock transporta fake put in the place where code would talk to the network
05 glossary: kinds of test
unitone function or class, nothing real underneath it
integrationseveral pieces together, often with a real file or database
end-to-endthe whole system as a user meets it. Slow, valuable, few
regression testwritten to pin a bug you just fixed, so it cannot come back
smoke testdoes it start at all
property-basedassert a rule for all inputs and let a tool hunt counterexamples
fuzzingthrow generated input at it and check nothing escapes the contract
differentialrun two implementations over one input; whichever disagrees is wrong
oracledifferential testing where the reference is a trusted external tool
golden / snapshotcompare output against a stored known-good file
contract testpin the shape you publish: names, codes, status codes, schema version
meta-testa test about the test suite, e.g. does every test still assert
06 glossary: test quality
coveragewhich lines ran. Not whether anything checked them
branch coveragedid both sides of each if run
mutation testingbreak the code deliberately and see whether the suite notices
equivalent mutanta deliberate break that changes no behaviour, so nothing can catch it
mutation gateadmit a generated suite only if it kills a threshold of mutants
flakypasses and fails without the code changing. Always a bug, never noise
quarantinemove a flaky test behind a deselected marker with a ticket
test smella sign the test or code is wrong. More patching than assertion is one
07 glossary: an AI author
apparatusthe machinery that makes reading generated output unnecessary: tests, types, sanitizers, canaries
provenance separationwhatever wrote the code does not grade it, and ideally is not the same family
self-preference biasan evaluator scoring its own generations higher than a human would
intrinsic self-correctiona model revising its own output with no external signal. Does not reliably work
external feedbacka signal from outside the model. A test run is one, which is the point
characterization testassertions transcribed from what the code currently does. Pins change, not correctness
the oracle problemknowing what the right answer is, independently of the thing being tested
metamorphic relationa property linking two runs when you cannot state the answer for either
translation validationproving one output matches its input for this run, rather than proving the tool
the intent gaptests cannot check that they encode what was actually wanted. No technical fix
08 what gets collected
filestest_*.py  or  *_test.py
classesTest*, and no __init__
functionstest*  not  test_*
# a helper named tests_in() IS COLLECTED as a test.
# if it is a generator, the whole FILE dies:
'yield' keyword is allowed in fixtures,
 but not in tests
# a collection error takes every other test in
# that file with it. name helpers find_tests().
hed0rah · pytest field card · side 1 of 6 hed0rah.github.io/testharness
turn over
2

pytest / test harness field card

conftest.py · pyproject.toml steal this · flags muscle memory · plugins verdicts · fixtures scope and teardown · fixture shapes
2 / 6sheet 1 back
09 conftest.py
Not a junk drawer. A plugin loaded by directory, with three jobs: isolate the process, supply specimens, extend pytest.
visibility follows the DIRECTORY tree.
nearest wins. no import needed anywhere.
repo/conftest.py       everything below
  tests/conftest.py    tests/ and below
    tests/test_x.py    a fixture here SHADOWS both
                       and nothing announces it
# where a marker becomes behaviour
def pytest_addoption(parser):
    parser.addoption("--runnet", action="store_true")

def pytest_collection_modifyitems(config, items):
    if config.getoption("--runnet"): return
    skip = pytest.mark.skip(reason="needs network")
    for item in items:
        if "net" in item.keywords:
            item.add_marker(skip)
10 pyproject.toml · steal this
[tool.pytest.ini_options]
testpaths    = ["tests"]
pythonpath   = ["src"]     # run without installing
addopts      = "-rs --strict-markers"
xfail_strict = true
markers = [
  "slow: takes real wall-clock time",
  "net: touches a real network",
]
filterwarnings = [
  # YOUR deprecations are errors. SCOPE it, or
  # someone else's release breaks your build.
  "error::DeprecationWarning:yourpkg.*",
]
-rsprint every skip REASON
--strict-markersa typo'd marker is an error
xfail_strictan xfail that passes = failure
pythonpatha fresh clone runs immediately
11 flags · muscle memory
--lf  /  --fflast-failed / failed-first  the debug loop
-xstop at the first failure
-k 'a and not b'select by name expression
-m 'not net'select by marker
--collect-onlywhat WOULD run. first thing to try
--setup-showevery fixture setup/teardown
--tb=short|line|notraceback size
-llocals in tracebacks
--pdbdebugger at the failure
--durations=10slowest ten. run monthly
-W errorevery warning becomes an error
-p no:NAMEdisable a plugin for one run
-n 4xdist. also an order-dependence detector
--co -qthe flat list of every test id
12 plugins · verdicts
pytest-covyes read it as a map, never a score
pytest-xdistyes cheapest order-dependence detector you own
hypothesisyes parsers, and anything with an inverse
anyio / asyncioone if you have async. not both
pytest-randomlyok occasionally infuriating. that is the point
freezegunlast only where you cannot inject a clock
respx / responseslast only if you cannot pass a transport
pytest-mockno thin wrapper. monkeypatch is already there
rerunfailuresno external deps only. never your own code
13 fixtures · scope & teardown
build order = DEPENDENCY order.
teardown = its exact reverse.

 outer:setup  inner:setup  TEST
              inner:teardown  outer:teardown

code after `yield` runs even if the test FAILS.
it does NOT run if setup before the yield raised.
→ put what CAN fail after what MUST be cleaned up.
scopebuilt
functionper test  default, almost always right
classper test class
moduleper file. only if IMMUTABLE
packageper directory
sessionper run. never mutable
Never make a session-scoped fixture mutable. The test that corrupts it fails a different test, in one ordering only.
14 fixture shapes
FACTORY: the test gets the FUNCTION
@pytest.fixture
def make_uf2(): return build_uf2
  beats forty fixtures named after defects

PARAMETRIZED: requester runs once per case
@pytest.fixture(params=["uf2","elf"], ids=[...])
def any_artifact(request): ...

NAMED: function name is not the fixture name
@pytest.fixture(name="digest")
def _make_digest(uf2): ...

CONDITIONAL CLEANUP: addfinalizer still wins
@pytest.fixture
def thing(request, tmp_path):
    t = tmp_path / "a"; t.write_bytes(b"")
    request.addfinalizer(t.unlink)  # only NOW
    return t
  with yield, teardown runs even when setup
  half-failed, and must defend itself.
hed0rah · pytest field card · side 2 of 6 hed0rah.github.io/testharness
next sheet
3

pytest / test harness field card

parametrize and ids · property-based · assertions · capturing output
3 / 6sheet 2 front
15 parametrize & ids
@pytest.mark.parametrize("fam,want",
    [(RP2040,"RP2040"), (NRF,"NRF52840")],
    ids=["rp2040","nrf52840"])        # explicit
    ids=lambda n: "block%d" % n       # callable
    ids=repr                          # byte specimens

STACKED = cartesian product. 2x2 = 4 tests,
ids compose: [le-elf64]. multiplies FAST:
a 4th axis of 5 is 40 tests, a 5th is 200.

pytest.param = per-case id AND marks
pytest.param(x, id="unknown",
  marks=pytest.mark.xfail(strict=True, reason="..."))

indirect: the param goes to a FIXTURE first,
so each case gets setup AND teardown
@parametrize("vault",[0,1,5], indirect=True)

pytest_generate_tests(metafunc): build the
case list at collection, from the real source
of truth instead of a copy of it.
16 property-based
propertystrength
round-tripparse(build(x))==x. strongest
invariantholds for all inputs
oraclefast impl == obvious impl
idempotencef(f(x))==f(x)
never-crashweakest. where everyone starts
@given(n=st.integers(1,12), p=st.integers(0,476))
@settings(deadline=None, max_examples=100)
def test_round_trip(n, p): ...

assume(bit64 or entry < 2**32)
  state preconditions with assume, not by
  narrowing the strategy: hypothesis tracks
  rejections and complains if the filter is
  too aggressive. a narrowed strategy just
  searches less, silently.
Shrinking is the feature. A fuzzer hands you 1,847 bytes and a traceback. Hypothesis hands you b'\x00' and the same traceback.
17 assertions
with pytest.raises(ParseError, match=r"bad magic"):
    parse(blob)
  match is re.SEARCH, not fullmatch.
  ESCAPE YOUR METACHARACTERS. "offset (0x1fc)"
  is a group. a match that matches NOTHING still
  passes if the exception type is right.

with pytest.raises(TruncatedError) as ei: ...
assert ei.value.needed == 32     # the OBJECT,
                                 # not just the class
assert 0.1+0.2 == pytest.approx(0.3)
with pytest.warns(DeprecationWarning, match="..."): ...
tells you nothing:
assert result                    E  assert None
assert len(hits) > 0             E  assert 0 > 0
tells you what broke:
assert codes(...) == ["OVERSIZE"]
E  assert ['UNSIGNED'] == ['OVERSIZE']
assert not bad, "undocumented: " + ", ".join(bad)
E  AssertionError: undocumented: ingest vault
18 capturing output
capsysstdout/stderr at the Python level
capfdat the fd level. C ext, subprocess
caploglog records
tmp_pathfresh dir per test, last 3 runs kept
tmp_path_factorythe session-scoped variant
Output missing from capsys? That is your answer. Use capfd.
caplog.set_level(logging.WARNING, logger="pkg.mod")
1. SET THE LEVEL. default capture is WARNING, so
   an info() you assert on never arrives and it
   looks like the code is wrong.

assert [r.getMessage() for r in caplog.records] == [...]
2. ASSERT ON RECORDS, NOT caplog.text. records
   carry .levelname .name .getMessage(). text is
   whatever the formatter felt like, and nobody
   thinks of a format string as an interface.

3. AND THE NEGATIVE CASE:
def test_happy_path_logs_nothing(caplog):
    assert caplog.records == []
In the package: log = logging.getLogger(__name__). Never logging.warning(...): it configures the root handler as a side effect and steals formatting from whatever imported you.
hed0rah · pytest field card · side 3 of 6 hed0rah.github.io/testharness
turn over
4

pytest / test harness field card

skip xfail the trap · the flaky test · what do I reach for? · seams the whole game · ASGI and fake clients
4 / 6sheet 2 back
19 skip · xfail · the trap
skipdoes not apply here
skipif(cond)same, decided at collection
xfailthis is broken and we know
xfail(strict=True)and tell me when it stops
xfail(raises=E)fails FOR THIS REASON
trap
@pytest.mark.xfail(reason="bug FW-118")
def test_the_bug():
    assert True        # someone fixed it
# → XPASS. exit 0. GREEN.
# marker sits there two years. the test has
# asserted nothing that whole time.
@pytest.mark.xfail(strict=True, reason="FW-118")
# → FAILED: "delete this marker."
Without raises=, an xfail absorbs every failure, including the ImportError you added this morning.
20 the flaky test
A flaky test is a bug report, against the test or the code. Never noise. Something is genuinely non-deterministic and you found it.
causefix
unseeded randomfix the seed
real clock / sleepinject a clock
shared statenarrow the scope
fixed path + -ntmp_path
real networka marker + a fake
dict / set ordersort, or compare sets
test orderrun -p no:randomly vs seeded
Quarantine, do not rerun. Move it behind a deselected marker with a ticket. --reruns 3 on your own code turns a real bug into a slower green run, permanently.
21 what do I reach for?
I need to replace a collaborator.
  do I OWN the seam?
   YES  pass a different object. INJECT.
   NO   monkeypatch it.        B4
  what KIND of replacement?
    needs canned answers only    stub
    needs to model >1 outcome    FAKE
    needs to record its calls    spy
    needs a SEQUENCE of answers  script
    needs to assert on itself    rethink

I have a lot of similar cases.
  I can name each one           parametrize
  each needs setup/teardown     indirect
  the list comes from the code  generate_tests
  hundreds, unnameable          hypothesis

I cannot write down the expected output.
  a second dumb impl exists     differential
  a trusted tool exists         oracle
  only a RELATIONSHIP holds     property

I want to know if my tests are any good.
  break the code on purpose     mutation B11
22 seams · the whole game
Feathers: a place where you can alter behavior without editing in that place. The enabling point is where you choose.
def __init__(self, url, transport=None, clock=None):
    self.transport = transport or UrllibTransport()
                     ▲ the enabling point

# the class never says urllib, socket or host.
# the test passes a different object.
# nothing is patched.
INJECT what you DO own
       your transport, clock, store, policy,
       streams, argv, the vault root
PATCH  what you do NOT own
       urllib, datetime, os.replace, a vendor lib
SPLIT  when there is nothing to pass:
       a generator that WALKS + a function that
       FOLDS is a seam too, and often a better one
23 ASGI & fake clients
async def app(scope, receive, send)
  scope    dict describing the connection
  receive  await it to pull events IN
  send     await it to push events OUT

that is the entire protocol. every test client
you have used builds a scope, feeds a receive
and collects the sends. about 20 lines.
raw harnesschunk boundaries, disconnects, malformed scopes, visible lifespan
ASGITransportsubstitutes the server
MockTransportsubstitutes what you call
TestClientwraps ANY asgi app, lifespan via with
Answer lifespan, or a test client hangs at fixture time and it looks like the framework is broken.
hed0rah · pytest field card · side 4 of 6 hed0rah.github.io/testharness
next sheet
5

pytest / test harness field card

the five doubles · monkeypatch all of it · four patching traps · hostile input · differential / oracle
5 / 6sheet 3 front
24 the five doubles
DUMMY  fills a signature, never used
STUB   canned answers, no state
FAKE   a real, working, SIMPLIFIED impl  ← this one
SPY    a stub that records its calls
MOCK   a spy that asserts on ITSELF      ← almost never
  • Fake is the only kind that can be wrong in a way a test notices. A stub returning 200 forever cannot fail to model a 404. It was never modelling anything.
  • Spy for the negative assertion. "the cache was used" is invisible in a return value and obvious in a call log.
  • Mock fails inside the double, so the message describes a call that did not happen, not a behaviour that is wrong.
  • A double that runs off the end of its script must fail loudly. One that keeps answering makes "retried 3 times" and "retried 300 times" the same passing test.
loose = Mock()          # no spec=
loose.method_that_does_not_exist()   # passes.
spec= is not optional.
25 monkeypatch · all of it
setattr(o,n,v)raises if n is absent. the guardrail
delattr(o,n)
setitem(d,k,v)sys.modules, os.environ, any table
delitem(d,k)raising=False for cleanup
setenv / delenvstrings only. be explicit
syspath_prepend+ invalidates importlib caches
chdir(p)and puts it back
context()undo EARLY, mid-test
patch where the name is LOOKED UP:
from . import families;  families.FAMILIES[x]
  read at CALL time  → patchable
from .families import FAMILIES;  FAMILIES[x]
  bound at IMPORT time into a SECOND namespace
  → patching does NOTHING, and the test PASSES
monkeypatch is FUNCTION-scoped. a session
fixture requesting it gets ScopeMismatch:
@pytest.fixture(scope="session")
def env():
    with pytest.MonkeyPatch.context() as m:
        m.setenv("HOME", "/x"); yield m
26 four patching traps
1 raising=False disables your only signal
  a patch aimed at a typo'd name is a patch
  aimed at nothing, and it passes.

2 patching a constant does not move an
  already-bound default
  @dataclass  x: int = CONST  ← evaluated ONCE
  at class creation. same for def f(x=CONST)
  and for `from mod import CONST`. to be
  patchable it must be READ at call time.

3 the string form walks getattr
  "pkg.mod.CONST" imports pkg, then getattrs
  along. if pkg re-exported a FUNCTION over
  its own submodule name, the walk dies there.
  `import pkg.mod as m` does NOT save you.
  importlib.import_module() reads sys.modules.

4 sys.modules[n] = None makes `import n` raise
  delitem FIRST. if already imported, the None
  assignment is what takes effect, and you get
  a pass for the wrong reason.
27 hostile input
the contract: for ANY bytes, either a result or
YOUR declared error. never IndexError,
struct.error, MemoryError, or a hang.

TRUNCATION SWEEP  every prefix of a valid file
  cheapest, finds the most. an interrupted
  upload is the commonest corrupt file alive.
BIT FLIPS         one byte, at FIELD boundaries
  12 named offsets beat a million random ones
SEEDED FUZZ       a FIXED seed, always
  unseeded = a flake nobody can reproduce, and
  everyone learns to just re-run CI.
VALID PREFIX + tail  gets PAST the magic check
  pure random spends 99.9% of its budget on
  the same early branch.

and check the SWEEP straddles both outcomes: a
step that never lands on a block boundary runs
twenty cases down one branch.
Every never-crash test needs a positive companion. All of the above passes against def parse(b): raise ParseError("no"). Put the companion in the same file.
28 differential / oracle
Two implementations over one corpus. The naive one is obviously correct and too slow; the real one is fast and subtle. No expected values to write, ever.
  • The reference must be independent. Shared helpers mean a shared bug cancels out and both agree.
  • Compare outcomes, not just outputs. Agreeing on the result is not enough if one raises and the other does not.
  • The corpus needs its own guard: if every specimen raises, both agree everything is broken and the test is vacuous.
  • One test per specimen, named by specimen. Adding a corpus entry then adds a test with no assertion to write. That is the economics.
  • Oracle variant: the reference is an external tool. readelf, ffmpeg, the vendor's own parser.
hed0rah · pytest field card · side 5 of 6 hed0rah.github.io/testharness
turn over
6

pytest / test harness field card

testing a CLI · meta-tests · coverage mutation CI · smells and the build order
6 / 6sheet 3 back
29 testing a CLI
def main(argv=None, stdout=None, stderr=None) -> int
  takes argv. returns an int. writes to streams
  it was HANDED. costs nothing, and it is the
  highest-leverage decision in a CLI's design.

IN-PROCESS  microseconds, every branch. the
            coverage lives here.
SUBPROCESS  four cases only, but they are the
            ones in-process CANNOT see:
  · imports from a CLEAN interpreter
  · the exit code as the SHELL sees it
  · buffering, encoding, line endings
  · a stray print() corrupting your JSON
python -I implies -E → PYTHONPATH ignored.
a cold-start import test that passes the path
via the environment gets an empty sys.path and
fails looking exactly like a packaging bug.
put the path in the -c program instead.
Exit codes are a contract. "your input is bad" and "our infrastructure is down" must be different numbers, or a red build teaches nobody anything and pages the wrong team.
30 meta-tests
A suite rots silently, and in the direction of passing. These read the test tree as data.
  • every test asserts something
  • every skip explains itself
  • no fixed paths, no path outside the repo
  • nothing silently disabled: a bare return at the top of a test leaves no skip line at all
  • no duplicate test names across files
  • no mutated module-level state
  • __all__, error codes, exit codes, schema version, route table: all pinned
  • the dependency boundary, twice: AST-walk for module-level optional imports, and sys.modules[x]=None then run it
(1) every check names the file and line it objects to. (2) every check is shown FIRING at least once, or a broken pattern passes everything forever.
scanning source? skip COMMENTS + DOCSTRINGS,
keep string LITERALS. strip every string and the
scanner cannot see the "/tmp/" it hunts; strip
nothing and it flags the paragraph explaining
the rule. both versions ship green.
31 coverage · mutation · CI
line coverage    did this line execute
branch coverage  did BOTH sides of the `if`
                 --cov-branch. line coverage
                 calls a half-tested `if` covered.
mutation score   if I BREAK it, does anything go red

only the third is a question about your TESTS.
100% line coverage is compatible with ZERO
assertions.
  • Tools: mutmut, cosmic-ray. Minutes to hours. Occasionally, never in CI.
  • Equivalent mutants are why the score is never 100%: an edit that changes no behaviour and so cannot be detected. Separating those from real holes is the manual cost.
  • Cheap version that does belong in the suite: a few hand-written mutants, each paired with the assertion that kills it.
  • A mutant is killed if the suite goes red, and an ERROR counts. Note pytest.fail.Exception derives from BaseException, so except Exception misses it.
jobs:
  bare: pip install pytest   # and NOTHING else
        # the only environment that can DISPROVE
        # a no-dependencies claim.
  full: matrix [ubuntu, windows] x [3.11, 3.13]
        fail-fast: false  # one red cell must not
                          # hide the others
No --cov-fail-under. A coverage threshold is a number people game, and the cheapest way to game it is a test with no assertions.
32 smells & the build order
n n n
smellwhat it means
more patch than assertthe code has no seam
"just re-run it"a real bug, unquarantined
assert in a fixturefailure blames the wrong file
if param == in a testtwo tests in a trenchcoat
green after deleting a checkthe check was never tested
1 give the code SEAMS   every collaborator a param
2 specimen BUILDERS    not a committed corpus
3 example tests, good ids
4 a FAKE per seam      ship it in the package
5 the NEGATIVE cases   the clean specimen that
                       must NOT warn
6 hostile input        truncation first, cheapest
7 contract tests       before your first release
8 hygiene tests        before 100 test files

patching comes LAST, and only for what you do
not own.