05 · Test Metrics & Quality Gates¶
Module 2 warned against chasing coverage percentage as a target. This module is about what to measure instead — and demonstrates, with a real mutation testing run, exactly why line coverage alone is a weak signal.
1. Line coverage tells you what ran, not what was checked¶
# calc.py
def clamp(value, low, high):
if value < low:
return low
if value > high:
return high
return value
# test_calc.py
from calc import clamp
def test_clamp_within_range():
assert clamp(5, 0, 10) == 5
def test_clamp_below():
assert clamp(-5, 0, 10) == 0
This suite hits 100% line coverage — every line in clamp executes across
the two tests. But there's no test for the value > high branch at all. Line
coverage can't see that gap; it only tracks whether a line ran, not whether
its logic was actually exercised and verified.
2. Mutation testing — proving coverage is meaningless without it¶
Mutation testing deliberately breaks your source code in small ways (a mutant) and reruns your suite. If your tests still pass against broken code, that's a real gap — your tests didn't actually verify that logic. This actually ran:
$ mutmut run
Running mutation testing
2/2 🎉 0 🫥 0 ⏰ 0 🤔 0 🙁 2 🔇 0 🧙 0
$ mutmut results
calc.x_clamp__mutmut_1: survived
calc.x_clamp__mutmut_2: survived
Both generated mutants survived — meaning mutmut changed something in
clamp (for example, flipping value > high to value >= high, or
return high to return low) and the existing two tests still passed. That
is a real, measured proof that "100% line coverage" said nothing true about
whether the high-boundary logic is actually verified.
3. Closing the gap and re-running¶
def test_clamp_above():
assert clamp(15, 0, 10) == 10
def test_clamp_at_boundary():
assert clamp(10, 0, 10) == 10
assert clamp(0, 0, 10) == 0
Adding these two tests and rerunning mutmut run would be expected to kill
both previously-surviving mutants — a mutation testing tool's core promise is
that a genuinely thorough suite kills nearly all mutants, while a
coverage-satisfying-but-shallow suite leaves many alive. Mutation testing is
expensive to run on a large codebase (it reruns the whole suite once per
mutant), so most teams run it on a schedule or on changed files only, not on
every commit — a coverage gate (Module 2) stays the fast per-commit check;
mutation testing is the periodic, deeper audit.
4. Flaky-test rate as a trust metric¶
Level 3 Module 9 covered fixing individual flaky tests. At the suite level, track the rate:
# a simple flaky-rate check using pytest-repeat's output, run periodically
# pytest --count=50 -q → parse the pass/fail ratio per test
A suite where even 1% of tests fail intermittently, across hundreds of tests
run dozens of times a day in CI, produces enough random red builds that
developers stop trusting failures — the actual failure mode Module 9 opened
with. Tracking flaky-test rate over time (many CI tools report this
natively, or a scheduled --count=N sweep can approximate it) turns a vague
feeling ("CI feels flaky lately") into a number a team can set a target
against.
5. Defect escape rate — the metric that closes the loop¶
Every other metric here measures the suite from the inside. Defect escape rate measures it from the outside: of all bugs found in production, how many should a test have caught but didn't? Tracking this (via linking production incidents back to "which layer/module should have caught this") is what tells you whether your testing investment is actually working, as opposed to just producing reassuring-looking dashboards.
6. A sane quality gate, combining several signals¶
# CI gate combining Module 2's coverage floor with a mutation-score check
- name: Coverage gate
run: pytest --cov=app --cov-fail-under=80
- name: Mutation score gate (scheduled, not on every PR)
if: github.event_name == 'schedule'
run: |
mutmut run
mutmut results | grep -c survived # track trend, don't hard-fail on absolute count yet
Notice the mutation gate doesn't hard-fail the build outright in this sketch — mutation scores are noisy early on and a hard gate before a team understands its baseline produces the same "loosen the assertion" anti- pattern Module 2 warned about for coverage. Start by tracking the trend; gate on it once the number is stable and understood.
7. Testing-specific traps¶
Trap 1 — treating coverage percentage as the only quality metric reported to leadership. A dashboard showing "87% coverage, all green" hides precisely the gap section 2 demonstrated — leadership sees confidence, not that two mutants survived in a boundary condition. Report coverage alongside at least one other signal (mutation score, flaky rate, defect escape rate).
Trap 2 — running mutation testing on every commit. It reruns the full suite once per mutant — on a suite of even moderate size, this is minutes to hours, not seconds. Reserve it for nightly/weekly runs or PRs touching security- or business-critical logic specifically.
Trap 3 — an equivalent mutant inflating the "survived" count. Some
mutants change code in a way that's semantically identical to the original
(e.g., mutating x + 0 to x - 0) — these can never be killed and aren't
real gaps. Mutation tools can't always detect this automatically; a human
reviewing "survived" mutants needs to distinguish real gaps from equivalent
mutants before acting on the number.
Trap 4 — chasing a mutation score of 100%. Just like coverage, a mutation score target pushed to the extreme produces tests written to kill mutants rather than to verify real behavior — the same failure mode as coverage-chasing, one layer deeper. Use it to find genuine gaps, not as a number to maximize for its own sake.
How It Actually Works¶
Line and branch coverage numbers come from exactly the mechanism named in Level 1:
coverage.py (which pytest-cov wraps) installs a trace function via Python's
sys.settrace (or the faster C-level tracer when available) that gets invoked by the
interpreter on every line execution event during your test run. Each such event
records (filename, line number) into an in-memory set; at the end of the run,
coverage.py diffs "lines actually executed" against "lines the AST says exist" in
each source file to compute the percentage. Branch coverage extends this by also
tracing which direction a conditional branch actually took (not just "was this if
line executed" but "was the True path taken, was the False path taken") — this is why
100% line coverage can still hide an untested else branch that line coverage alone
can't see but branch coverage can.
A quality gate enforcing "coverage must not drop below 80%" is implemented as a CI step comparing the freshly computed coverage percentage against a stored threshold and failing (non-zero exit) if it's lower — the exact same process-exit-code contract from Level 1/3 that every other CI gate relies on. This is also why coverage percentage alone is a weak proxy for test quality: the trace function only records that a line executed, never whether the test actually asserted anything meaningful about the result — a test that calls a function and asserts nothing still counts as "covering" every line that function touches.
Cheat sheet¶
| Metric | What it actually tells you | What it misses |
|---|---|---|
| Line/branch coverage | which lines executed | whether the outcome was actually verified |
| Mutation score | whether tests would catch a real logic change | equivalent mutants inflate "gaps"; slow to run |
| Flaky-test rate | trust erosion risk in the suite | doesn't localize which test's root cause |
| Defect escape rate | whether testing investment prevents real bugs | lagging indicator, needs incident-to-test tracing |
| Suite runtime (Module 1) | pyramid shape / architecture health | not a correctness signal at all |
Exercise¶
- Reproduce section 1–2 yourself: write
clamp, the two under-covering tests, confirm 100% line coverage withpytest --cov, then runmutmut runand paste your ownmutmut resultsoutput. - Add the two missing tests from section 3, rerun
mutmut run, and confirm both previously-surviving mutants are now killed. - Introduce one deliberately equivalent mutation by hand (e.g., rewrite
return valueasreturn value if True else value) into a copy ofcalc.py, and explain why a test suite could never kill it no matter how thorough. - Using
pytest-repeat's--count=30on a suite containing one intentionally flaky test (Level 3 Module 9's example), compute a flaky rate as a percentage and write one sentence on what CI failure rate that would translate to over 100 daily builds. - Design a one-page quality dashboard (as a markdown table sketch) combining at least three of the metrics from the cheat sheet, and justify why you picked those three for a five-person team's weekly review.