Files
libakerror/tests/MUTATION.md
Andrew Kesterson 10f7203e8f
All checks were successful
libakerror CI Build / cmake_build (push) Successful in 2m41s
libakerror CI Build / mutation_test (push) Successful in 6m54s
Fix AKERR_MAX_ERR_VALUE to cover all AKERR_* codes
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>
2026-07-27 17:17:26 -04:00

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 truefalse
AOR arithmetic / compound assign +-, +=-=
ICR integer literal 01, 10
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:

  1. Real gap → add or strengthen a test in tests/ so the mutant is killed, then re-run.
  2. Equivalent mutant → no test can catch it; leave a note. If a specific line is a persistent source of equivalents, narrow the target with --target or extend the skip rules in scripts/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 the memset/NULL setup 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;1 is likewise dead: it is overwritten before use.
  • Default logger / handler internals (vfprintf, va_end, the errctx == NULL branch, 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_VALUE was AKERR_LAST_ERRNO_VALUE + 15, below AKERR_NOT_IMPLEMENTED (+16) and AKERR_BADEXC (+17). akerr_name_for_status rejects any status > AKERR_MAX_ERR_VALUE, so those codes could never store or return a name and the akerr_name_for_status(AKERR_BADEXC, ...) call in akerr_init was dead code (which is why deleting it survived). The max is now + 17, and tests/err_maxval.c guards the invariant so it can't regress.