AKERR_MAX_ERR_VALUE was AKERR_LAST_ERRNO_VALUE + 15, but the highest defined code, AKERR_BADEXC, is + 17 (AKERR_NOT_IMPLEMENTED is + 16). akerr_name_for_status rejects any status above the max, so those codes could never have a registered name and the AKERR_BADEXC registration in akerr_init was dead code -- a gap found by mutation testing. Bump the max to + 17. - err_maxval: new test asserting the reserved AKERR_* range exceeds the number of AKERR_* codes and that every code is individually indexable. Fails against the old + 15 value (verified), guarding against regression. - err_error_names: now also checks AKERR_BADEXC's name, which the fix makes reachable. Mutation score on src/error.c rises 71% -> 74%: the previously-dead BADEXC registration and the name_for_status upper-bound check are now killable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4.2 KiB
Mutation testing
The unit tests tell us the library works. Mutation testing tells us the tests work — that they would actually fail if the library were broken.
scripts/mutation_test.py deliberately breaks the library in small ways
("mutants"), one at a time, and runs the whole CTest suite against each broken
copy:
- if the tests fail, the mutant is killed — good, the suite caught it;
- if the tests still pass, the mutant survived — a bug of that shape would slip through, so it points at a missing test.
The mutation score is killed / (killed + survived). A surviving mutant is
a to-do item: write a test that distinguishes the mutant from the original.
Running
No third-party tools are required — just Python 3 and the normal cmake/ctest toolchain. The harness never touches your working tree; it copies the repo to a scratch directory and mutates the copy.
# Default: mutate src/error.c and include/akerror.tmpl.h
scripts/mutation_test.py
# Faster: just the C source
scripts/mutation_test.py --target src/error.c
# See what would run without building anything
scripts/mutation_test.py --target src/error.c --list
# Gate CI: exit non-zero if the score drops below 90%
scripts/mutation_test.py --threshold 90
Via CMake (configures a build first if needed):
cmake --build build --target mutation
Useful flags: --timeout SECONDS (per-suite build+test cap; a mutant that
hangs is counted as killed), --keep (retain the scratch copy for debugging),
--work DIR (use a specific scratch directory).
Mutation operators
Each mutant changes exactly one location by one of:
| Tag | Operator | Example |
|---|---|---|
| ROR | relational operator | == → !=, < → <=, >= → > |
| LCR | logical connector | && → || |
| BCR | boolean constant | true → false |
| AOR | arithmetic / compound assign | + → -, += → -= |
| ICR | integer literal | 0 → 1, 1 → 0 |
| SDL | statement deletion | err->refcount += 1; → (removed) |
Preprocessor control lines, comments, and the block of error-code / buffer-size
#defines are skipped: mutating those produces equivalent or uninteresting
mutants that only add noise.
Interpreting survivors
Not every survivor is a test gap — some mutants are equivalent (they don't change observable behaviour, e.g. resizing an internal scratch buffer). For each survivor, decide:
- Real gap → add or strengthen a test in
tests/so the mutant is killed, then re-run. - Equivalent mutant → no test can catch it; leave a note. If a specific
line is a persistent source of equivalents, narrow the target with
--targetor extend the skip rules inscripts/mutation_test.py.
Re-run after adding tests and confirm the score went up.
Current status
src/error.c scores ~74% (the CI gate is set to 65% for headroom). The
remaining survivors are dominated by:
- Equivalent mutants in
akerr_init: deleting thememset/NULLsetup of file-scope statics (AKERR_ARRAY_ERROR,__akerr_last_ditch,__akerr_last_ignored) changes nothing, because C already zero-initializes objects with static storage duration.int oldid = 0;→1is likewise dead: it is overwritten before use. - Default logger / handler internals (
vfprintf,va_end, theerrctx == NULLbranch,exit(1)): killing these needs a subprocess-based test that captures a child's stderr and exit code, rather than the in-process capturing logger the other tests use.
Findings surfaced by mutation testing:
- Fixed:
AKERR_MAX_ERR_VALUEwasAKERR_LAST_ERRNO_VALUE + 15, belowAKERR_NOT_IMPLEMENTED(+16) andAKERR_BADEXC(+17).akerr_name_for_statusrejects any status> AKERR_MAX_ERR_VALUE, so those codes could never store or return a name and theakerr_name_for_status(AKERR_BADEXC, ...)call inakerr_initwas dead code (which is why deleting it survived). The max is now+ 17, andtests/err_maxval.cguards the invariant so it can't regress.