Files
firehose/doc/mutation-testing.md
T

164 lines
7.6 KiB
Markdown

# 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:
```bash
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_call` mutates `a and b` to
`b and a`, `a == b` to `b == a`, and `a || b` to `b || a`. These compile to
the same behaviour, and muex's trivial-compiler-equivalence check marks them
`equivalent` (7 of the 19 baseline mutants). They are noise from the
mutator, not survivors.
- **Removed log lines.** A removed `Logger` call 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`/`or` in 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`, not `survived`, 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.fast` raise
`mutation scope … is empty; refusing to run the mutation sensor over nothing`.
- Ad-hoc `--files` arguments 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.