Mutation testing verifies that the test suite actually detects real bugs by introducing small code changes ("mutations") and confirming the tests fail.
This project currently uses mutation testing in two contexts:
| Context | Tool | Target | Status |
|---|---|---|---|
| TypeScript SDK | Stryker | packages/sdk/src/errors.ts |
✅ Implemented & CI-wired |
| Rust Smart Contract | cargo-mutants | contracts/invoice_liquidity/src/lib.rs |
⏳ Proposal (see below) |
Target mutation score: ≥ 80% for the SDK errors module.
- Config file:
packages/sdk/stryker.config.mjs - Runner:
pnpm test:mutation(frompackages/sdk/) - CI:
.github/workflows/mutation-testing.yml- Scheduled: runs weekly (Sunday 06:00 UTC)
- PR-triggered: runs on changes to
errors.ts,errors.test.ts, or Stryker config - Non-blocking: results are advisory; the job uses
continue-on-error: true - Artifact: HTML mutation report is uploaded as a workflow artifact (
mutation-report-sdk-errors) with 30-day retention
packages/sdk/src/errors.ts — a high-value, self-contained module:
- 20+ structured error classes (ILNError hierarchy)
parseContractError()— mapping numeric/string contract errors to typed errorsnormalizeError()— converting arbitrary thrown values to ILNError- 35+ test cases in
packages/sdk/src/errors.test.ts - No external dependencies (pure TypeScript logic)
- Fast mutation run (< 30 seconds on CI)
To add mutation testing for another SDK module:
- Add the file path to the
mutatearray instryker.config.mjs. - Ensure the module has a corresponding
.test.tsfile with adequate coverage. - The CI workflow automatically picks up the new target on the next run.
The original vision for mutation testing was to run
cargo-mutants against the Rust smart contract at
contracts/invoice_liquidity/src/lib.rs. This capability is not yet
implemented for the following reasons:
- The smart contract lives in a separate repository
(
Invoice-Liquidity-Network/ILN-Smart-Contract), included here as a git submodule. - Setting up
cargo-mutantsrequires Rust toolchain and is slower than the TypeScript Stryker runs (10-30 minutes per run). - A CI workflow for Rust mutation testing would need to be implemented in the
ILN-Smart-Contractrepo or in this repo with access to the submodule.
To implement in the future:
- Install
cargo-mutants:cargo install cargo-mutants - Run:
cargo mutants --package invoice_liquidity - Results are written to
mutants.out/in the workspace root. - Add a CI workflow similar to
mutation-testing.ymltargeting./backend/contracts/invoice_liquidity/.
Status: This doc has been updated to describe the actual current state (Stryker for SDK errors) alongside the proposed future work (cargo-mutants for the Rust contract).
The following mutations are expected survivors for the Rust contract target — they are semantically equivalent to the original code in all reachable paths, or they affect code paths that are intentionally left unchecked:
// In notify_distribution_funding / notify_distribution_settlement:
let Some(dist_contract) = env.storage()...get::<_, Address>(...) else {
return; // ← mutating this early return has no observable effect in unit tests
};Why it survives: The distribution contract is not set in unit tests. The early return is exercised (no panic) but any mutation to it would still pass all tests because the notification path is a fire-and-forget side effect not directly observable in the contract's return values.
Mitigation: Integration/e2e tests that set a distribution contract would
catch mutations here. See tests_distribution.rs for partial coverage.
invoice.funder = Some(funder.clone()); // Legacy support commentWhy it survives: The funder field is only set when amount_funded == amount
(full funding). Tests verify invoice.funder == Some(t.funder) post-fund, so
mutations that change this assignment (e.g., None) would be caught. However,
mutations that change the condition for this line (e.g., always assign vs
only on full fund) may survive if partial-fund tests don't check funder.
Mitigation: mt02 and existing funder-field tests cover most paths.
fn discount_rate_as_i128(rate: u32) -> i128 { rate as i128 }Why it survives: This is a pure type cast. Mutations here (e.g., return a constant) would be caught by existing arithmetic tests.
When a mutation testing tool reports a new survivor:
- Identify the mutated line and what invariant it violates.
- Add a targeted test to the relevant
.test.tsfile that asserts the exact boundary value differentiating the original from the mutant. - Re-run
pnpm test:mutationto confirm the new test kills the mutation.
- Identify the mutated line and what invariant it violates.
- Add a targeted test to
src/tests_mutation.rsthat asserts the exact boundary value differentiating the original from the mutant. - Re-run
make mutantsto confirm the new test kills the mutation. - Document the mutation in the table above.