How fast is moonbuggy?

The short answer: on the benchmark workload below, moonbuggy finishes mutation testing in about half a second — roughly 1.7 times as fast as mutmut and more than 40 times faster than a naive re-run of your whole test suite per mutant.

The longer answer is the point of this page: a number only means something if you understand what was measured, on what machine, and what moonbuggy is and is not claiming. This page gives you all three.

What we measured

A benchmark is only as good as the workload it runs on. Ours is generated to have the shape of a real, non-trivial test suite:

  • Three modules of application code, each with four functions that do real arithmetic work.

  • Ninety tests across the three modules, each exercising one function with a real loop of six thousand iterations.

  • Each mutable line of code is covered by only a few of those tests.

That last point is the load-bearing one. Coverage-guided mutation testing — running only the tests that exercise a given mutated line — only pays off when test execution is the dominant cost, not process startup. A workload where every test is instant would never show the speed difference. This one is built so the tests actually do work, which is the shape of the real suites moonbuggy is aimed at.

The benchmark compares three tools against the exact same project:

  • moonbuggy, with coverage-guided test selection

  • mutmut, a widely-used open-source mutation testing tool

  • a naive baseline: run the entire suite once per mutant, no selection, no forkserver, no caching — the obvious way to do mutation testing

Every tool runs against its own fresh copy of the project, so none can benefit from artifacts another tool left behind. Numbers are the median of three consecutive runs, on a single machine.

The headline results

tool

wall time

mutants

mutants/sec

moonbuggy

0.53s

96

183

mutmut

0.93s

108

116

naive baseline

21.6s

96

4.4

Read this table carefully, because the two ratios tell a different story:

  • vs. mutmut: 1.7x faster. Three consecutive runs gave 1.79x, 1.72x and 1.74x — stable, not a lucky single sample.

  • vs. the naive baseline: 41x faster. Three runs: 41.0x, 40.0x and 41.3x.

The comparison against mutmut is worth a word of context. The two tools do not generate identical mutant sets: mutmut implements a larger set of mutation operators, so it turns this project into 108 mutants to moonbuggy’s 96. The wall-clock ratio between the two tools therefore reflects a combination of speed and operator coverage, not a like-for-like comparison of per-mutant speed. So we do not treat that ratio as a clean measure — we lead instead with the number that is clean.

The clean comparison is against the naive baseline, which runs the same moonbuggy mutation operators. Both produce exactly 96 mutants, with identical status breakdowns. Nothing is pruned. The 41x speedup is real, and it is not bought by quietly mutating less.

And on a real project?

Everything above runs on a generated workload plus a project we wrote ourselves — the right tools for measuring how the engine behaves, but subject to a fair objection: a benchmark that proves how fast we are is measured against code we chose. So there is a second, public-facing benchmark that runs the same three tools against a real, widely-used open-source project pinned to a fixed commit, so anyone can reproduce it and no one has to wonder whether we hand-picked the subject.

Subject: more-itertools v11.1.0 at commit 64be96ce. Scope: mutate more_itertools/recipes.py (the itertools-recipes module) and run its dedicated test file tests/test_recipes.py (140 tests, ~10,000 parameterised subtests). All three tools see the same scope and the same test selection. Reproduce with make bench-real.

tool

wall time

mutants

mutants/sec

moonbuggy

234s

381

1.6

mutmut

426s

1085

2.5

naive baseline

3750s

381

0.1

The gap is not a workload we shaped to flatter the tool — it is what selection, warm-forking and in-place mutation are for. On this real module, the naive baseline took over an hour (it re-runs the whole test file once per mutant) and mutmut took 7 minutes; moonbuggy finished in under four minutes. That is the honest cost that selection exists to remove.

Three things this table asks you to keep in view, because each is why the table is the shape it is:

  • The comparison that cannot be gamed is against the naive baseline. It runs moonbuggy’s exact mutation operators, so 381 == 381, with identical status breakdowns — nothing is pruned. That is the speed claim, and it is like-for-like.

  • mutmut’s count is not 381. mutmut implements a larger operator set, so on the same file it yields 1085 mutants to moonbuggy’s 381. The wall-clock comparison against mutmut is therefore speed-plus-operator-coverage, exactly like the synthetic table above; we do not lead with it.

  • The scope is a bounded slice, not the whole library. The naive baseline re-runs the selected tests per mutant, so a bigger scope would scale that cost rather than change this ratio. The point is the shape of the gap on real code.

This benchmark is deliberately much slower than make bench — running it takes about an hour, almost all of it the naive baseline. That slowness is the credibility point, not a defect.

What “half a second” actually buys you

Put a number on it. On this workload, mutmut spends about a second mutating a project whose 90 tests take real time to run. moonbuggy spends half that. On the naive baseline — the way many teams first try mutation testing — the same job takes 22 seconds, because every one of the 96 mutants re-runs the entire suite from scratch.

That is the difference between running mutation testing on every push and reserving it for a slow job. At half a second, moonbuggy fits in your normal test run. At 22 seconds with the naive approach, it is a separate, slower stage you will be tempted to run rarely — which is exactly when the coverage gaps it finds stop being caught in time.

Where the speed comes from

moonbuggy gets its speed from one central design choice. A single long-lived process runs your suite exactly once, under coverage, and records which tests reach which lines — importing every test module at the same time. For each mutant, that already-warm process forks a child instead of starting a fresh interpreter. The work that used to be repeated identically for every mutant, importing the same test modules and re-mapping the suite over and over, is now done once. That single change, plus running only the tests that reach a given mutated line, is where the time comes out. It was not fast on day one — its first recorded run took 10.1 seconds — and every improvement since came from measuring where the time actually went, not from guessing.

How far the project has come

The headline is one workload. The deeper claim is that the approach scales across the shapes real suites come in, and that is where moonbuggy improved the most. Over four development rounds, on the same machine and the same three workload shapes, wall time fell on every single one of them:

workload shape

first round

after four rounds

speedup

many tiny tests, nearly instant each

0.554s

0.302s

1.8x

fewer tests, each doing real work

0.989s

0.415s

2.4x

a large number of small files

1.534s

0.478s

3.2x

Each shape stresses a different part of the engine, and each one got faster: the nearly-instant-test shape is dominated by process startup, so it was the warm-fork change that won there; the real-work shape is dominated by test execution, so it was the coverage-guided selection that won there; the many-small-files shape is dominated by discovery and collection, so it was the file-handling work that won there. No round regressed a shape it was not aiming at — every improvement on one held on the other two.

The honest caveats are the same ones that keep the numbers honest. You can reproduce every figure here from the repository, and you should treat anything you read as a snapshot of one machine, one day — including ours.

The honest caveats

  • One machine, one Python version. Results are on a 14-core macOS machine running Python 3.12. Your mileage will depend on your hardware, your suite, and your Python.

  • The fast path is POSIX-only. The warm-session, fork-based approach needs fork(). On Windows, moonbuggy falls back to the fresh-process-per-mutant path. The speedups above are what you get on macOS and Linux.

  • Parallel workers use a separate path. Distributed workers (xdist) need real subprocesses, so combining the parallel warm-session path with true parallelism is unexplored territory.

Reproduce it yourself

Everything here is regenerated from the same benchmark in the repository. Run make bench, and the numbers come from a deterministic workload and a version-controlled fixture. The real-project table is regenerated the same way with make bench-real, from a real open-source project pinned to a fixed commit — so both tables are reproducible, and neither is a number we typed by hand. If you want to see how any change to moonbuggy moves the needle, the comparison harness (make ab) interleaves two different versions of the tool and reports a confidence interval, so a headline that moved can’t be blamed on the machine being in a different mood that day.

The general lesson is worth carrying into any benchmark you read, including ours: a number taken on one machine on one day tells you about that day. When a ratio changes, ask whether the denominator moved too — we do.