Overview
Learn how to use mutation testing to assess the quality of your test suite
Mutation testing runs small edits (mutations) on the contract source, recompiles, and re-runs the suite. For instance, it might change + to -, flip the < to >=, or remove an assertion. If tests still pass, the mutant code survived, and the suite likely missed an assertion on that behavior.
Why mutation testing
Code coverage only shows execution and lines of code executed, whereas mutation testing tells how well those lines are tested and whether existing assertions contradict mutations.
Additionally, coverage can be misleading and overlook weak tests. For example, a test that only calls add(1, 2) can reach every line yet never exercise the a > 0 guard in the following code:
fun add(a: int, b: int): int {
assert (a > 0) throw 5; // Validation
return a + b;
}Mutation testing can delete assert (a > 0) throw 5. Without a failing test case such as add(-1, 2), that mutant survives: the line ran, but the guard was not actually enforced by the test suite. This is critical for smart contracts where assertions guard invariants: if an assert can be removed without tests failing, that assertion is effectively untested.
Further, mutation testing can reveal implementation issues. For example, two storage.save() calls where removing the second does not fail any test suggests that the second call might be redundant and can be removed to optimize gas usage.
How it works
- Mutant generation: Acton scans the contract code and picks mutation sites.
- Compilation: Each mutant compiles; mutants that fail to compile are reported separately and are not run through the test suite.
- Testing: Acton runs the test suite against each valid mutant.
- Reporting:
- Killed: tests failed on the mutant as expected.
- Survived: tests unexpectedly passed, indicating a coverage gap.
- Compile errors: mutants that failed to compile are reported separately and excluded from the mutation score.
Run mutation tests
Pass --mutate and name the contract from Acton.toml with --mutate-contract:
acton test --mutate --mutate-contract WalletOptions
| Option | Description |
|---|---|
--mutate | Enables mutation testing mode. |
--mutate-contract <NAME> | Specifies which contract from Acton.toml to mutate. |
--mutation-diff <MODE> | Limits mutation testing to changed lines in worktree, ref, or branch scope. |
--mutation-diff-ref <REF> | Base ref used by ref mode and optional override for branch mode. |
--mutation-levels <LEVELS> | Runs only selected mutation levels such as critical,major. |
--mutation-rules-file <PATH> | Loads custom query-based mutation rules from a JSON file. |
--mutation-session-id <ID> | Reuses a session ID and appends progress to build/mutation-sessions/<ID>.jsonl. |
--mutation-workers <N> | Overrides mutation parallelism. Defaults to the host's available parallelism. |
--mutation-id <ID[,ID...]> | Re-runs only specific mutation IDs from a previous mutation report. |
--mutation-minimum-percent <P> | Fails the run when the mutation score drops below the required percentage. |
--mutation-disable-rules <RULE> | Disables specific mutation rules (can be used multiple times). |
Parallel workers
Mutation testing can spawn a lot of compile and test workers. By default, Acton uses the host's available parallelism and keeps one isolated mutation workspace per worker.
Use --mutation-workers <N> to cap or raise concurrency for a specific run:
acton test --mutate --mutate-contract Wallet --mutation-workers 4This is useful when limiting CPU, disk, or process count on a laptop or CI host. Progress still streams by mutation ID while output stays ordered.
Scope mutation runs
Mutation testing can be expensive because each valid mutant is compiled and tested separately. In practice, most local runs should use one or more filters to keep the scope small and the feedback fast.
Changed-line modes
Use --mutation-diff to mutate only code that intersects changed lines.
worktree: compares the current worktree withHEAD. This is the best mode for uncommitted local changes. Untracked files are treated as fully changed.ref: compares the current checkout against an explicit ref, tag, or commit. This mode requires--mutation-diff-ref <REF>.branch: compares the current branch against the merge-base with its upstream branch. If the branch has no upstream, pass--mutation-diff-ref <REF>to choose the base explicitly, for exampleorigin/main.
Examples:
# Only mutate lines changed in the current worktree
acton test --mutate --mutate-contract Wallet --mutation-diff worktree
# Compare against an explicit ref or commit
acton test --mutate --mutate-contract Wallet --mutation-diff ref --mutation-diff-ref HEAD~1
# Compare the current branch against its upstream merge-base
acton test --mutate --mutate-contract Wallet --mutation-diff branch
# Compare the current branch against a specific branch instead of upstream
acton test --mutate --mutate-contract Wallet --mutation-diff branch --mutation-diff-ref origin/mainLevel and rule filters
Use --mutation-levels to restrict the run to select rule levels:
acton test --mutate --mutate-contract Wallet --mutation-levels critical,majorUse --mutation-disable-rules to exclude noisy or intentionally ignored rules:
acton test --mutate --mutate-contract Wallet --mutation-disable-rules replace_plus_with_minus \
--mutation-disable-rules replace_minus_with_plusAll mutation filters compose. For example, this command runs only critical mutations on lines changed in the current worktree and skips one specific rule:
acton test --mutate --mutate-contract Wallet --mutation-diff worktree --mutation-levels critical \
--mutation-disable-rules remove_logical_notThe reported mutation score always reflects only the mutants that remain after all filters are applied.
Custom JSON rules
Custom rules are merged with the built-in set. If a custom rule uses the same rule ID as a built-in rule, the custom rule overrides it.
Example:
[
{
"name": "replace_plus_with_multiply_custom",
"description": "Replace + with *",
"explanation": "Custom arithmetic mutation loaded from JSON.",
"level": "major",
"group": "arithmetic",
"matcher": {
"type": "query",
"query": "(binary_operator operator_name: \"+\" @op)",
"capture": "op"
},
"edit": {
"type": "replace",
"replacement": "*"
}
}
]Use --mutation-rules-file to run project-specific query-based mutations without rebuilding Acton:
acton test --mutate --mutate-contract Wallet --mutation-rules-file mutation-rules.jsonPrint the JSON schema for editor integration or validation with:
acton meta get-schema mutation-rulesCurrently, JSON rules support only the serializable query matcher and remove or replace edits.
Session logs and resume
Each mutation run gets a session ID. Without explicit --mutation-session-id, Acton generates a hash-like ID and appends progress to build/mutation-sessions/<SESSION_ID>.jsonl.
Use --mutation-session-id to keep that ID stable and append to the same log on later runs:
# Start or resume a session for the current worktree changes
acton test --mutate --mutate-contract Wallet --mutation-diff worktree \
--mutation-session-id wallet-worktreeTo resume successfully, keep the same contract and the same mutation filters. Acton validates the stored session metadata before reusing the file.
Interrupting with Ctrl+C stops Acton without marking the session finished and prints the exact resume command for that session.
Re-run specific mutants
Use --mutation-id to focus on one or more mutants from a previous report. Pass the mutation numbers from that report and keep the same mutation filters used when those IDs were produced.
Examples:
# Re-run one specific mutant
acton test --mutate --mutate-contract Wallet --mutation-id 2
# Re-run several mutants from the same filtered run
acton test --mutate --mutate-contract Wallet --mutation-id 1,3With extra filters such as --mutation-diff, --mutation-levels, or --mutation-disable-rules, the IDs must still exist after those filters apply. With --mutation-session-id, the session metadata must match the same filtered mutation set.
Minimum mutation score gate
Use --mutation-minimum-percent to turn mutation testing into a CI quality
gate:
acton test --mutate --mutate-contract Wallet --mutation-minimum-percent 85The threshold is applied to the final mutation score after filters are applied. Compile errors are reported separately and excluded from that score.
Config defaults in Acton.toml
You can store the same filters in Acton.toml:
[test.mutation]
diff = "branch"
diff-ref = "origin/main"
mutation-levels = ["critical", "major"]
rules-file = "mutation-rules.json"
minimum-percent = 85
disable-rules = ["replace_plus_with_minus"]CLI flags override [test.mutation] for the current run.
Requirements
Tests must build the mutated contract by contract name from Acton.toml, not by raw file path:
// ✅ Correct — uses contract name
val wallet = build("JettonWallet");
// ❌ Incorrect — uses direct file path
val wallet = build("JettonWallet", "./contracts/JettonWallet.tolk");Mutation rewrites apply to the tree selected by --mutate-contract. File-path build(...) calls bypass that wiring and skip mutations, which weakens overall results.
Mutation rules
Acton includes many built-in mutation rules for assertions, storage and upgrade calls, external-message handling, control flow, arithmetic, comparisons, booleans, logical operators, and bitwise or shift operators.
Read the report
After a mutation run, the console shows a summary of the results:
Mutation Testing
────────────────────────────────────────────────────────────
Session: 4949ed3b0ae13461
Contract: JettonWallet
Source: contracts/JettonWallet.tolk
Files: 6
Mutants: 51
◉ Mutation 1/51 contracts/JettonWallet.tolk:36:17 Remove assert statements KILLED
◉ Mutation 2/51 contracts/JettonWallet.tolk:77:13 Remove assert statements SURVIVED
◉ Mutation 3/51 contracts/JettonWallet.tolk:78:13 Remove assert statements KILLED
◉ Mutation 4/51 contracts/JettonWallet.tolk:81:13 Remove assert statements KILLED
◉ Mutation 5/51 contracts/JettonWallet.tolk:82:13 Remove assert statements KILLED
◉ Mutation 6/51 contracts/JettonWallet.tolk:87:13 Remove assert statements KILLED
◉ Mutation 7/51 contracts/JettonWallet.tolk:113:13 Remove assert statements KILLED
◉ Mutation 8/51 contracts/JettonWallet.tolk:114:13 Remove assert statements KILLED
◉ Mutation 9/51 contracts/JettonWallet.tolk:134:13 Remove assert statements SURVIVED
...- KILLED: tests failed on the mutant as expected.
- SURVIVED: tests still passed, failing to detect the bug; add a test case and tighten coverage for this scenario.
- Session log: Progress is written incrementally to
build/mutation-sessions/<SESSION_ID>.jsonl, which makes interrupted runs resumable. - Mutation IDs: The number in
Mutation 2/51orMutation #2can be used later with--mutation-id 2to re-run that specific mutant.
For survived mutants, Acton prints a diff of the edit plus a short rationale tied to the rule:
✗ Mutation #2
Rule: Remove assert statements [remove_assert]
Level: critical
Group: assertion
at contracts/JettonMinter.tolk:46:13
44 │
45 │ RequestWalletAddress => {
46 │ assert (
47 │ in.valueCoins > in.originalForwardFee + MINIMAL_MESSAGE_VALUE_BOUND
48 │ ) throw ERR_NOT_ENOUGH_AMOUNT_TO_RESPOND;
49 │
50 │ var respondOwnerAddress: Cell<address>? = msg.includeOwnerAddress
Why it's bad: This assertion is not covered by tests. This could lead to security vulnerabilities
if the condition is not enforced.Last updated on