Mutation testing¶
cuere keeps 100% branch coverage, but coverage only proves a line ran — not
that a test would fail if its behavior changed. We use
mutmut to measure that: it makes small
edits ("mutants") to the source and checks whether the test suite catches each
one. A surviving mutant is a hole in the assertions, not the coverage.
Mutation testing is run locally, never in CI — it is slow and the surviving
set is reviewed by hand (some mutants are equivalent; see below). Config lives in
pyproject.toml under [tool.mutmut].
Running it¶
Mutmut must run with cuere uninstalled. meson-python's editable install
registers a sys.meta_path import hook that shadows the conftest.py
sys.path insert, so with the package installed import cuere resolves to the
real src/cuere instead of the mutated copy under mutants/, and mutmut reports
"could not find any test case for any mutant". Uninstall the project first, then
run mutmut without letting uv run re-sync it:
uv sync --no-install-project # install deps but NOT cuere itself
uv run --no-sync mutmut run # mutate src/cuere/ and run the suite per mutant
uv run --no-sync mutmut results # list survivors
uv run --no-sync mutmut show <id> # one mutant's diff, e.g. cuere.terminal.x_fits__mutmut_24
uv sync # restore the editable install when done
The root conftest.py puts the in-tree src/ on sys.path so that — with the
package uninstalled — import cuere resolves to the mutated copy mutmut places
under mutants/ (gitignored). The mutmut config clears cuere's coverage/junit
addopts and deselects three install-dependent tests (subprocess re-import +
distribution metadata) that cannot pass with the project run uninstalled, as
mutation runs require.
Type-check pass (mypy)¶
Before running tests, mutmut runs mypy once over the whole mutant tree
(type_check_command in [tool.mutmut]). A mutant that introduces a type
error — e.g. passing None to a parameter typed bool/RenderMode — is
"caught by the type checker" (🧙 in the progress line) and skipped from the test
run. This both speeds the run up and catches defects no runtime test needs to.
It uses a relaxed config, mutmut-mypy.ini, not the project's strict
settings ([tool.mypy]). mutmut's own scaffolding — the
mutmut.mutation.trampoline import, the untyped trampoline decorator, and
injected # type: ignore comments — would otherwise produce errors on
non-mutant lines, and mutmut crashes if any reported error does not fall inside a
mutant function. The relaxed config (ignore_missing_imports, no strict-only
warnings) reports only genuine type errors inside the mutant bodies. mypy's
JSON-lines output (--output=json) is required by mutmut's parser. A
consequence: the baseline must type-check clean (it does — see the main CLAUDE.md
gate), or mutmut will fail to map an error to a mutant.
Hypothesis under mutmut¶
tests/conftest.py loads a dedicated mutmut hypothesis profile (selected when
mutmut sets MUTANT_UNDER_TEST) that disables the shrink phase. Under
mutation testing a failing example already means the mutant is caught, so
shrinking it is pure overhead that can overrun mutmut's per-mutant timeout and
turn a clean kill into a flaky ⏰. Generation is unaffected, so detection is
unchanged; normal pytest runs keep full shrinking.
Current status¶
As of the suite in this branch: 219 mutants — 99 killed by tests, 116 caught by the type checker, 4 survivors (~98.2% caught). All 4 survivors are equivalent mutants that are also type-safe (so mypy cannot catch them either) — the mutated code is behaviorally identical to the original, so no test can distinguish it without asserting something artificial or weakening the code. They are listed below and accepted for now. This is not cemented: a later iteration may revisit whether any can be removed by a small refactor.
(mutmut's numeric mutant IDs shift whenever the source changes, so the table
describes the mutation, not a fixed __mutmut_N id.)
Accepted equivalent mutants¶
| Mutation | Why it is equivalent |
|---|---|
render._half_lines: zip(top, bottom, strict=True) → strict=False / strict removed |
top and bottom are always equal length by construction — bottom is either the next matrix row (rectangular) or an explicit len(top) light row — so strict can never trip. Kept as a defensive invariant. (The strict=None variant is caught by the type checker.) |
terminal.show: warnings.warn(..., stacklevel=2) dropped / changed to 3 |
stacklevel only affects the file/line the warning is attributed to; it is not observable from the warning object, so no meaningful assertion exists. |
Several other None-substitution mutants (e.g. _half_lines's strict=None,
show's render_mode=None) are caught by the type-check pass rather than by a
test — each passes None where a concrete type is required, which mypy rejects.
(An earlier run also had two fits boost_error equivalents; the encode-options
DRY removed those per-call mutation sites, so the kwarg is now only mutated once,
in QRMatrix.encode.)
If a future run reports a new survivor not in this table, it is a real gap — add a test that pins the affected behavior rather than extending this list.