Skip to content

Latest commit

 

History

153 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

mutflow

Mutation testing inside your Kotlin tests. Compile once, catch gaps.

Maven Central

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!

Contents

What is this?

mutflow brings mutation testing to Kotlin with minimal overhead. Instead of the traditional approach (compile and run each mutant separately), mutflow:

  1. Compiles once - All mutation variants are injected at compile time as conditional branches
  2. Discovers dynamically - Mutation points are found during baseline test execution
  3. Runs all mutations - Every discovered mutation is tested by default, no configuration needed

Why?

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.

What mutflow tests (and what it doesn't)

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.

Setup

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>"
}

Kotlin Version Compatibility

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-core to your implementation dependencies (for @MutationTarget annotation)
  • Adds mutflow-junit6 to your test dependencies (for @MutFlowTest annotation)
  • 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.

Specifying Mutation Targets

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.

Disabling Mutation Testing

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=false

Or in gradle.properties:

mutflow.enabled=false

When 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.

Quick Start

// 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, MutantSurvivedException is thrown and the build fails

Example Output

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.

Configuration

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 { ... }

Target Filtering (Integration Tests)

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 mutations
  • excludeTargets: 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.

Timeout Detection

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 MutationTimedOutException suggesting to add // mutflow:ignore on 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.

Traps (Pinning Mutations)

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:

  1. Mutation survives → build fails with display name like (Calculator.kt:8) > → >=
  2. Copy the display name into traps array
  3. Trapped mutation now runs first every time (before random selection)
  4. Fix your test until it catches the mutation
  5. 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) > → >=

Verification Mode

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 entirely

When 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 test

Or for a gradual adoption workflow:

# Run mutation tests but don't block the build yet
MUTFLOW_VERIFICATION_MODE=LENIENT ./gradlew test

The environment variable accepts STRICT, LENIENT, or DISABLED (case-insensitive). Invalid values produce a warning and fall back to the annotation value.

Suppressing Mutations

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.

Selection and Shuffle Parameters

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.

Verifying Production Artifacts

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.jar

It 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-annotations classes (@MutationTarget, @SuppressMutations) - BINARY retention 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-runtime to 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.

Mutation Operators

  • 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!expr for 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. IllegalArgumentExceptionIllegalStateException), 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

Features

Core

  • JUnit 6 integration - @MutFlowTest annotation for automatic multi-run orchestration
  • K2 compiler plugin - Transforms @MutationTarget classes (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: @MutFlowTest tests 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 via MUTFLOW_VERIFICATION_MODE env var
  • @SuppressMutations - Skip mutations on specific classes or functions
  • Comment-based line suppression - // mutflow:ignore and // mutflow:falsePositive to skip individual lines (zero production overhead)
  • Target filtering - includeTargets/excludeTargets to 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-injected checkTimeout() 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 using ThreadLocal (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), and ThrowMutationOperator (for throw statements) interfaces for adding new mutation types

Kotlin Multiplatform Support

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.

Setup

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=KotlinSourceSetDependsOnDefaultCompilationSourceSet

Writing tests

Tests 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)
    }
}

Running

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 host

mutflowJvmTest 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.

Configuration

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.

How it works

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.

Current limitations

  • Targets: linuxX64 and mingwX64 for 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 maxMutationRuns caps runs where they matter most.
  • maxMutationRuns selects 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: commonTest sources cannot name @MutFlowTest, so settings live in the Gradle DSL instead of on the annotation. That split may still be revised. The kotlin("jvm") path is unaffected.

Trying an unpublished target

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=macosArm64

Pass 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 (macosArm64 on an Apple Silicon Mac): full mutation testing. The plugin registers mutflowMacosArm64Test and it works exactly like mutflowLinuxX64Test.
  • A target your machine can only build (any simulator or device target, or macosArm64 from 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 status mingwX64 has 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.

How Mutations Work

How Relational Comparison Mutations Work

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.

How Constant Boundary Mutations Work

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.

How Arithmetic Mutations Work

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.

How Equality Swap Mutations Work

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.

How Boolean Logic Mutations Work

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.

How Boolean Inversion Mutations Work

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.

How Boolean Return Mutations Work

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.

How Nullable Return Mutations Work

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 mutation

Note: 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.

How Void Function Body Mutations Work

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 mutation

Note: Void function body mutations only apply to functions that return Unit, have non-empty bodies, and are not property accessors (getters/setters).

How Exception Type Swap Mutations Work

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 swap

Wrap 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.

Design Decisions

See DESIGN.md for architecture details, design decisions, and implementation plan.

Troubleshooting

Code coverage (JaCoCo/Kover) reports 0% when mutflow is enabled

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 run

See Disabling Mutation Testing for all configuration options.


Co-developed with an AI assistant.

Releases

Contributors

Languages