muta

Mutation testing for Elixir. Name the code and the tests, and it tells you what the tests miss.

Elixir ~> 1.20Status: alphaLicense: MIT

Experimental. muta is alpha. Expect the API to change between releases, and expect rough edges: it has been used seriously on one codebase so far. Read Limits before you rely on it.

Tests can cover every line of a function without checking what it actually does. muta makes small changes to your code, runs the tests you named, and reports the changes no test caught:

survived lib/store/pricing.ex:5 Comparison
- cents > 5000
+ cents >= 5000

Free shipping over $50 is now free shipping at exactly $50, and the suite is still green, because no test uses a cart of exactly 5000 cents.

Install

{:muta, github: "fabioelizandro/muta", only: [:dev, :test]}

muta runs your tests, so the task needs the test environment:

def cli do
[preferred_envs: [mutate: :test]]
end

Running it

Give it source files to mutate and test files to judge them by. Both flags repeat.

mix mutate lib/store/pricing.ex --test test/store/pricing_test.exs
Mutating lib/store/pricing.ex
Judged by test/store/pricing_test.exs
survived lib/store/pricing.ex:5 Comparison
- cents > 5000
+ cents >= 5000
8 mutants · 7 killed · 1 survived · 0 unstable · 0 invalid · 87.5%

Name more than one source file and you also get a line per file, so you can see which one is weak instead of one blended number. Files muta found nothing to mutate in are listed too:

lib/store/pricing.ex 8 mutants · 7 killed · 1 survived · 0 unstable · 0 invalid · 87.5%
lib/store/cart.ex no mutants, so nothing here was checked
8 mutants · 7 killed · 1 survived · 0 unstable · 0 invalid · 87.5%
1 of the 2 files you named produced no mutants and went unchecked.
The score above says nothing about them.

Reading the output

Every finding is outcome file:line mutator, and there are four outcomes:

OutcomeMeansYou
killedA test failed, twice. That behaviour is pinned.do nothing
survivedEvery test passed. Nothing pins it.add a test
unstableA test failed, then passed on the re-run.see below
invalidThe mutated code didn't compile, so no test ran against it.do nothing

The score is killed / (killed + survived). Unstable and invalid mutants never produced a verdict, so they stay out of it. Exit code is 0 when everything was killed, 1 when anything survived or came back unstable, and 2 when muta wouldn't score the run at all.

muta runs your tests twice before recording a kill. Tests fail for reasons that have nothing to do with the mutation, and a false kill makes the score look better than it is. When the first run fails and the second passes you get unstable, which means your suite gave two answers about the same mutant. Re-run it, drop --workers, or fix the flaky test.

You pick the tests

muta judges each mutant only against the test files you name. A survivor means those tests didn't catch it, not that nothing in your suite would. Name too few and you'll chase survivors some other test already kills.

That's deliberate. Guessing from filenames assumes a layout, static call graphs fall apart at protocols and LiveView callbacks, and building a runtime coverage map costs a full serialised run of the suite first.

With Claude Code

Working out which tests exercise a file is something an agent is good at. Put this in your CLAUDE.md:

## Mutation testing
Before committing, mutation-test the staged source files:
```shell
mix mutate <source file> --test <its test files>
```
Read the diff, work out which test files exercise each staged source file, then
run one command per file so each gets its own score. Run them in parallel.
Exit `0` is clean, `1` needs attention, `2` means muta wouldn't score the run and
the report says why. Each finding reads `outcome file:line mutator`:
- `survived` is a missing assertion. Add the assertion. Never delete the mutant
or loosen the code to make it pass.
- `unstable` means a test failed then passed on the re-run, so the failure wasn't
the mutation's doing. Re-run once. If it repeats, the flaky test is the bug.
- `invalid` means the mutated code didn't compile. Nothing to fix.
A file reported as `no mutants` wasn't checked at all. The score says nothing
about it, so don't read a pass as covering it.
Skip files defining a `defguard` or `defmacro`, which muta won't mutate.

Wrap it in a skill if you'd rather trigger it by hand than on every commit.

Mutators

Eight, all on by default.

MutatorTurns
Comparisona < ba <= b, a < ba > b, a == ba != b
Conditionif x doif true do, then → if false do
Result:ok:error, {:error, r}{:ok, r}
Booleantruefalse
Logicala and ba or b, a && ba || b, !aa
Arithmetica + ba - b, a * ba / b
Membershipa in ba not in b
CounterpartEnum.filterEnum.reject, String.upcasedowncase

Look at surviving Condition mutants first. A surviving if true is a branch no test goes near, and line coverage still calls that branch covered.

Nothing inside @spec, @type, @doc, @impl and friends gets mutated. Those never reach runtime so no test could tell, and each one would come back as a survivor you can't do anything about. On a codebase of 183 files that was 86 findings.

Speed

muta compiles a mutant and loads it into the running VM instead of writing it to disk, which costs about 79ms for a 400-line module:

source → AST → mutate one node → Code.compile_quoted → :code.load_binary
→ ExUnit.run/1 on your tests → restore the original binary

Mutants are judged in parallel, one worker VM each, because the code server holds one version of a module at a time. Workers pull from a shared queue, since a killed mutant costs a fraction of a second and a survivor costs a full run of your tests.

On a Phoenix app with roughly 1,900 tests, at the default four workers:

Module under mutationMutantsTime
a small value module93.8s
a webhook parser273.4s
a context full of queries144108s

That 144-mutant run takes 269s with --workers 1 and 102s with --workers 8.

Four is the default because there's little left to gain past it. More workers means more contention, and a test that gets slow enough under load trips its own timeout, which muta can only read as the mutant dying. Verdicts have drifted by a mutant or two between repeat runs at higher worker counts, usually between survived and unstable. Use --workers 1 when you need the same number twice.

When muta stops instead of scoring

A score you can't trust reads as reassurance, so muta raises Muta.UntrustworthyError rather than print one, when:

Programmatic use

config = Muta.Config.new(["lib/store/pricing.ex"], ["test/store/pricing_test.exs"])
report = Muta.run(config)
report.score #=> 87.5
Muta.Report.survivors(report) #=> [%Muta.Mutant{line: 5, original: "cents > 5000", ...}]

muta loads the test files itself, once here and again in every worker, so pass each one once and don't pass anything test_helper.exs already requires. It calls Node.start/2 if the VM isn't alive, since the workers need distribution.

OptionDefaultSets
:workers4Worker VMs judging at once.
:mutatorsall eightWhich mutators to apply.
:test_helpertest/test_helper.exsWhat each worker requires before your tests.
:baseline_timeout_ms30_000How long your tests may take before muta gives up.

Writing a mutator

There's one callback. Given a node, return what it could become, or [] to leave it alone.

defmodule Store.Mutator.Concat do
@behaviour Muta.Mutator
@impl Muta.Mutator
def mutations({:<>, meta, [left, right]}), do: [{:<>, meta, [right, left]}]
def mutations(_node), do: []
end

Pass it in mutators:, and test it without running anything:

test "offers the halves the other way round" do
[mutation] = Store.Mutator.Concat.mutations(quote(do: first <> last))
assert Macro.to_string(mutation) == "last <> first"
end

muta wraps atoms as {:__block__, meta, [atom]} so :ok and true carry their line. Match that shape to mutate them, like Result. The wrapper comes off before anything compiles.

Limits

Prior art

Stryker for the mutator catalogue, Pitest for the return-value mutators, and go-mutesting for keeping mutators small and independent.

License

MIT. See LICENSE.