Mutineer
A clean-room mutation-testing tool for Ruby. Mutineer mutates your source one change at a time, runs your test suite (Minitest or RSpec) against each mutant, and reports the ones your tests failed to catch — the gaps where your suite isn't actually testing anything.
- Prism + stdlib only — zero runtime dependencies (Ruby ≥ 3.4).
- One mutation per mutant, validity-checked by re-parsing.
- Fork-isolated, parallel execution (Linux + macOS).
- Coverage-guided — each mutant runs only the test files that cover its line.
📖 mutineer.github.io → — overview, operators, and usage.
Install
gem install mutineer
Or in a Gemfile:
gem "mutineer", group: :test
Usage
mutineer run <source...> --test <test...> [options]
Mutate lib/calculator.rb, checking it against its test, and fail CI if the
mutation score drops below 90%:
mutineer run lib/calculator.rb --test test/calculator_test.rb --threshold 90
Options
| Flag | Meaning | |
|---|---|---|
--test FILE |
Test file covering the sources (repeatable) | |
--operators LIST |
Comma-separated operator names (default: the Tier-1 set) | |
--threshold FLOAT |
Exit 1 when the score is below FLOAT (default: 0 = off) | |
--only NAME |
Restrict to one fully-qualified subject, e.g. Calculator#add |
|
--framework NAME |
minitest (default) or rspec; auto-detected as rspec when most --test files end in _spec.rb |
|
--since REF |
Only mutate lines changed since git REF (e.g. origin/main) — ideal for PR CI |
|
--baseline FILE |
Compare against a prior --format json run; exit 1 on new survivors / score drop (see CI) |
|
--baseline-epsilon FLOAT |
Score-drop tolerance for --baseline (default: 0) |
|
--jobs N |
Parallel worker count (default: processor count; 1 under --rails) |
|
--verbose |
Surface the real error when a fork capture fails (alias --debug) |
|
--strategy NAME |
Mutation application: reload whole-file (default) or redefine surgical (7a/7b accepted as deprecated aliases) |
|
| `--format human\ | json` | Report format (default: human) |
--output FILE |
Write the report to FILE instead of stdout | |
--dry-run |
List candidate mutations without executing | |
--list-operators |
List available operators (default vs optional) and exit | |
--version, --help |
Print version / usage and exit |
Exit codes
| Code | Meaning |
|---|---|
0 |
Score ≥ threshold (or no threshold set) |
1 |
Survivors below threshold, or a runtime error |
2 |
Usage / invalid-flag error |
Operators
Run mutineer --list-operators to see them. Default (Tier 1): arithmetic,
comparison, boolean_connector, boolean_literal, statement_removal.
Available but off by default (Tier 2, enable via --operators): return_nil,
literal_mutation, condition_negation.
Rails apps
Rails code needs its environment booted before the suite runs, so point Mutineer
at your app with --rails and run it inside the project's bundle:
RAILS_ENV=test bundle exec mutineer run \
app/models/order.rb --test test/models/order_test.rb --rails
--rails boots config/environment once in the parent process (every mutant
then forks and inherits it), defaults --strategy to redefine (surgical — it
avoids reloading files into the app tree), and reconnects ActiveRecord in each
fork so the database connection is fork-safe. Use --boot FILE to boot a
different entry point. Boot mode requires at least one --test file and is
coverage-guided — each mutant runs only the test files that exercise its line
(coverage is captured by forking the booted app, then cached).
Add Mutineer to your Gemfile's test group:
gem "mutineer", group: :test, require: false
Suppressing equivalent mutants
Some mutants are equivalent (behaviour-identical) and survive forever — keeping a
file off 100%. Suppress them so the score and --threshold gate stay meaningful:
- Inline:
some_line # mutineer:disable-line(or scope it:# mutineer:disable-line comparison). - Config: a
.mutineer.ymlignore:list of stable mutant ids. Each survivor'sidis printed in the JSON report, so copy it straight intoignore:.
Suppressed mutants are excluded from the score (so 100% becomes reachable).
CI gating
Store a JSON run as a baseline, then fail the build only when a PR makes things worse:
mutineer run app/ --baseline .mutineer/baseline.json # exit 1 on NEW survivors or a score drop
--baseline reports which survivors are new (by stable id) and any score drop. It
combines with --threshold (the worse of the two sets the exit code). Pass a
directory (or several sources) to audit a whole layer in one boot — tests are
auto-paired by convention and the report breaks down per source.
Configuration
Mutineer reads an optional .mutineer.yml from the project root (nearest one,
walking up). CLI flags override config; config overrides defaults.
Sources are positional CLI arguments and test files come from --test; the
config file accepts these keys: operators, threshold, jobs, only,
require (extra files to load before mutating), and boot/rails.
# .mutineer.yml
operators: [arithmetic, comparison, boolean_connector, boolean_literal, statement_removal]
threshold: 90
jobs: 4
require:
- config/environment
Coverage results are cached in .mutineer/coverage.json (digest-keyed; rebuilt
automatically when sources change). Add .mutineer/ to your .gitignore.
License
MIT — see LICENSE.