7.6 KiB
Mutation testing with muex
Mutation testing is a sensor, not a gate. It deliberately breaks
production code one mutant at a time and re-runs the suite: a mutant killed
by a test is evidence the test pins that behaviour; a mutant that survives
is a question for a Lens session. Runs always pass --fail-at 0, so a survivor
is reported and triaged, never a failing build.
Nothing here is wired into VERIFY_SENSORS. make check, make verify,
coverage floors and .githooks/pre-push are unchanged by this yak.
How to run it
Both apps declare two aliases in mix.exs; both read a plain-text scope file
at run time, so the file stays the single source of truth.
| Command | Scope | Blogex wall clock (2026-10-28) |
|---|---|---|
mix mutate.fast |
mutation_scope_fast.txt (3 modules) |
~3 s |
mix mutate |
mutation_scope.txt (8 modules) |
~4 s |
The app declares the same aliases but both scope files are empty (see
"Scope: app" below), so mix mutate there raises a clear Mix error instead of
running a sensor over nothing.
Extra arguments pass through to muex, so a one-off target is explicit:
cd blogex
mix mutate --files lib/blogex/seo.ex # one module, ad hoc
mix mutate.fast --no-optimize # full mutant list, slower
The aliases pin --fail-at 0 first, so an ad-hoc flag cannot turn the sensor
into a gate. mix mutate does not enable --cover: muex runs each mutant's
suite under MIX_ENV=test, and coverage would let a code-deleting mutant trip
the project's coverage floor and abort the run.
Scope: blogex
In scope (mutation_scope.txt) |
Why |
|---|---|
lib/blogex/link_validator.ex |
Pure link parsing/validation; the yak's example module. |
lib/blogex/seo.ex |
Pure sitemap/JSON-LD/OpenGraph string building. |
lib/blogex/feed.ex |
Pure feed generation. |
lib/blogex/post.ex |
Pure post parsing/visibility helpers. |
lib/blogex/external_links.ex |
Pure HTML rewriting for external links. |
lib/blogex/registry.ex |
Module-registry lookup; no I/O. |
lib/blogex/not_found_error.ex |
Exception definition. |
lib/blogex/link_error.ex |
Exception definition. |
| Out of scope | Why |
|---|---|
lib/blogex.ex |
Facade + moduledoc: delegation and docs, no logic to mutate. |
lib/blogex/blog.ex |
A defmacro __using__ that expands NimblePublisher; mutating macro bodies produces noise, not behaviour. |
lib/blogex/router.ex |
Phoenix router DSL (use, scope, get); macro-generated. |
lib/blogex/components.ex |
Phoenix component DSL (use, attr, slot, ~H). |
lib/blogex/layout.ex |
LiveView layout DSL. |
mutation_scope_fast.txt is a subset (link_validator, seo, post) so the
inner loop stays seconds long; a pin in
test/mutation_scope_test.exs fails if it ever drifts outside the audited
scope. Another pin fails if any scoped path stops existing, so a rename cannot
silently leave the sensor reporting a score for a reduced set.
Mutators
The aliases pass an explicit curated set (muex 0.12 CLI names):
arithmetic, boolean, comparison, conditional, function_call, literal, return_value
Macro/use-based mutators are deliberately absent. The scoped modules are plain
functions, and the macro-heavy modules are out of scope; macro mutations produce
false-positive survivors rather than behavioural questions. The arithmetic,
boolean, comparison and conditional families are the behavioural core;
function_call, literal and return_value catch removed calls, changed
constants and stubbed return values.
Baseline (blogex, 2026-10-28)
Command: mix mutate (default muex optimization, --fail-at 0).
Result: 19 mutants, 12 killed, 0 survived, 0 invalid, 7 equivalent — 100%.
Wall clock: 3.5 s. The run that found the survivor below was 11 killed / 1
survived (91.67%) before its pin landed.
| Module | Mutants | Killed | Survived | Equivalent |
|---|---|---|---|---|
link_validator.ex |
7 | 4 | 0 | 3 |
external_links.ex |
6 | 5 | 0 | 1 |
registry.ex |
4 | 1 | 0 | 3 |
post.ex |
1 | 1 | 0 | 0 |
seo.ex |
1 | 1 | 0 | 0 |
feed.ex |
0 | 0 | 0 | 0 |
not_found_error.ex |
0 | 0 | 0 | 0 |
link_error.ex |
0 | 0 | 0 | 0 |
muex's file analyser gives not_found_error.ex a complexity score of 0 and
skips it under the default filter. The scope file still lists it: it is pure
code and stays audited if it grows logic. feed.ex is analysed but the curated
mutator set generates nothing for it.
Survivors found and killed
| Mutant | Resolution |
|---|---|
link_validator.ex:201 — Boolean: and to or in validate_body/3's guard when is_binary(body) and is_atom(blog) |
Genuine: the mutant let a string blog id through the guard. Pinned by two FunctionClauseError tests in test/blogex/link_validator_test.exs; mix mutate.fast then reports 0 survivors. |
False-positive classes
These are not gaps in behaviour; do not weaken a test to "kill" them.
- Commutative argument swaps.
function_callmutatesa and btob and a,a == btob == a, anda || btob || a. These compile to the same behaviour, and muex's trivial-compiler-equivalence check marks themequivalent(7 of the 19 baseline mutants). They are noise from the mutator, not survivors. - Removed log lines. A removed
Loggercall changes no return value; the suite cannot observe it. Such a mutant is documented here instead of adding a log-capture assertion that pins an implementation detail. and/orin guards is not automatically a false positive. The baseline survivor above was a real guard-contract gap; only commutative rewrites are equivalent.- Invalid mutants. Mutated code that does not compile is counted
invalid, notsurvived, and excluded from the score.
Deeper runs
muex's default balanced optimization reduced 1304 generated mutants to 12
tested ones (99.1%) on the blogex full scope. That is what keeps the sensor in
seconds, but it is sampled, not exhaustive. mix mutate --no-optimize tests
everything; on the three-module fast scope it took ~2 min 15 s and produced
434 mutants (329 killed, 19 survived, 83 invalid, 3 equivalent) before the
guard pin. Use it when triaging a specific module, not in a loop.
Re-baseline after a yak that changes the scoped modules (notably the Boundary
work, which adds use lines inside them); do not compare scores across such a
change.
Scope: app
Deliberately empty; no app baseline was run. The candidate pure modules
are Firehose.Accounts.User, Firehose.Accounts.Scope and
Firehose.Accounts.UserToken, but every test that exercises them lives in
test/firehose/accounts_test.exs, which uses Firehose.DataCase, fixtures and
Repo; the SQL sandbox needs PostgreSQL. The yak's Q45 decision was: use those
modules only after confirming their tests need no database, otherwise leave the
app scope empty. They need one, so the scope is empty.
Consequences, all pinned by app/test/mutation_scope_test.exs and
app/test/mutation_config_test.exs:
- Both app scope files list nothing, so
mix mutate/mix mutate.fastraisemutation scope … is empty; refusing to run the mutation sensor over nothing. - Ad-hoc
--filesarguments do not bypass the guard: the scope is validated before muex arguments are built. - No scope entry may ever begin with
lib/firehose_web(controllers, LiveViews, components).
There is no app mutation baseline to report. Do not add one until the scoped modules have tests that run without the database; change the scope file and these pins in the same commit.