Mutation testing for Clojure. Discovers mutation sites, applies each one, runs your specs, reports killed/survived.
Add a :mutate alias to your project's deps.edn:
:mutate {:main-opts ["-m" "clj-mutate.core"]
:extra-deps {clj-mutate/clj-mutate {:local/root "/path/to/clj-mutate"}
org.clojure/tools.reader {:mvn/version "1.4.2"}}
:extra-paths ["spec"]}Requires Speclj as your test runner.
# Analyze spec structure and SCRAP scores
clj -M:scrap spec
# Mutate-test a source file.
# If the file already has a footer manifest, this defaults to changed top-level forms only.
clj -M:mutate src/myapp/foo.cljc
# Scan a file for mutation counts without running coverage or specs
clj -M:mutate src/myapp/foo.cljc --scan
# Rewrite the embedded manifest without running coverage or mutations
clj -M:mutate src/myapp/foo.cljc --update-manifest
# Retest only specific lines (e.g. survivors from a previous run)
clj -M:mutate src/myapp/foo.cljc --lines 45,67,89
# Force differential mutation even if you want to be explicit
clj -M:mutate src/myapp/foo.cljc --since-last-run
# Override the default differential behavior and mutate all covered sites
clj -M:mutate src/myapp/foo.cljc --mutate-all
# Reuse existing LCOV data without refreshing coverage
clj -M:mutate src/myapp/foo.cljc --reuse-lcov
# Warn when a module exceeds a mutation-count threshold
clj -M:mutate src/myapp/foo.cljc --mutation-warning 75
# Limit parallel worker count
clj -M:mutate src/myapp/foo.cljc --max-workers 4
# Use a custom infinite-loop timeout multiplier (baseline factor)
clj -M:mutate src/myapp/foo.cljc --timeout-factor 15
# Use a custom test command (quote commands containing spaces)
clj -M:mutate src/myapp/foo.cljc --test-command "clj -M:spec --tag ~slow"
# Show command usage help
clj -M:mutate --helpThe tool automatically:
- Runs a baseline test (
clj -M:spec --tag ~no-mutate) to verify all included specs pass unmodified - Applies each mutation, runs all specs with a timeout (
--timeout-factor, default 10x baseline) - Restores the original file after each mutation
- Writes an embedded footer manifest with the last test date and top-level form hashes
- Updates that embedded manifest after successful differential runs as well as full runs
- Defaults to differential mutation when that footer manifest is already present
- Prints a warning when mutation count exceeds
--mutation-warning(default50) - Excludes specs tagged
:no-mutateby default so mutation workers do not recursively launch nested mutation runs - Can reuse existing LCOV data with
--reuse-lcov
--scan is the fast structural mode. It skips coverage, skips test execution, and reports:
- total mutation sites
- changed mutation sites relative to the embedded manifest
- the standard mutation-count warning
--update-manifest rewrites the embedded footer manifest for the file's current contents without running coverage, baseline specs, or mutation workers.
Run mutation testing one file at a time.
Before running mutation work, run SCRAP on your specs:
clj -M:scrap spec
clj -M:specclj -M:scrap includes the structural checks that were previously handled by speclj-structure-check, and also reports SCRAP scores for the worst examples in each spec file. The alias pulls SCRAP from github.com/unclebob/scrap.
If you have specs that should never run from inside mutation workers, tag them :no-mutate. clj-mutate excludes those by default with clj -M:spec --tag ~no-mutate. You can override that behavior with --test-command.
After specs pass, run --scan on the files you changed:
clj -M:mutate src/myapp/foo.cljc --scanIf a changed file reports more than 50 mutation sites, consider splitting it before doing full mutation work.
Then mutate exactly one source file with --max-workers 3:
clj -M:mutate src/myapp/foo.cljc --max-workers 3Workflow rules:
- Mutate only one file at a time.
- Before moving to the next file, cover every uncovered mutation in the current file.
- Before moving to the next file, kill every surviving mutation in the current file.
clj-mutateuses LCOV coverage data and regenerates it when stale or missing.- In a batch of mutation runs, let the first run generate coverage, then consider
--reuse-lcovfor the remaining files if you accept stale-coverage risk.
Recommended loop for each file:
- Run
clj -M:mutate path/to/file.clj --max-workers 3. - If any mutations are uncovered, add or fix specs until they are covered.
- If any mutations survive, change code or specs until they are killed.
- Rerun the same single-file mutation command.
- Only start the next file when the current file has no uncovered mutations and no survivors.
For local incremental work, once a file has a footer manifest the default run is differential. You can still be explicit:
clj -M:mutate src/myapp/foo.cljc --since-last-runBefore baseline and worker execution, a mutation run prints:
- total mutation sites
- covered mutation sites
- uncovered mutation sites
- changed mutation sites
- whether a manifest exists
- whether the module hash changed
- differential surface area
- manifest-violating surface area
To force a full rerun on a file with a manifest:
clj -M:mutate src/myapp/foo.cljc --mutate-allBefore a push or major release, consider running --mutate-all on the files you changed to verify the full file instead of relying only on differential mutation.
The footer manifest is embedded at the end of the source file and records:
- the last successful mutation test date
- each top-level form's id
- its line span
- a hash of its normalized form
Differential mutation runs update the footer manifest on success, so the next differential run compares against the latest successful mutation baseline.
| Category | Mutations |
|---|---|
| Arithmetic | + ↔ -, * → /, inc ↔ dec |
| Comparison | > ↔ >=, < ↔ <= |
| Equality | = ↔ not= |
| Boolean | true ↔ false |
| Conditional | if ↔ if-not, when ↔ when-not |
| Constant | 0 ↔ 1 |
Known-equivalent mutations (e.g. comparisons on (rand), constants inside rand-nth pools) are auto-suppressed.
If a :cov alias is configured with Cloverage and --lcov output, the tool reads target/coverage/lcov.info to skip mutations on uncovered lines.
Coverage freshness is checked automatically:
- If
target/coverage/lcov.infois missing,clj-mutateregenerates it withclj -M:cov --lcov. - If LCOV is older than current source/spec inputs,
clj-mutateregenerates it withclj -M:cov --lcov. - The run prints a diagnostic message when regeneration is triggered.
- If a mutation site sits on a
recurargument line or a nested loop-state update expression, LCOV may emit noDAentry for that line. In that caseclj-mutateclassifies the site as uncovered even when behavior-level tests exercise the path.
With --reuse-lcov:
clj-mutateuses the existingtarget/coverage/lcov.infoas-is- stale coverage is allowed
- the run prints a warning that covered/uncovered classification may be inaccurate
- the run prints whether the LCOV file exists, its last modified time when present, and whether the target source is newer than the LCOV file
- if
target/coverage/lcov.infois missing, the run prints a clear error and exits with status1
:cov {:main-opts ["-m" "speclj.cloverage" "--" "-p" "src" "-s" "spec" "--lcov"]
:extra-deps {cloverage/cloverage {:mvn/version "1.2.4"}
speclj/speclj {:mvn/version "3.10.0"}}
:extra-paths ["spec"]}Parallel mutation runs now use a unique worker root per run:
target/mutation-workers/run-<uuid>/worker-N
This avoids collisions when two mutation runs overlap or when a prior run exits unexpectedly.
This repo includes a Claude Code skill for AI-assisted mutation testing. Add it to your project's .claude/settings.json:
{
"skills": ["github.com/unclebob/clj-mutate"]
}