Mutation Testing¶
Mutation testing measures whether the test suite actually constrains behavior:
mutmut systematically edits the source (an if a becomes
if not a, a + becomes -, a literal 1 becomes 2) and re-runs the tests. A mutant that
tests still pass against is a survivor: a change to production behavior that nothing detects.
A survivor is either a hole in the tests or an equivalent mutant (an edit that cannot change
any observable behavior, so no test could ever catch it).
The goal is to drive each package to zero non-equivalent survivors: close every real hole with a test, and be left only with equivalent mutants that no test could kill. This file is the source of truth for which survivors are equivalent and why, so a run that finds them does not mistake them for test holes.
Run it per package (it must run inside the package for the src/ layout to resolve):
$ just mutate without-dag # run every mutant
$ just mutate without-dag results # list survivors from the last run
$ just mutate without-dag browse # interactive TUI
just mutate-all sweeps every package with the same interface (just mutate-all to run,
just mutate-all results to list survivors), printing each package under its own header and
a final ok/FAILED summary table.
The mutate recipe writes the per-run mutmut setup.cfg; its comments explain each setting
(why -n0, the no_mutation marker filter, the assert_never skip pattern).
A package whose source is pure pass-through generates no mutants at all (without-env, whose
EnvContext has no operators, literals, or branches to mutate). mutmut hardcodes exit(1) in
that case with no config knob to allow it, so the mutate recipe absorbs it: a run that mutates
zero files reports success rather than FAILED. A run that does build mutants but leaves them
uncovered still fails, since that is a genuine test hole.
Equivalent-mutant categories¶
When a survivor is not a test hole, it falls into one of the categories below. Each is genuinely unkillable (or unreachable by mutmut's own suppression), with a concrete example.
Match-case drops¶
The category mutmut structurally cannot suppress. mutmut's operator_match mutates a match by
dropping one case at a time, applied to the whole Match node. Its pragma/pattern suppression
only fires on BaseExpression nodes, so no # pragma: no mutate can suppress a dropped case,
and neither can the do_not_mutate_patterns regex (also gated to expressions).
Dropping an exhaustiveness default is equivalent because the default is unreachable for valid
input. Every closed match ends this way:
match event:
case HttpResponseStart(...): ...
case HttpResponseBody(...): ...
case _ as unreachable:
assert_never(unreachable) # dropping this case: valid input still matches a real arm above
assert_never only runs if a value outside the type reaches it, which the type system forbids.
Dropping the arm changes behavior only for input that "cannot happen", so it is equivalent. (The
assert_never expression mutation — assert_never(unreachable) → assert_never(None) — is
suppressed by the recipe's skip pattern; only the whole-case drop survives.)
Dropping a redundant case is likewise equivalent when the fallthrough does the same thing: a
trailing case whose body is a no-op (a bare continue at the bottom of a loop) behaves identically
whether present or dropped. Prefer deleting such a case outright, so it falls through to a documented
comment and mutmut has nothing to drop, rather than leaving it as a survivor to explain here.
Loop control the loop condition already decides¶
continue and break are interchangeable where the loop's own condition is already
false, which mutmut swaps freely. without-dag's scheduler ends this way, in drive:
while sorter.is_active():
while ready and (limit is None or len(running) < limit):
...spawn each ready node...
if not running:
continue # mutant: continue -> break
done = await completed.get()
Reaching that guard means the fill loop drained ready (it exits on a full limit
otherwise, and then running is non-empty), and every node the sorter had passed out
has been marked done, so is_active() is false and continue leaves the loop
immediately. break leaves it too. The guard exists for the run where a checkpoint
supplies a graph's last nodes: without it, nothing is in flight and completed.get()
waits forever.
Note the sibling mutation one loop up, continue -> break on the branch that skips an
already-supplied key, is not equivalent: it stops filling early, so a supplied node
delays its ready siblings by a whole completion round. That one is killed by
test_drive_keeps_filling_past_a_node_whose_result_is_supplied, which pins the
property that resuming from a checkpoint does not serialize the work that is left.
suppress() of a subclass alongside its base¶
without-http's HTTP/2 path guards several defensive operations with:
StreamClosedError is a subclass of ProtocolError, so suppress(ProtocolError) already
catches it. Mutations that drop StreamClosedError or replace either argument with None are
equivalent: the set of caught exceptions is unchanged. The variant that drops the base differs
only if a non-StreamClosed ProtocolError is raised inside — but these blocks wrap a stream
reset / bad-request send / data ack that only raises on an already-doomed stream, not
deterministically reachable from a test.
Boolean / sentinel / sort-key equivalents¶
A value read only in a boolean context is equivalent under True/False/None swaps that
preserve truthiness:
# without-web/router.py — multi_segment is only read as `not multi_segment`, so None == False
def _render_value(..., *, multi_segment: bool) -> str:
if not multi_segment and "/" in rendered: # mutant: multi_segment=False -> None, both falsy
...
A sort key is equivalent under any change that preserves the ordering, not the value:
# without-web/trie.py — used only as `sorted(..., key=_param_precedence)`
def _param_precedence(item) -> int:
return 1 if converter.name == "str" else 0 # mutant: 1 -> 2. Values are {0, str}; 0 < 2 orders
# identically to 0 < 1, so the sort result is unchanged
Others in this class: authority = b"" -> None when authority is only read as if authority;
request_done = True -> None / more_body=False -> None where the flag feeds only an if.
Codec-name case swaps¶
The wire codecs are named once per module (_ASCII = "ascii", _LATIN1 = "latin-1") rather than
repeated at each .encode/.decode call. mutmut's operator_string mutates that one literal three
ways: the "XXasciiXX" wrap is an invalid codec (LookupError) and the .decode(None) at each call
site is a TypeError, both killed by any test that exercises the path. Only the case swap
("ascii" → "ASCII") survives, because codec lookup is case-insensitive so the emitted bytes are
identical. That leaves one survivor per constant, in h11_wire, h2_wire, ws_wire, server, and
client (without-http) and files (without-asgi). Hoisting the name is what keeps this to one
documented survivor per module instead of a # pragma: no mutate on every call site (which would
also blind the killable LookupError/TypeError mutants).
Unobservable defensive-path mutations¶
Defensive code can carry mutations that produce no observable difference. From without-http's
HTTP/2 server:
- Log-message text.
logger.warning(f"...") -> logger.warning(None)inside a# pragma: no coverbranch (untracked-stream handlers h2 rejects before they run). The log line changes; no behavior does, and there is no caplog assertion convention. - Header case.
(b":status", ...) -> (b":STATUS", ...)on an error response. h2'snormalize_outbound_headerslowercases header names on the wire, so the emitted bytes are identical (verified empirically). writer.write(None). Crashes the per-stream task, but the buffered response already sits in the sharedh2.Connectionand is re-flushed on the nextreceive_data, so the client still gets it (verified empirically).- Post-close / racing internal state.
streams.pop(None, None)leaks a finished stream into a dict that only gets a harmlesswindow.set();events = [] -> Nonecrashes after the GOAWAY is written and the socket closed. Neither changes what the client observes, and whether the internalTypeErrorsurvives to teardown or is pre-empted byCancelledErroris a race with no timing-free kill. - Unreachable defaults.
next((v for n, v in headers if n == b":method"), b"")— the default is unreachable because h2 rejects a request with no:methodbefore this code runs. and→oron the crash-to-500 guard.without-http's_run_requestends withif not response_done and conn.our_state is h11.SEND_RESPONSE:. Theormutant differs only when the app already sent response headers (stateSEND_BODY, so theisclause is false): it then calls_send_simple, whosesuppress(h11.ProtocolError, OSError)swallows the illegal second-response send, writing nothing._send_simplecan only emit when the state isSEND_RESPONSE, exactly whenandalso fires, soornever produces an observable 500 thatandwould not. (Theis→is notandnot response_done→response_donemutants on the same line are real behavior changes, killed by the crash tests.)
Killing test excluded by the trampoline¶
mutmut rewrites every function into a trampoline that dispatches to the original or a mutant. That
trampoline does not run an async generator's aclose()-triggered finally. A test that
asserts exactly that teardown fails the mutmut baseline while passing the real suite, so it is
marked @pytest.mark.no_mutation and excluded. A mutant whose only killing test is excluded then
survives — e.g. in without-http's _with_release, if not fully_read: → if fully_read: is a
real behavior change the excluded test would otherwise catch.
Writing tests that kill mutants¶
Patterns that generalize (the mutation-testing skill has the full method):
- Assert concrete output, not that it "works". A test that asserts an exact parsed value, the
exact response bytes, or the exact raised message kills every field/keyword/literal mutation in
one shot.
assert head.status == 500kills the:statusvalue mutant; asserting a whole frame dict kills every key mutation in it. - Anchor error-message matches.
pytest.raises(match="at least one sink")still matches theXX-wrapped string mutation of that message. Usematch=r"^tee requires at least one sink$"so the exact text is load-bearing. - Hit the exact boundary.
>= max_age→> max_ageonly diverges at equality: drive the injected clock to exactlyopened + max_age.limit < 1→<= 1only diverges atlimit == 1. These are deterministic, never timing-based. - Use distinct non-default values. A field set to
0,"", or the first enum member can make a broken function pass by coincidence; give each field a different, non-default value so an argument swap surfaces.
Considered and rejected: the type-checker filter¶
mutmut's type-checker filter
(type_check_command) runs mypy over the whole mutated tree once and marks any mutant that
produces a type error as caught, without running it against the suite. It is deliberately not
enabled here, for two reasons.
It removes none of the suppression machinery. Every # pragma: no mutate in this codebase guards
a mutation that is type-valid but behavior-equivalent: truthiness (bool(msg.get(k, False)) →
None), a value that equals a field default, a timing-only buffer depth, zip(strict=True) where
lengths are always equal. mypy sees no error in
any of these, so the pragmas stay. The filter only catches type-invalid mutations (assigning
None into a non-optional slot, cast(T, x) → cast(None, x)), a set almost disjoint from what
the pragmas suppress. It would auto-catch a few hand-documented equivalent survivors (the
multi_segment=False → None on a bool parameter, writer.write(None)), but that is a small,
package-specific win, not a reduction in pragmas.
It cannot run on without core at all. mutmut's trampoline rewrite of the @overload functions in
wiring.py makes mypy report "an overloaded function outside a stub file must have an
implementation" on a line the filter cannot map back to any mutant, so
filter_mutants_with_type_checker raises and aborts the whole package run. This fires even with
every pragma intact, so the filter can't be turned on uniformly through the shared mutate recipe.