Mutation testing inside your Kotlin tests. Compile once, catch gaps.
mutflow uses a dual-compilation approach to keep production builds clean - mutations only exist in test compilation. The project is actively maintained and evolving. Bug reports and feedback are welcome!
- What is this?
- Why?
- What mutflow tests (and what it doesn't)
- Setup
- Quick Start
- Configuration
- Verifying Production Artifacts
- Mutation Operators
- Features
- Kotlin Multiplatform Support
- How Mutations Work
- Design Decisions
- Troubleshooting
mutflow brings mutation testing to Kotlin with minimal overhead. Instead of the traditional approach (compile and run each mutant separately), mutflow:
- Compiles once - All mutation variants are injected at compile time as conditional branches
- Discovers dynamically - Mutation points are found during baseline test execution
- Runs all mutations - Every discovered mutation is tested by default, no configuration needed
Traditional mutation testing is powerful but expensive. Most teams skip it entirely.
mutflow trades exhaustiveness for practicality: low setup cost, no separate tooling, runs in your normal test suite. Some mutation testing is better than none.
mutflow closes the gap between code coverage and assertion quality. Coverage tells you code was executed; mutflow verifies your assertions actually catch behavioral changes.
Important: mutflow only tests code reached within MutFlow.underTest { } blocks. Unreached code produces no mutations - mutflow won't warn you. This is intentional (keeps scope focused) but means you should use coverage tools separately to ensure code is exercised at all.
A carefully selected set of mutation operators is built in, with an extensible architecture for adding new ones. See features for the full list of capabilities.
Add the mutflow Gradle plugin to your build.gradle.kts:
plugins {
kotlin("jvm") version "2.4.20"
id("io.github.anschnapp.mutflow") version "<latest-version>"
}mutflow tracks the newest Kotlin release and updates frequently. Because compiler plugins are tightly coupled to the compiler's internal APIs, each mutflow release requires the Kotlin version it was built against - it will not work with older Kotlin versions in your project. If you are on an older Kotlin, use the matching older mutflow release:
| mutflow | Kotlin |
|---|---|
| 1.0.2+ | 2.4.x |
| up to 1.0.1 | 2.2.x - 2.3.x |
A mismatch typically fails with an obscure compiler error (e.g. NoClassDefFoundError
during compilation) - if you see that, check your Kotlin version first.
Once available, the plugin automatically:
- Adds
mutflow-coreto your implementation dependencies (for@MutationTargetannotation) - Adds
mutflow-junit6to your test dependencies (for@MutFlowTestannotation) - Configures the compiler plugin for mutation injection
Important: The plugin uses a dual-compilation approach - your production JAR remains clean (no mutation code), while tests run against mutated code.
There are two ways to tell mutflow which classes to mutate - use either or both:
Option 1: @MutationTarget annotation (on production code)
@MutationTarget
class Calculator { ... }Option 2: Gradle configuration (no annotations on production code)
mutflow {
targets = listOf(
"com.example.Calculator", // exact class
"com.example.service.*", // all classes in a package
"com.example.service.**", // package + all subpackages
"com.example.*Service" // glob pattern
)
}Both can be combined freely. If a class matches either mechanism, it will be mutated. The Gradle config is useful when you prefer not to annotate production code with test-related annotations.
You can completely disable mutation testing without removing the plugin. When disabled, no compiler plugin is registered and no extra compilation happens - zero overhead.
// In build.gradle.kts
mutflow {
enabled = false
}Or via command line:
./gradlew test -Pmutflow.enabled=falseOr in gradle.properties:
mutflow.enabled=falseWhen disabled, your code still compiles normally (@MutationTarget and @MutFlowTest annotations are still available), but tests run without any mutations - the mutation summary will show 0 mutations discovered.
// Mark code under test (Option 1: annotation)
@MutationTarget
class Calculator {
fun isPositive(x: Int) = x > 0
}
// Or use Gradle config instead (Option 2: no annotation needed)
// mutflow { targets = listOf("com.example.Calculator") }
// Test with mutation testing - simple!
@MutFlowTest
class CalculatorTest {
private val calculator = Calculator()
@Test
fun `isPositive returns true for positive numbers`() {
val result = MutFlow.underTest {
calculator.isPositive(5)
}
assertTrue(result)
}
@Test
fun `isPositive returns false for negative numbers`() {
val result = MutFlow.underTest {
calculator.isPositive(-5)
}
assertFalse(result)
}
}That's it! For a more detailed walkthrough, check out this blog post on dev.to.
The @MutFlowTest annotation handles everything:
- Run without mutations: Discovers mutation points, all tests pass normally
- Mutation runs: Each mutation is activated across all tests; if any test catches it (assertion fails), the mutation is killed and tests appear green
- Survivor detection: If no test catches a mutation,
MutantSurvivedExceptionis thrown and the build fails
CalculatorTest > Run without mutations > isPositive returns true for positive numbers() PASSED
CalculatorTest > Run without mutations > isPositive returns true at boundary() PASSED
CalculatorTest > Run without mutations > isPositive returns false for negative numbers() PASSED
CalculatorTest > Run without mutations > isPositive returns false for zero() PASSED
CalculatorTest > Mutation: (Calculator.kt:7) > → >= > ... PASSED
CalculatorTest > Mutation: (Calculator.kt:7) > → < > ... PASSED
CalculatorTest > Mutation: (Calculator.kt:7) 0 → 1 > ... PASSED
CalculatorTest > Mutation: (Calculator.kt:7) 0 → -1 > ... PASSED
╔════════════════════════════════════════════════════════════════╗
║ MUTATION TESTING SUMMARY ║
╠════════════════════════════════════════════════════════════════╣
║ Total mutations discovered: 4 ║
║ Tested this run: 4 ║
║ ├─ Killed: 4 ✓ ║
║ ├─ Survived: 0 ✓ ║
║ └─ Timed out: 0 ✓ ║
║ Remaining untested: 0 ║
╠════════════════════════════════════════════════════════════════╣
║ DETAILS: ║
║ ✓ (Calculator.kt:7) > → >= ║
║ killed by: isPositive returns false for zero() ║
║ ✓ (Calculator.kt:7) > → < ║
║ killed by: isPositive returns false for negative numbers()║
║ killed by: isPositive returns true at boundary() ║
║ killed by: isPositive returns true for positive numbers() ║
║ ✓ (Calculator.kt:7) 0 → 1 ║
║ killed by: isPositive returns true at boundary() ║
║ ✓ (Calculator.kt:7) 0 → -1 ║
║ killed by: isPositive returns false for zero() ║
╚════════════════════════════════════════════════════════════════╝
How to read this output:
- All tests PASSED - This is the expected result! During mutation runs, when a test's assertion fails (catching the mutation), the exception is swallowed and the test appears green.
- Summary shows killed/survived - After all runs complete, the summary shows which mutations were killed (good) vs survived (gap in coverage).
- Build fails with
MutantSurvivedException- Only if a mutation survives (no test caught it). This indicates missing test coverage.
By default, @MutFlowTest runs all discovered mutations - no configuration needed. For large codebases where running all mutations is too slow, you can limit the number of runs:
@MutFlowTest(maxRuns = 20) // Baseline + up to 19 mutation runs
class CalculatorTest { ... }When an integration test exercises multiple @MutationTarget classes but you only want to test mutations in specific ones:
// Only test mutations from Calculator - ignore other @MutationTarget classes reached by underTest
@MutFlowTest(includeTargets = [Calculator::class])
class CalculatorIntegrationTest { ... }
// Test everything except infrastructure classes
@MutFlowTest(excludeTargets = [AuditLogger::class, MetricsService::class])
class PaymentServiceTest { ... }includeTargets: Whitelist - only these classes produce active mutationsexcludeTargets: Blacklist - these classes are skipped- Both can be combined: include narrows first, exclude removes from that set
- Empty (default) = no filtering, all discovered mutations are candidates
All mutation points are still discovered during baseline (for accurate touch counts), but only filtered mutations are selected, counted in the summary, and considered for exhaustion.
Mutations that flip loop conditions (e.g., < to >) can cause infinite loops. mutflow automatically detects this by injecting a timeout check at the top of every loop body in @MutationTarget classes.
- Default timeout: 60 seconds per mutation run
- On timeout: The test fails with a
MutationTimedOutExceptionsuggesting to add// mutflow:ignoreon the affected line - Timed-out mutations appear in the summary with a
⏱marker
// Custom timeout (or 0 to disable)
@MutFlowTest(timeoutMs = 30_000)
class CalculatorTest { ... }The timeout check is nearly free: a single System.nanoTime() comparison per loop iteration during mutation runs, and an instant null-check return during baseline or production execution.
When a mutation survives, you can trap it to run it first every time while you fix the test gap:
@MutFlowTest(
traps = ["(Calculator.kt:8) > → >="] // Copy from survivor output
)
class CalculatorTest { ... }How traps work:
- Mutation survives → build fails with display name like
(Calculator.kt:8) > → >= - Copy the display name into
trapsarray - Trapped mutation now runs first every time (before random selection)
- Fix your test until it catches the mutation
- Remove the trap
Traps run in the order provided, regardless of selection strategy. After all traps are exhausted, normal selection continues.
Invalid trap handling: If a trap doesn't match any discovered mutation (e.g., code moved), a warning is printed with available mutations:
[mutflow] WARNING: Trap not found: (Calculator.kt:999) > → >=
[mutflow] Available mutations:
[mutflow] (Calculator.kt:8) 0 → -1
[mutflow] (Calculator.kt:8) > → >=
By default, mutflow uses strict verification: surviving mutations fail the build. You can change this behavior per test class or globally.
@MutFlowTest(verificationMode = VerificationMode.STRICT) // default — survivors fail the build
@MutFlowTest(verificationMode = VerificationMode.LENIENT) // survivors are reported but don't fail
@MutFlowTest(verificationMode = VerificationMode.DISABLED) // mutation runs are skipped entirelyWhen to use each mode:
STRICT— Default. Use for CI pipelines where mutation coverage must be maintained.LENIENT— Use when building up test coverage incrementally. Mutations still run and are reported in the summary, but survivors don't break the build. This lets you focus on writing regular tests first and address surviving mutations later.DISABLED— Use when you only want fast feedback from regular tests. Only the baseline runs — no mutations are tested at all.
Environment variable override:
The MUTFLOW_VERIFICATION_MODE environment variable overrides the annotation value for all test classes. This enables phased CI pipelines:
# Phase 1: Fast feedback — only regular tests
MUTFLOW_VERIFICATION_MODE=DISABLED ./gradlew test
# Phase 2: Full mutation testing
./gradlew testOr for a gradual adoption workflow:
# Run mutation tests but don't block the build yet
MUTFLOW_VERIFICATION_MODE=LENIENT ./gradlew testThe environment variable accepts STRICT, LENIENT, or DISABLED (case-insensitive). Invalid values produce a warning and fall back to the annotation value.
Suppression works regardless of how the class was targeted (annotation or Gradle config). mutflow provides three levels of suppression granularity:
Class level - skip all mutations in a class:
@MutationTarget
@SuppressMutations
class LegacyCalculator { ... }Function level - skip mutations in a specific function:
@MutationTarget
class Calculator {
@SuppressMutations
fun debugLog(x: Int): Boolean = x > 100
}Line level - skip mutations on a single line using comments:
@MutationTarget
class Calculator {
fun process(x: Int): Boolean {
val threshold = x > 100 // mutflow:ignore this is just a heuristic
// mutflow:falsePositive equivalent mutant, boundary doesn't matter
val inRange = x >= 0
return threshold && inRange
}
}Two comment keywords are supported - same technical effect, different intent:
mutflow:ignore- the code is not worth testing (logging, debug utilities, heuristics)mutflow:falsePositive- the mutation is an equivalent mutant or not meaningful to test
Free-form text after the keyword documents the reason for reviewers.
Inline comment suppresses mutations on the same line. Standalone comment (on its own line) suppresses mutations on the next line. Comments have zero production overhead - they are stripped by the compiler and nothing appears in the production bytecode.
MutFlow.underTest { } accepts optional parameters for controlling mutation selection and ordering:
// Baseline
MutFlow.underTest(run = 0, Selection.MostLikelyStable, Shuffle.PerChange) {
calculator.isPositive(5)
}
// Mutation runs
MutFlow.underTest(run = 1, Selection.MostLikelyStable, Shuffle.PerChange) {
calculator.isPositive(5)
}Selection strategies (PureRandom, MostLikelyRandom, MostLikelyStable) and shuffle modes (PerRun, PerChange) control how mutations are prioritized. The @MutFlowTest annotation uses sensible defaults automatically - these parameters are only needed for custom integrations.
The Gradle plugin compiles mutations into a separate mutatedMain source set, so your production JAR never contains them. If you want a hard guarantee in your release pipeline (for example right before docker build), use the shipped check script:
scripts/mutflow-verify-jar.sh build/libs/my-app.jarIt scans every class in the archive, including nested archives such as Spring Boot's BOOT-INF/lib/*.jar and shadow JARs, and fails if a class references the mutflow runtime registry (MutationRegistry), which is what an injected mutation switch looks like in bytecode.
What is fine in a production artifact:
- the
mutflow-annotationsclasses (@MutationTarget,@SuppressMutations) -BINARYretention markers with no runtime behavior - your own classes annotated with
@MutationTarget
What fails the check:
- any class carrying injected mutation switches
- bundled mutflow core/runtime classes (pass
--allow-bundled-runtimeto permit them)
Exit codes: 0 clean, 1 findings, 2 usage error or missing unzip.
Typical CI usage:
./gradlew build
scripts/mutflow-verify-jar.sh build/libs/*.jar || exit 1
docker build -t my-app .Example failure output:
MUTATION build/libs/my-app.jar!/BOOT-INF/classes/com/example/PricingService.class
FAILED: found 1 class(es) containing mutflow mutations.
The artifact was built with the mutflow compiler plugin applied to production code.
The script requires bash and unzip. It is tested end-to-end by scripts/test-mutflow-verify-jar.sh, which runs on every pull request: it publishes mutflow to mavenLocal, builds a small consumer project with the real Gradle plugin, and asserts that the production JAR passes while the mutatedMain compilation of the very same sources is rejected.
- Relational comparisons -
>,<,>=,<=with 2 variants each (boundary + flip) - Constant boundary - Numeric constants in comparisons mutated by +1/-1 (e.g.,
0 → 1,0 → -1) - Arithmetic -
+↔-,*↔/,%→/(with safe division to avoid div-by-zero) - Equality swaps -
==↔!=(1 variant each) - Boolean logic swaps -
&&↔||(1 variant each) - Boolean inversion -
expr→!exprfor boolean function calls, property accesses, and variables/parameters (1 variant each) - Boolean return - Boolean return values replaced with
true/false(explicit returns only) - Nullable return - Nullable return values replaced with
null(explicit returns only) - Void function body - Unit function bodies replaced with empty bodies, detecting untested side effects
- Exception type swap - Thrown exceptions replaced with a sibling type (e.g.
IllegalArgumentException→IllegalStateException), detecting tests that assert something threw but not what - Recursive operator nesting - Multiple mutation types combine on the same expression
- Type-agnostic - Works with
Int,Long,Double,Float,Short,Byte,Char
Core
- JUnit 6 integration -
@MutFlowTestannotation for automatic multi-run orchestration - K2 compiler plugin - Transforms
@MutationTargetclasses (or Gradle-configured target patterns) with multiple mutation types - Parameterless API - Simple
MutFlow.underTest { }when using JUnit extension - Runs all mutations by default - Zero-config:
@MutFlowTesttests every discovered mutation
Reporting
- Summary reporting - Visual summary at end of test class showing killed/survived mutations
- Readable mutation names - Source location and operator descriptions (e.g.,
(Calculator.kt:7) > → >=,(Calculator.kt:7) 0 → 1). When the same operator appears multiple times on one line, an occurrence suffix disambiguates (e.g.,> → >= #2) - IDE-clickable links - Source locations in IntelliJ-compatible format for quick navigation
- Mutation result tracking - Killed mutations show as PASSED (exception swallowed), survivors fail the build
Control
- Verification mode -
STRICT(default, survivors fail),LENIENT(survivors reported only),DISABLED(skip mutations). Configurable per annotation or globally viaMUTFLOW_VERIFICATION_MODEenv var @SuppressMutations- Skip mutations on specific classes or functions- Comment-based line suppression -
// mutflow:ignoreand// mutflow:falsePositiveto skip individual lines (zero production overhead) - Target filtering -
includeTargets/excludeTargetsto scope mutations by class in integration tests - Trap mechanism - Pin specific mutations to run first while debugging test gaps
Robustness
- Timeout detection - Mutations that cause infinite loops (e.g., flipping
<in a loop condition) are automatically detected and reported. Compiler-injectedcheckTimeout()at the top of every loop body ensures even tight loops are caught. Test fails with actionable guidance to add// mutflow:ignore - Partial run detection - Automatically skips mutation testing when running single tests from IDE (prevents false positives)
- Parallel test safe - Mutation test classes can run alongside other tests in parallel;
underTest {}blocks serialize automatically via a synchronized lock, without usingThreadLocal(keeping the door open for coroutine/reactive support) - Session-based architecture - Clean lifecycle, no leaked global state
Extensibility
- Extensible architecture -
MutationOperator(for calls),ReturnMutationOperator(for returns),WhenMutationOperator(for boolean logic),FunctionBodyMutationOperator(for function bodies), andThrowMutationOperator(for throw statements) interfaces for adding new mutation types
mutflow also runs on Kotlin/Native targets in Multiplatform projects. The
published targets are linuxX64 and mingwX64. The Gradle wiring is generic
over native targets rather than written per target, so it is designed to work on
any target where Kotlin/Native tests can run at all, but the published list is
deliberately narrower than that: a target ships once its tests have been run
somewhere, and covering every target Kotlin supports is not a goal here. Within
this project the native path is verified on linuxX64 (see Current limitations
below, and Trying an unpublished target if you
need one that is not shipped).
To our knowledge it is the first mutation testing tool for Kotlin/Native: traditional tools mutate JVM bytecode, which does not exist on Native, and recompiling per mutant is impractical with native compile times. mutflow's compile-once approach sidesteps both problems.
Apply the same plugin in a KMP project:
plugins {
kotlin("multiplatform") version "2.4.20"
id("io.github.anschnapp.mutflow") version "<latest-version>"
}
kotlin {
jvm()
linuxX64()
// ... other targets
}Every target gets mutation testing. They differ only in how the runs are
driven: the jvm() target uses the ordinary in-process JUnit path, native
targets get one process per mutation from a Gradle task.
Add one line to gradle.properties (the instrumented test compilation shares
sources with the main compilation, which KGP warns about; this suppresses only
that specific diagnostic):
kotlin.suppressGradlePluginWarnings=KotlinSourceSetDependsOnDefaultCompilationSourceSetTests are plain kotlin-test in commonTest with the same underTest API - no
JUnit import, no annotation. The same file is the test suite for every target;
on the jvm() target the compiler plugin synthesizes the @MutFlowTest that
commonTest cannot name in source:
class CalculatorTest {
@Test
fun positiveNumber() {
val result = MutFlow.underTest { calculator.isPositive(5) }
assertTrue(result)
}
}Mutation testing is a dedicated, opt-in task per target (plain test/check
runs are untouched):
./gradlew mutflowJvmTest # the jvm() target, in-process JUnit runs
./gradlew mutflowLinuxX64Test # one native target
./gradlew mutflowNativeTest # all native targets runnable on this hostmutflowJvmTest re-runs each test class once per mutation inside one JVM, so
the IDE test tree shows every run - the same experience a plain
kotlin("jvm") project gets. The native task instead runs the test binary
once for discovery and then once per mutation, printing the same summary. A
failing test suite during a mutation run means the mutation was killed;
survivors fail the build either way.
In a multiplatform project the @MutFlowTest parameters live in the Gradle
DSL, and apply to every target:
mutflow {
maxMutationRuns = 20 // default: unlimited (all mutations)
timeoutMs = 60_000L // infinite-loop protection deadline
verificationMode = "STRICT" // STRICT | LENIENT | DISABLED
}There is no annotation to configure instead: the one on multiplatform test
classes is generated, so the DSL is the configuration surface. (A plain
kotlin("jvm") project keeps configuring @MutFlowTest directly.)
MUTFLOW_VERIFICATION_MODE overrides the mode per run, like on the JVM.
@MutationTarget, Gradle target patterns, @SuppressMutations and the
comment-based suppression (// mutflow:ignore, // mutflow:falsePositive)
work unchanged - they are compile-time and backend-neutral. Timed-out
mutations (infinite loops) fail the build with the affected line, same
fail-loudly rule as the JVM.
The compiler plugin is backend-agnostic and runs unchanged. What differs is
orchestration: kotlin-test on Native has no extension mechanism, so the run
loop lives in the Gradle task - one process per mutation, activated via an
environment variable, with the exit code deciding killed vs. survived. The
jvm() target keeps the in-process JUnit loop; it only needs the compiler
plugin to write @MutFlowTest into the bytecode, because commonTest sources
must also compile for Native and cannot name a JVM-only annotation.
Production klibs, jars and binaries stay clean: instrumentation only exists in
a dedicated second test compilation. See
DESIGN-MULTIPLATFORM.md for the full architecture
and example-native/ for a working project.
- Targets:
linuxX64andmingwX64for now. mingwX64 cross-compiles from Linux but has not yet been exercised on a Windows host. macOS and Apple simulator targets are not published yet - not for any code reason, but because we have no Apple machine to run their tests on, and shipping a mutation testing tool for a target whose tests we have never executed is a promise we are not making yet. If you have a Mac, you can build them yourself today - see Trying an unpublished target. - No traps and no random selection strategies on Native yet; mutations
run in the deterministic most-likely-to-survive order (fewest-touched
first), so
maxMutationRunscaps runs where they matter most. maxMutationRunsselects per target, so with a cap set the JVM and native targets may test different subsets. Unlimited runs (the default) are unaffected.- No IDE test-tree integration on Native: results arrive as Gradle output
plus the summary. Recommended workflow in KMP projects: develop against the
jvm()target for interactive feedback, run native mutation verification in CI. - The multiplatform configuration surface is young:
commonTestsources cannot name@MutFlowTest, so settings live in the Gradle DSL instead of on the annotation. That split may still be revised. Thekotlin("jvm")path is unaffected.
The native targets mutflow publishes are the ones it supports, and that is a
rule of Kotlin Multiplatform rather than a policy: a project can only depend on
a KMP library whose target set is a superset of its own. Declaring
macosArm64() against a mutflow that does not publish macosArm64 gives you a
"no matching variant" resolution failure, not a degraded mutflow.
Nothing in mutflow's source is target-specific, though. The native
implementations live in a shared nativeMain source set that every native
target inherits, and the Gradle plugin is written generically over targets
rather than per target. So you can build the artifacts for a target we do not
publish, without editing any build file:
git clone https://github.com/anschnapp/mutflow && cd mutflow
./gradlew publishToMavenLocal -Pmutflow.extraNativeTargets=macosArm64Pass a comma-separated list for several (macosArm64,macosX64). The name is
the Kotlin target function name. All mutflow modules read the same property, so
their target sets stay consistent automatically.
Then consume it from your own project:
repositories {
mavenLocal() // before mavenCentral()
mavenCentral()
}Use the same version the clone builds (0.1.0-SNAPSHOT by default, or pass
-PreleaseVersion=<something>).
What you get depends on the target:
- A target your machine can run (
macosArm64on an Apple Silicon Mac): full mutation testing. The plugin registersmutflowMacosArm64Testand it works exactly likemutflowLinuxX64Test. - A target your machine can only build (any simulator or device target, or
macosArm64from Linux - Apple klibs cross-compile fine): the instrumented test binary is produced as compile proof, but no mutation task is registered, since the orchestrator has to execute the binary to run mutations. This is the same statusmingwX64has on a Linux host.
If you try a target this way and it works, please open an issue - a report from a machine we do not have is exactly what a target needs to move into the published set.
Relational comparison mutations verify that your tests exercise boundary conditions and direction of comparisons.
Example: For fun isPositive(x: Int) = x > 0:
| Mutation | Code becomes | Caught by test |
|---|---|---|
> → >= |
x >= 0 |
isPositive(0) should be false |
> → < |
x < 0 |
isPositive(1) should be true |
Each relational operator produces 2 variants - a boundary mutation (include/exclude equality) and a direction flip:
| Original | Boundary variant | Flip variant |
|---|---|---|
> |
>= |
< |
>= |
> |
<= |
< |
<= |
> |
<= |
< |
>= |
If your tests only use values far from the boundary (e.g., isPositive(5) and isPositive(-5)), the boundary variant may survive - revealing the gap.
The constant boundary mutation detects poorly tested boundaries that operator mutations alone cannot find.
Example: For fun isPositive(x: Int) = x > 0:
| Mutation | Code becomes | Caught by test |
|---|---|---|
> → >= |
x >= 0 |
isPositive(0) should be false |
> → < |
x < 0 |
isPositive(1) should be true |
0 → 1 |
x > 1 |
isPositive(1) should be true |
0 → -1 |
x > -1 |
isPositive(0) should be false |
If your tests only use values far from the boundary (e.g., isPositive(5) and isPositive(-5)), the constant mutations will survive - revealing the gap in boundary testing.
Arithmetic mutations verify that your tests detect when math operations are swapped.
Example: For fun total(price: Int, tax: Int) = price + tax:
| Mutation | Code becomes | Caught by test |
|---|---|---|
+ → - |
price - tax |
total(100, 10) should be 110 |
The full set of arithmetic swaps:
| Original | Mutated to |
|---|---|
+ |
- |
- |
+ |
* |
/ (with safe division) |
/ |
* |
% |
/ |
Safe division: When mutating * to /, mutflow guards against division by zero. If the divisor is 0, it computes divisor / dividend instead; if both are 0, it returns 1. This prevents ArithmeticException from masking the actual mutation test.
Equality swap mutations verify that your tests distinguish between == and != conditions.
Example: For fun isZero(x: Int) = x == 0:
| Mutation | Code becomes | Caught by test |
|---|---|---|
== → != |
x != 0 |
isZero(0) should be true |
And for fun isNotZero(x: Int) = x != 0:
| Mutation | Code becomes | Caught by test |
|---|---|---|
!= → == |
x == 0 |
isNotZero(5) should be true |
If your tests only exercise values where == and != produce different results for obvious inputs, the mutations will be killed. But if tests use ambiguous inputs or don't assert the return value, survivors reveal the gap.
Boolean logic mutations verify that your tests distinguish between && (AND) and || (OR) conditions.
Example: For fun isInRange(x: Int) = x >= 0 && x <= 100:
| Mutation | Code becomes | Caught by test |
|---|---|---|
&& → || |
x >= 0 || x <= 100 |
isInRange(-5) should be false |
And for fun isOutOfRange(x: Int) = x < 0 || x > 100:
| Mutation | Code becomes | Caught by test |
|---|---|---|
|| → && |
x < 0 && x > 100 |
isOutOfRange(-5) should be true |
These mutations catch tests that only exercise the "happy path" where both conditions agree. If && and || would produce the same result for all your test inputs, the mutation survives - revealing that your tests don't cover the case where the two conditions disagree.
Boolean inversion mutations verify that your tests detect when a boolean value is flipped. Every boolean expression - function calls, property accesses, and variable/parameter reads - is mutated by wrapping it in !.
Function calls and property accesses (expr() → !expr()):
fun isActive(user: User): Boolean {
return user.isEnabled()
}| Mutation | Code becomes | Caught by test |
|---|---|---|
isEnabled() → !isEnabled() |
return !user.isEnabled() |
isActive(enabledUser) should be true |
Boolean variables and parameters (varName → !varName):
fun inheritedSelection(invertSelection: Boolean, parentSelected: Boolean): Boolean {
return if (invertSelection) !parentSelected else parentSelected
}| Mutation | Code becomes | Caught by test |
|---|---|---|
invertSelection → !invertSelection |
if (!invertSelection) |
Test with invertSelection=true should invert |
parentSelected → !parentSelected |
!(!parentSelected) / !parentSelected |
Test should verify both branches |
Discarded results are not inverted: a boolean call whose value is thrown away, such as list.add(x) or flow.tryEmit(value) on its own line, gets no inversion point. The call and its arguments still run exactly the same, so the mutant would be equivalent and no test could kill it. Only the outermost call of a statement counts as discarded; in rows.add(x > 0) the > is still mutated.
Negation removal is implicit: There is no separate "remove !" mutation. Adding ! to the inner expression of !expr produces !(!expr), which evaluates to expr - achieving the same effect. This simplification covers all boolean types uniformly without special-casing negation.
These mutations catch tests that don't verify the polarity of boolean results. If your test calls a function but doesn't assert the actual boolean value, the inversion mutation will survive.
Boolean return mutations verify that your tests actually check return values, not just that the code runs without error.
Example: For a function with explicit returns:
fun isInRange(x: Int, min: Int, max: Int): Boolean {
if (x < min) return false
if (x > max) return false
return true
}| Mutation | Original | Becomes | Caught when |
|---|---|---|---|
return false → true |
return false |
return true |
Test asserts false for out-of-range |
return false → false |
return false |
return false |
(no change - original behavior) |
return true → true |
return true |
return true |
(no change - original behavior) |
return true → false |
return true |
return false |
Test asserts true for in-range |
Note: Boolean return mutations only apply to explicit return statements in block-bodied functions. Expression-bodied functions (fun foo() = expr) are mutated via their expression operators instead.
Nullable return mutations verify that your tests check actual return values, not just non-null.
Example: For a function that returns nullable:
fun findUser(id: Int): User? {
val user = database.query(id)
if (user != null) {
return user
}
return null
}| Mutation | Original | Becomes | Caught when |
|---|---|---|---|
return user → null |
return user |
return null |
Test asserts actual user properties |
Common weak test patterns this catches:
// WEAK: Only checks non-null, not the actual value
val user = findUser(1)
assertNotNull(user) // Would still pass with null mutation? NO - but doesn't verify content
// WEAK: Uses the value but doesn't verify it
val user = findUser(1)
println(user?.name) // No assertion at all!
// STRONG: Verifies actual content
val user = findUser(1)
assertEquals("Alice", user?.name) // Catches null mutationNote: Nullable return mutations only apply to explicit return statements in block-bodied functions that return nullable types. The mutation replaces the return value with null.
Void function body mutations verify that your tests check side effects, not just that a function can be called without error.
Example: For a Unit function with side effects:
fun addItem(item: String) {
items.add(item)
updateCount()
}| Mutation | Original | Becomes | Caught when |
|---|---|---|---|
| empty body | full body | { } |
Test asserts items contains the added item |
Common weak test patterns this catches:
// WEAK: Only calls the function, doesn't verify anything
service.addItem("apple")
// No assertion!
// WEAK: Only checks that no exception is thrown
assertDoesNotThrow { service.addItem("apple") }
// STRONG: Verifies the side effect
service.addItem("apple")
assertEquals(listOf("apple"), service.getItems()) // Catches empty body mutationNote: Void function body mutations only apply to functions that return Unit, have non-empty bodies, and are not property accessors (getters/setters).
Exception type swap mutations verify that your tests assert which exception was thrown, not merely that the call failed.
Example: For a function that validates its input:
fun withdraw(amount: Int) {
if (amount <= 0) throw IllegalArgumentException("amount must be positive")
...
}| Mutation | Original | Becomes | Caught when |
|---|---|---|---|
| type swap | IllegalArgumentException |
IllegalStateException |
Test asserts the specific exception type |
Common weak test patterns this catches:
// WEAK: Only checks that something went wrong
assertThrows<Exception> { account.withdraw(-5) }
// WEAK: Catches the supertype, so a swapped sibling still matches
assertThrows<RuntimeException> { account.withdraw(-5) }
// STRONG: Asserts the exact type
assertThrows<IllegalArgumentException> { account.withdraw(-5) } // Catches the type swapWrap the call as usual and assert around it - the exception escaping the block is fine, mutation points reached before the throw are still discovered:
assertThrows<IllegalArgumentException> {
MutFlow.underTest { account.withdraw(-5) }
}Swap pairs:
| Original | Becomes |
|---|---|
IllegalArgumentException |
IllegalStateException |
IllegalStateException |
IllegalArgumentException |
NullPointerException |
IllegalArgumentException |
IndexOutOfBoundsException |
IllegalStateException |
UnsupportedOperationException |
IllegalStateException |
ClassCastException |
IllegalArgumentException |
NumberFormatException |
IllegalStateException |
ArithmeticException |
IllegalStateException |
NoSuchElementException |
IllegalStateException |
Pairs are chosen so that neither type is a subtype of the other. A swap to a subtype would be an equivalent mutant, since a catch of the supertype matches both. This is why NumberFormatException is paired with IllegalStateException rather than with IllegalArgumentException, which it extends.
Constructor arguments are copied by position onto a matching constructor of the target type, so throw IllegalArgumentException(message, cause) mutates to IllegalStateException(message, cause).
Note: Only a throw of a direct constructor call is mutated. val e = IllegalStateException(); throw e is not, since the thrown expression is a variable read rather than a constructor call. Constructs that eventually become throws (!!, TODO(), require/check, exhaustive when without else) are never mutated by this operator either, because they are still ordinary calls at the point where the compiler plugin runs.
See DESIGN.md for architecture details, design decisions, and implementation plan.
mutflow compiles your sources twice - once normally (main) and once with mutations injected (mutatedMain). During tests, the mutated classes are loaded instead of the original ones. Since coverage tools instrument the main classes, they see no execution.
Solution: Run coverage and mutation testing as separate steps:
./gradlew test -Pmutflow.enabled=false # coverage run
./gradlew test # mutation testing runSee Disabling Mutation Testing for all configuration options.
Co-developed with an AI assistant.
