| test | a function whose name starts with test. It passes if it does not raise |
| assertion | a line stating what must be true. assert x == y |
| test suite | all your tests together |
| test runner | the tool that finds and runs them. pytest is one |
| collection | pytest finding your tests, before running any |
| fail vs error | fail = an assertion was false. error = it blew up before reaching one |
| AAA | arrange, act, assert. The three parts of most tests, in that order |
| TDD | write the failing test first, then the code that makes it pass |
| fixture | named setup a test asks for by putting its name in the arguments |
| factory fixture | a fixture returning a function, so a test can make several things |
| setup / teardown | before and after. In pytest, teardown is whatever follows yield |
| scope | how often a fixture is rebuilt: function, class, module, package, session |
| autouse | a fixture applied to every test without being asked for |
| conftest.py | shared fixtures for a folder and everything under it. No import needed |
| override | a fixture in a test file shadowing one of the same name from conftest |
| tmp_path | built in: an empty folder, new per test, cleaned up for you |
| capsys / capfd | built in: captured output. capfd when the write bypasses Python |
| caplog | built in: log records. Set the level or you capture nothing |
| monkeypatch | built in: change something temporarily, put back automatically |
| test id | the name in the report, e.g. test_naming[2-many] |
| parametrize | run one test many times with different inputs, each reported separately |
| indirect | send a parametrize value to a fixture, so each case gets setup and teardown |
| marker | a label on a test: @pytest.mark.slow. Select with -m |
| skip / skipif | does not apply here. skipif decides at collection |
| xfail | known broken. Needs strict=True or it stays green once fixed |
| xpass | an xfail that unexpectedly passed. Usually means delete the marker |
| deselect | excluded from this run by -k or -m. Not the same as skipped |
| test double | any stand-in used instead of the real thing |
| dummy | passed only to fill a signature. Never actually used |
| stub | always returns the same canned answer |
| fake | a real but simplified implementation. A dict instead of a database |
| spy | works, and records how it was called |
| mock | a spy that also asserts it was called correctly |
| patching | replacing something in place, by name |
| injection | passing the collaborator in, rather than the code fetching it itself |
| seam | a place you can change behaviour without editing there. An argument is one |
| mock transport | a fake put in the place where code would talk to the network |
| unit | one function or class, nothing real underneath it |
| integration | several pieces together, often with a real file or database |
| end-to-end | the whole system as a user meets it. Slow, valuable, few |
| regression test | written to pin a bug you just fixed, so it cannot come back |
| smoke test | does it start at all |
| property-based | assert a rule for all inputs and let a tool hunt counterexamples |
| fuzzing | throw generated input at it and check nothing escapes the contract |
| differential | run two implementations over one input; whichever disagrees is wrong |
| oracle | differential testing where the reference is a trusted external tool |
| golden / snapshot | compare output against a stored known-good file |
| contract test | pin the shape you publish: names, codes, status codes, schema version |
| meta-test | a test about the test suite, e.g. does every test still assert |
| coverage | which lines ran. Not whether anything checked them |
| branch coverage | did both sides of each if run |
| mutation testing | break the code deliberately and see whether the suite notices |
| equivalent mutant | a deliberate break that changes no behaviour, so nothing can catch it |
| mutation gate | admit a generated suite only if it kills a threshold of mutants |
| flaky | passes and fails without the code changing. Always a bug, never noise |
| quarantine | move a flaky test behind a deselected marker with a ticket |
| test smell | a sign the test or code is wrong. More patching than assertion is one |
| apparatus | the machinery that makes reading generated output unnecessary: tests, types, sanitizers, canaries |
| provenance separation | whatever wrote the code does not grade it, and ideally is not the same family |
| self-preference bias | an evaluator scoring its own generations higher than a human would |
| intrinsic self-correction | a model revising its own output with no external signal. Does not reliably work |
| external feedback | a signal from outside the model. A test run is one, which is the point |
| characterization test | assertions transcribed from what the code currently does. Pins change, not correctness |
| the oracle problem | knowing what the right answer is, independently of the thing being tested |
| metamorphic relation | a property linking two runs when you cannot state the answer for either |
| translation validation | proving one output matches its input for this run, rather than proving the tool |
| the intent gap | tests cannot check that they encode what was actually wanted. No technical fix |
| files | test_*.py or *_test.py |
| classes | Test*, and no __init__ |
| functions | test* 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().
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)
[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.*", ]
| -rs | print every skip REASON |
| --strict-markers | a typo'd marker is an error |
| xfail_strict | an xfail that passes = failure |
| pythonpath | a fresh clone runs immediately |
| --lf / --ff | last-failed / failed-first the debug loop |
| -x | stop at the first failure |
| -k 'a and not b' | select by name expression |
| -m 'not net' | select by marker |
| --collect-only | what WOULD run. first thing to try |
| --setup-show | every fixture setup/teardown |
| --tb=short|line|no | traceback size |
| -l | locals in tracebacks |
| --pdb | debugger at the failure |
| --durations=10 | slowest ten. run monthly |
| -W error | every warning becomes an error |
| -p no:NAME | disable a plugin for one run |
| -n 4 | xdist. also an order-dependence detector |
| --co -q | the flat list of every test id |
| pytest-cov | yes read it as a map, never a score |
| pytest-xdist | yes cheapest order-dependence detector you own |
| hypothesis | yes parsers, and anything with an inverse |
| anyio / asyncio | one if you have async. not both |
| pytest-randomly | ok occasionally infuriating. that is the point |
| freezegun | last only where you cannot inject a clock |
| respx / responses | last only if you cannot pass a transport |
| pytest-mock | no thin wrapper. monkeypatch is already there |
| rerunfailures | no external deps only. never your own code |
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.
| scope | built |
|---|---|
| function | per test default, almost always right |
| class | per test class |
| module | per file. only if IMMUTABLE |
| package | per directory |
| session | per run. never mutable |
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.
@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.
| property | strength |
|---|---|
| round-trip | parse(build(x))==x. strongest |
| invariant | holds for all inputs |
| oracle | fast impl == obvious impl |
| idempotence | f(f(x))==f(x) |
| never-crash | weakest. 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.
b'\x00' and the same traceback.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
| capsys | stdout/stderr at the Python level |
| capfd | at the fd level. C ext, subprocess |
| caplog | log records |
| tmp_path | fresh dir per test, last 3 runs kept |
| tmp_path_factory | the session-scoped variant |
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 == []
log = logging.getLogger(__name__). Never logging.warning(...): it configures the root handler as a side effect and steals formatting from whatever imported you.| skip | does not apply here |
| skipif(cond) | same, decided at collection |
| xfail | this 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."
raises=, an xfail absorbs every failure, including the ImportError you added this morning.| cause | fix |
|---|---|
| unseeded random | fix the seed |
| real clock / sleep | inject a clock |
| shared state | narrow the scope |
fixed path + -n | tmp_path |
| real network | a marker + a fake |
| dict / set order | sort, or compare sets |
| test order | run -p no:randomly vs seeded |
--reruns 3 on your own code turns a real bug into a slower green run, permanently.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
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
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 harness | chunk boundaries, disconnects, malformed scopes, visible lifespan |
| ASGITransport | substitutes the server |
| MockTransport | substitutes what you call |
| TestClient | wraps ANY asgi app, lifespan via with |
lifespan, or a test client hangs at fixture time and it looks like the framework is broken.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
loose = Mock() # no spec= loose.method_that_does_not_exist() # passes. spec= is not optional.
| 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 / delenv | strings 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
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.
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.
def parse(b): raise ParseError("no"). Put the companion in the same file.readelf, ffmpeg, the vendor's own parser.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.
return at the top of a test leaves no skip line at all__all__, error codes, exit codes, schema version, route table: all pinnedsys.modules[x]=None then run itscanning 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.
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.
mutmut, cosmic-ray. Minutes to hours. Occasionally, never in CI.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
--cov-fail-under. A coverage threshold is a number people game, and the cheapest way to game it is a test with no assertions.| smell | what it means |
|---|---|
| more patch than assert | the code has no seam |
| "just re-run it" | a real bug, unquarantined |
| assert in a fixture | failure blames the wrong file |
if param == in a test | two tests in a trenchcoat |
| green after deleting a check | the 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.