What mutation testing is

Audience: you know pytest. You have never used mutation testing, and you are reasonably suspicious of tools that promise to grade your tests.

Coverage measures the wrong thing

Here is a function and a test. The test passes. Coverage is 100%.

>>> def shipping_cost(weight_kg):
...     if weight_kg > 20:
...         return 15.0
...     return 5.0
>>> def test_shipping():
...     assert shipping_cost(1) == 5.0
...     assert shipping_cost(50) == 15.0
>>> test_shipping()

Every line ran. Both branches ran. By any coverage report, this function is completely tested.

Now change one character — > 20 becomes >= 20:

>>> def shipping_cost(weight_kg):
...     if weight_kg >= 20:   # the only change
...         return 15.0
...     return 5.0
>>> test_shipping()

The test still passes. It did not notice.

The bug this hides is real: a 20kg parcel now costs £15 instead of £5, and nothing in the suite says which is correct. Coverage cannot see this, because coverage asks “did this line run?” and the answer is yes either way. The question that matters is “would anything have complained if this line were wrong?”, and there is only one way to find out.

So: break it on purpose

Mutation testing makes small, plausible changes to your source — one at a time — and runs your tests against each one.

  • If a test fails, the mutant is killed. Your suite noticed. Good.

  • If every test passes, the mutant survived. Your suite did not notice, and that is a gap with a file, a line, and a description.

The changes are called mutants, and they are deliberately the kind of mistake people actually make: an off-by-one in a comparison, a + where a - belongs, an and that should be an or, a boolean flipped. moonbuggy’s set is small on purpose; Writing an operator covers adding to it.

The proportion killed is your mutation score. It is a more honest number than coverage because it cannot be raised by executing code without checking anything.

Why this is not just a slower coverage tool

Consider a test suite with this shape, which is extremely common:

def test_it_runs():
    result = process(order)
    assert result is not None

100% coverage of process. Mutation score near zero, because almost any change to process still returns something that is not None. The test executes the code and checks essentially nothing, and mutation testing is the tool that says so out loud.

The catch, stated up front

Some survivors cannot be killed. A mutation that produces code with identical behaviour — swapping < for != in a loop bound that can only be reached one way — will survive every possible test, because there is nothing to catch. These are equivalent mutants, detecting them in general is undecidable, and pretending otherwise is how mutation tools waste people’s afternoons. Equivalent mutants is about recognising them and suppressing them honestly.

It is slower than your test suite. It has to be: it runs tests once per mutant. moonbuggy’s answer is to run only the tests that execute the mutated line, which is usually a handful rather than all of them, and to keep the process warm between mutants. See Making runs fast.

A low score is not automatically bad. Code that is genuinely untested-by-design — a debug helper, a __repr__ — will produce survivors that nobody should write tests for. The output is evidence, not a grade.

What to do with it

Do not chase the score. Read the survivors and ask, for each one: if this change shipped, what would break, and would anyone find out?

  • If the answer is “a customer would be charged the wrong amount and nobody would know” — write the test.

  • If the answer is “nothing, the two versions are the same program” — it is an equivalent mutant, suppress it with a note.

  • If the answer is “nothing anyone cares about” — leave it.

Next: the Quickstart if you want it running now, or the Tutorial for a realistic module walked end to end.