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:

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:

  1. Write or strengthen a test (the usual answer). The survivor tells you exactly which behaviour is unguarded — e.g. "replace < with <= on lib/foo.ex:12 survived" means no test pins the boundary.
  2. 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.
  3. Exclude it from mutation when the code is intentionally untested (generated/DSL modules, glue): add @mutalisk_ignore true to the module (see below), or an exclude path 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

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:

  1. exact line — tests whose coverage includes the mutated line.
  2. enclosing function — tests covering any line of the mutated function.
  3. static fallback — tests statically referencing the mutant's module.
  4. all tests.

Two rules deliberately widen that ladder:

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

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.