Mutalisk
Modern Elixir mutation testing. Mutalisk makes small changes ("mutants") to
your source — flip a < to <=, swap Enum.filter for Enum.reject, drop a
clause — then runs your test suite against each one. If a test fails, the
mutant is killed (your tests caught the bug). If every test still passes,
the mutant survived — a real gap in your suite's coverage that line
coverage can't see.
Install
Add to your mix.exs:
defp deps do
[
{:mutalisk, "~> 0.1", only: [:test], runtime: false}
]
end
Run MIX_ENV=test mix deps.get.
Requires Elixir 1.19 or newer. Mutalisk is validated on Linux and macOS;
child-process cleanup uses Unix tools (ps, pgrep, and kill). Windows
is not currently tested.
Usage
MIX_ENV=test mix mut
That builds the mutants, runs your tests against each, prints a summary, and
writes stryker.report.json (viewable in the
Stryker mutation testing elements HTML viewer).
Because the dependency is only: [:test], the task only exists under the test
environment — always run it with MIX_ENV=test. runtime: false keeps Mutalisk out of your app's normal test start
graph; mutation workers enable the runtime dependency in their own overlay.
Plain mix mut in the default env fails with "task could not be found".
mix mut exits 1 when the mutation score is below 80%, so CI fails by
default. Change the threshold with --fail-at or fail_at: in config;
--fail-at 0 turns the gate off.
Useful flags (see MIX_ENV=test mix help mut or MIX_ENV=test mix mut --help for the full
list):
MIX_ENV=test mix mut --files "lib/my_app/core/**/*.ex" # only mutate some files
MIX_ENV=test mix mut --concurrency 8 # parallel workers
MIX_ENV=test mix mut --fail-at 70 # exit non-zero below 70%
MIX_ENV=test mix mut --reporters terminal,html # pick reporters
Mutalisk runs your suite as-is (it does not pass --warnings-as-errors), so
compiler warnings in the target do not affect the mutation score. One exception:
fallback-engine recompiles load your project and honour elixirc_options: [warnings_as_errors: true] from mix.exs, exactly as mix compile would — a
mutation that introduces a warning on such a project is reported CompileError
(invalid), not scored. Schema-engine mutants are compiled once with the real
mix compile and are unaffected. Keep your own mix compile --warnings-as-errors gate in CI if you rely on it.
Interpreting the score
The terminal summary reports a mutation score and a per-status breakdown:
- Killed — a test failed on the mutant. Good: your suite caught it.
- Survived — all selected tests passed on the mutant. A gap: no test distinguishes the mutated behaviour from the original.
- Timeout — the mutant made a test exceed its time budget (usually an infinite loop the mutation introduced). Counted as detected (like killed).
- CompileError / RuntimeError — the mutant didn't compile, or crashed the run for reasons unrelated to a test assertion. Excluded from the score.
- NoCoverage — no selected test executed for the mutant. Counted as undetected, like a survivor, because the report cannot prove a test guards it.
- Ignored — skipped (unsupported target,
@mutalisk_ignore, or anexcludematch). Excluded from the score.
The score is detected / (detected + survived + no_coverage) where
detected = killed + timeout. CompileError, RuntimeError, and Ignored mutants
are excluded from both — they don't measure test quality. This matches the
score the Stryker HTML viewer derives from the same report.
Handling surviving mutants
Each surviving mutant is a specific, located suggestion. For each one, pick:
- Write or strengthen a test (the usual answer). The survivor tells you
exactly which behaviour is unguarded — e.g. "replace
<with<=onlib/foo.ex:12survived" means no test pins the boundary. - Decide it's equivalent — the mutation produces behaviour that is genuinely indistinguishable (e.g. a defensive branch the spec doesn't constrain). Mutation testing can't decide equivalence for you; some survivors are expected.
- Exclude it from mutation when the code is intentionally untested
(generated/DSL modules, glue): add
@mutalisk_ignore trueto the module (see below), or anexcludepath pattern in config.
Use --reporters html for a clickable report of every survivor with its
source line and mutation, or --reporters github-actions to get inline PR
annotations in CI.
Source-level ignores
Add @mutalisk_ignore true anywhere in a module's body to exclude every
mutant in that module:
defmodule MyApp.Generated do
@mutalisk_ignore true
# ... no mutants are produced here ...
end
Configuration
Settings layer, lowest to highest precedence:
.mutalisk.exs project file < legacy config :mut < config :mutalisk < CLI flags
(config :mut is a deprecated namespace kept for compatibility; prefer
config :mutalisk.)
.mutalisk.exs (project root, optional) is a plain keyword list:
# .mutalisk.exs
[
selection: :coverage_with_static_fallback,
fail_at: 75.0,
concurrency: 8,
exclude: [~r"lib/my_app_web/router.ex"]
]
Or config :mutalisk, ... in config/test.exs. CLI flags override both. Run
MIX_ENV=test mix help mut or MIX_ENV=test mix mut --help for every key and flag.
Mutators
13 low-noise mutators run by default; 17 more are opt-in or explicit-only. See docs/MUTATORS.md for the full catalogue and enablement rules.
Reporters
terminal(default) — the summary printed to stdout.stryker-json(default) —stryker.report.jsonfor the Stryker HTML viewer.html(opt-in) — a self-containedstryker.report.htmllisting survivors.github-actions(opt-in) —::warningannotations for inline PR comments.
Select with --reporters a,b or config :mutalisk, reporters: [...]. Config may
use atoms such as :stryker_json; CLI examples use the hyphenated names.
Per-test timeout policy
Mutalisk runs ExUnit with a 10 000 ms per-test timeout (not ExUnit's
60 000 ms default), passed as mix test --timeout 10000. Override with
--test-timeout-ms or config :mutalisk, test_timeout_ms: N.
Mutation-introduced bugs are usually infinite loops or unbounded recursion —
1–10 s is plenty of evidence to classify them, and a 60 s timeout per such
mutant dominates wall-clock. If a test legitimately needs longer under no
mutation, tag it (@tag timeout: 60_000); ExUnit per-test tags override the
global default.
Whole-suite (host) deadline
The per-test timeout bounds one test; the host also bounds the whole selected suite for a mutant and kills the test port when that budget elapses. Because several individually valid slow tests can exceed a budget sized for one test — and the resulting host timeout would be scored as a detection, inflating the score — the host deadline is derived from the measured baseline run:
max(test_timeout_ms, min(baseline_wall_ms * 2, 600 000)) + 10 000 ms
Set --suite-timeout-ms N (or config :mutalisk, suite_timeout_ms: N, range
1 000..3 600 000) to pin it explicitly. The host uses
max(test_timeout_ms, suite_timeout_ms) + 10 000 ms, keeping the per-test
timeout as a floor and adding time for ExUnit to report its own timeout first.
Each run prints the deadline it derived and why.
Sandbox priv/ reset
Every mutant runs in a sandbox whose priv/ tree is restored between mutants,
so a database, generated asset, directory or symlink a test writes there never
leaks into the next mutant. Baseline priv/ files are compared by size + mtime
(priv_fingerprint: :stat, the default) — cheap, but blind to a rewrite that
keeps the byte size and lands in the same mtime second. Set
--priv-fingerprint hash (or config :mutalisk, priv_fingerprint: :hash) for a
content-exact comparison; it costs one content read per priv/ file per reset,
per worker.
Test selection
Test selection decides which test files run for each mutant. It defaults to
coverage_with_static_fallback; --selection static is the fully-portable
escape hatch. --selection coverage prevents a whole-run downgrade to static
selection when coverage collection becomes too slow; the per-mutant fallback
ladder below still applies. Selection only ever affects which tests run — it
never enters a mutant's stable id — and every rule below errs towards running more tests, because
under-selecting a killing test would report a false survivor.
In the coverage modes each mutant takes the first non-empty scope from:
- exact line — tests whose coverage includes the mutated line.
- enclosing function — tests covering any line of the mutated function.
- static fallback — tests statically referencing the mutant's module.
- all tests.
Two rules deliberately widen that ladder:
- Clause-head mutations skip exact-line coverage. A mutation in a pattern or a guard can make its clause accept inputs that originally routed to a different clause, so the killing test need never have executed the mutated line. Those mutants start at enclosing-function scope (every clause of the function), then try enclosing-file coverage before static selection.
- Degraded coverage is unknown coverage. Any test file whose coverage collection timed out or failed is added to every mutant's selection. An absent measurement is not evidence that the test is irrelevant.
Static selection is conservative and may select more tests than strictly
necessary. It indexes the modules each test file names, and then follows a
module reference graph built over project source files, including custom source
roots, umbrella layouts, and test support (build and dependency trees are
pruned): a test is selected for a target module when it references that
module or any module that transitively references it. This is what makes a
test that reaches the target only through a facade module still run. When the
closure spans the whole project, selecting every test is the correct answer.
The graph is built from statically visible aliases, so runtime-computed module
names are still handled by the separate dynamic-dispatch escape hatch (a test
file using apply/3 on a variable module, or Module.concat/1, is selected for
every mutant).
Limitations
- Mutalisk does not mutate DSL-emitted code, macro bodies, or generated code.
- Static test selection over-approximates; see Test selection.
- Mutants run via a fresh
mix testworker each (one mutant per VM), in an isolated sandbox subprocess.
Public API
The supported interfaces are MIX_ENV=test mix mut, its documented configuration
and reports, and the runtime helpers in Mutalisk: active_key/0,
set_active/1, get_active/0, and clear/0. The active mutant is VM-global;
use try/after to clear it when using these helpers directly. Modules under
Mut.* and the compiler integration are implementation details.
Maintaining Mutalisk
The source repository includes the release guide, architecture overview, and benchmark tools.