Why I Built a Mutation Testing Tool from Scratch

· 15 min read

From the limits of code coverage to a parallel mutation pipeline in Swift 6 — the reasoning, the architecture, and the benchmarks.


The Question That Started Everything

I shipped a feature with 100% test coverage. Every line touched, every branch exercised. I deployed with confidence — and the bug report arrived hours later.

The problem was not missing tests. It was missing assertions. The tests executed the code but didn’t care about the result. They tested presence, not behavior.

Mutation testing solves exactly this. Instead of asking “does your test execute this line?”, it asks: “would your test notice if this line were wrong?”

The mechanics are surgical: the tool introduces small mutations in the source code — swaps a > for >=, inverts a true to false, removes a return — and runs the test suite for each mutation. If the tests keep passing with the wrong code, the mutant survived. That is a hole in your suite. A test that does not detect a deliberate bug will not detect an accidental one either.

That question — do my tests actually matter? — was the motivation behind building swift-mutation-testing.


Why Build from Zero?

Muter already existed as the only mutation testing tool for Swift with any maturity. So why reinvent the wheel?

Three practical reasons:

Swift 6 strict concurrency. Muter was built before Swift 6 and carries concurrency abstractions that collide with the new data-race safety guarantees. Adapting it would be as much work as rebuilding with the right constraints from the start.

Xcode coupling. The dependency on XCTest and Xcode-specific build tools limited usage in pure CI environments and SPM projects. I wanted a tool that worked natively in any Swift context.

Full stack visibility. Building from zero means understanding every trade-off. Mutation testing is inherently slow — and to optimize intelligently, you need to know exactly where the time is being spent.


Two Architectural Hypotheses

Although mutation testing is well established in languages like Java and Kotlin, applying it to the iOS ecosystem presents structural challenges. The testing workflow depends heavily on Xcode tools — xcodebuild, CoreSimulator, and XCTest — which were designed for functional test execution, not for the intensive workloads that mutation testing demands.

The main obstacles are:

  • High Swift compilation cost
  • CoreSimulator parallelization limits
  • XCTest concurrency restrictions
  • Heavy hardware consumption when running multiple simulators

Facing these constraints, I formulated two experimental architectural hypotheses that attack the core bottleneck: the computational effort of compiling and executing mutants.

Hypothesis A — Mutation with Incremental Recompilation

The first hypothesis assumes a traditional mutation testing model: each mutant generates a new version of the code that must be recompiled before running the tests.

source.swift
   |
   v
AST mutation
   |
   v
file replacement
   |
   v
incremental recompilation
   |
   v
test execution

The central bet is that incremental recompilations combined with parallel execution across multiple simulators can reduce total time.

Complexity model:

If M = number of mutants, C = incremental compilation cost, T = test execution time:

O(M x (C + T))

In practice: O(M x C), because compilation cost typically dominates total time.

Consequences: intensive CPU usage from successive recompilations, high disk usage from multiple build states, memory pressure from multiple simulators, and linear growth in total time as mutant count increases. Efficiency depends directly on the behavior of Swift’s incremental build, which can be unpredictable in large projects.

Hypothesis B — Runtime Conditional Mutation with a Single Build

The second hypothesis eliminates per-mutant recompilation entirely. All mutants are injected into the code via AST transformation and activated dynamically at runtime using an environment variable.

Original code: a + b

Transformed code:

ProcessInfo.processInfo.environment["ACTIVE_MUTANT"] == "ID_123"
    ? (a - b)
    : (a + b)

Each mutant becomes a dormant conditional branch in the final binary.

source.swift
   |
   v
AST injection (conditional mutants)
   |
   v
single build
   |
   v
binary containing all mutants
   |
   v
execution activating mutants via environment variable

Complexity model:

The build happens only once: O(1)

Total execution: O(1) + O(M x T)

Compilation cost no longer grows with the number of mutants. The hardware shifts from being consumed by the compiler to being consumed by parallel test execution.

Architectural Trade-offs

CriterionHypothesis AHypothesis B
Builds requiredO(M)O(1)
Compiler dependencyHighLow
Runtime complexityLowModerate
AST complexityMediumHigh
Total computational effortHighSignificantly lower
Hardware consumptionDominated by compilationDominated by test execution

Xcode Ecosystem Limitations

Regardless of approach, structural limitations remain:

DerivedData locks. The Xcode build system uses global locks on build directories, causing contention when multiple processes build simultaneously.

CoreSimulator scalability. Each simulator consumes approximately 1-1.5 GB of memory:

RAMStable simulators
16 GB3-4
32 GB6-8
64 GB10+

XCTest limitations. XCTest was not designed for mutation testing workloads and can exhibit flaky tests, shared global state, and hidden dependencies between tests — all of which generate false surviving mutants.

The Verdict

The analysis indicated that Hypothesis B had the greater potential, since it shifts the dominant cost from repeated compilations to test execution. However, schemata-based injection involves complex edge cases with code generation and protocols. For the initial implementation, I proceeded with Hypothesis A’s per-mutant recompilation model — simpler to get correct — while designing the pipeline to accommodate Hypothesis B later.


From POC to Pipeline

Before writing a line of production code, I ran a three-day proof of concept. The goal was to validate technical hypotheses that, if wrong, would invalidate the entire design.

The POC yielded four non-negotiable constraints:

  1. Never use textual substitution to apply mutations. replacingOccurrences mutates all occurrences of a token in the file. Use SyntaxRewriter with utf8Offset to target exactly the intended AST node.

  2. Process + Pipe causes deadlock. Calling readDataToEndOfFile() on a live process blocks the thread while the process tries to write more than the pipe buffer can hold. The solution: redirect stdout/stderr to FileHandle.nullDevice (phase 1) or write to a temporary file and read after the terminationHandler fires (phase 2).

  3. SwiftSyntax attributes need trimmedDescription. attributeName.description includes trivia (whitespace) and causes false negatives in attribute detection.

  4. The Swift Testing filter format is TargetName/SuiteName/testName. Isolated function names are not sufficient.

With these constraints validated, the architecture emerged naturally as a pipeline of transformations:

Files -> Parser -> MutantGenerator -> MutantApplicator -> TestRunner -> Reporter

Each stage is a pure transformation: receives input, produces output, no shared state. This is not ideology — it is necessity. Swift 6 strict concurrency turns any shared mutable state between concurrent tasks into a compilation error, not a runtime bug.


Strict Concurrency in Practice

Swift 6 with .swiftLanguageMode(.v6) is different from any prior version. The compiler tracks data ownership across concurrent tasks and rejects code that could cause data races.

In practice, this means everything that crosses a concurrency boundary must be Sendable. Every type in the model — SourceFile, ParsedSource, MutationPoint, MutantResult, MutationSummary — had to be Sendable. Because they are value-type structs with Sendable properties, this came for free. But the compiler forced me to verify each boundary explicitly.

The benefit is proportional to the cost: when the code compiles, you have a static guarantee of no data races. It is not a property you discover in production — it is an invariant verified at compile time.


Precise Mutations: AST, Not Text

The simplest operator — RelationalOperatorReplacement — substitutes > with >=, < with <=, and so on. It sounds trivial. The implementation is not.

The naive approach would be to replace the token textually in the file. The problem: a file can have dozens of occurrences of the same operator. replacingOccurrences substitutes all of them at once, generating a mutation that mixes multiple mutation points in a single file. The results are unusable.

The correct approach uses SwiftSyntax:

final class RelationalOperatorVisitor: SyntaxVisitor {
    override func visit(_ node: BinaryOperatorExprSyntax) -> SyntaxVisitorContinueKind {
        let utf8Offset = converter.location(for: node.position).offset
        // Register the MutationPoint with precise file offset
        return .visitChildren
    }
}

And a SyntaxRewriter that, given a specific utf8Offset, rewrites only that node:

final class MutationRewriter: SyntaxRewriter {
    override func visit(_ node: BinaryOperatorExprSyntax) -> ExprSyntax {
        guard node.position.utf8Offset == targetOffset else {
            return super.visit(node)
        }
        return ExprSyntax(node.with(\.operator, .binaryOperator(replacement)))
    }
}

One file, one mutant, one surgical modification. Any other approach produces garbage.


To test each mutant in isolation, each execution needs a copy of the project with exactly one modified file. Copying the entire project for each mutant is infeasible — real projects have hundreds of megabytes in .build/.

The solution was a sandbox based on symlinks:

/tmp/swift-mutation-testing-<UUID>/
├── Sources/
│   ├── Calculator.swift  -> MUTATED CONTENT (real file)
│   ├── Validator.swift   -> symlink -> /project/Sources/Validator.swift
│   └── Sorter.swift      -> symlink -> /project/Sources/Sorter.swift
├── Tests/
│   └── ...               -> symlinks
├── Package.swift         -> symlink
└── .build/
    └── checkouts/        -> symlink -> /project/.build/checkouts

The main .build/ directory is ignored in the copy — each sandbox compiles from scratch. But .build/checkouts is symlinked, preventing SPM from downloading external dependencies again. The only file that exists as real content is the mutated file.

Creating a sandbox costs ~50ms. Before this optimization, it cost seconds of copy I/O.


The Sequential Executor: Functional, but Slow

The first version of MutationExecutor was straightforward:

for mutant in mutants {
    let sandbox = try await sandboxFactory.create(
        projectPath: configuration.projectPath,
        mutatedFilePath: mutant.filePath,
        mutatedContent: rewriter.rewrite(mutant)
    )
    defer { try? sandbox.remove() }
    let outcome = try await testRunner.run(
        projectPath: sandbox.rootURL.path,
        timeout: configuration.timeout
    )
    results.append(MutantResult(mutationPoint: mutant, status: outcome.status, ...))
}

Works perfectly. And is perfectly slow.

Benchmark on a controlled setup — 70 mutants, 3 files, 34 tests:

MetricValue
Total duration (avg of 3 runs)164.0s
Time per mutant2.34s
CPU utilization~24% (1 active core)

Cost decomposition per mutant:

StageTime
Sandbox creation~0.05s
Recompilation (swift build)~1.60s
Test execution~0.36s
Overhead (I/O, parsing, process)~0.33s

Recompilation dominates. For real projects, the cost scales with project size and test suite length. Projection for swift-marshal (23s per swift test): ~4 hours of sequential execution for ~530 mutants. Impractical as part of a CI cycle.


The Leap: Sequential to Parallel

The obvious solution is to execute multiple mutants in parallel. The implementation is less obvious.

What does not work: TaskGroup without concurrency control

The first intuition would be:

await withThrowingTaskGroup(of: MutantResult.self) { group in
    for mutant in mutants {
        group.addTask { try await execute(mutant) }
    }
    for try await result in group { results.append(result) }
}

With 70 mutants, this spawns 70 simultaneous tasks. Each one creates a sandbox, calls swift test, and competes for CPU, disk, and memory. Instead of 70x speedup, you get extreme contention — the macOS I/O scheduler collapses with dozens of Swift compiler processes writing build artifacts simultaneously.

What works: back-pressure with controlled concurrency

The correct implementation uses a sliding window pattern:

try await withThrowingTaskGroup(of: MutantResult.self) { group in
    var iterator = mutants.makeIterator()
    var inFlight = 0

    // Initial seed: up to 'concurrency' tasks
    while inFlight < concurrency, let mutant = iterator.next() {
        group.addTask { try await self.executeSingle(mutant, sources: sources) }
        inFlight += 1
    }

    // As each task completes, add the next one
    for try await result in group {
        results.append(result)
        if let mutant = iterator.next() {
            group.addTask { try await self.executeSingle(mutant, sources: sources) }
        }
    }
}

The pattern is elegant: for try await result in group blocks until the next task completes. When it does, if there are still mutants in the queue, a new task is added immediately. The number of running tasks never exceeds concurrency.

The default value for concurrency is max(1, ProcessInfo.processInfo.processorCount - 1). I leave one core free intentionally — for the operating system, for the UI, for any process that needs attention while the mutation run happens.

Swift 6 and closure capture constraints

The group.addTask pattern with Swift 6 strict concurrency introduces a subtle constraint: any value captured by the closure must be Sendable. In this case, mutant (of type MutationPoint) and self (the MutationExecutor) must be Sendable.

MutationPoint is a value-type struct — implicitly Sendable. MutationExecutor is a struct with immutable properties — also implicitly Sendable. The compiler accepts it. If any of these were, say, a class with mutable state, the code would not compile. That is exactly the value of Swift 6: the problem would be caught at compile time, not in production with an intermittent race condition.

The baseline before mutations

One complexity that parallelism does not solve: before executing any mutant, I need a baseline — running the test suite without any mutation and verifying that everything passes. It makes no sense to start a mutation run if the suite is already broken.

More than that, the baseline gives me the actual test execution time in that environment — and I use it to calculate the timeout for each mutant:

timeout = max(baselineDuration * 2.0, RunConfiguration.defaultTimeout)

The factor 2.0 is conservative. A test that normally runs in 5s gets a timeout of 10s. A mutant that causes an infinite loop is detected. A mutant that simply makes tests slower is not penalized unfairly.


The Benchmarks

I measured the parallel executor under the same conditions as the sequential baseline:

MetricSequentialParallelDelta
Total duration (avg)164.0s96.4s-41.2%
Time per mutant2.34s1.38s-41.0%
CPU utilization~24%~380%+15.8x
Mutation score74.3%74.3%=
Timeouts detected22=
Speedup1x1.70x

Correctness was preserved: same score, same timeouts, same mutants survived vs killed. Parallelism did not change the result — only the time.

Why 1.70x and not 15x?

With concurrency = 15 on a 16-core machine, the theoretical maximum speedup would be 15x. I observed 1.70x. The gap is instructive.

The bottleneck is not CPU — utilization went from 24% to 380%, a 15.8x gain. The bottleneck is I/O.

Each sandbox executes swift test in a directory without build cache. With 15 simultaneous compilations:

  • The Swift compiler uses multiple threads internally. Fifteen processes times N threads each equals hundreds of threads competing for CPU and disk simultaneously.
  • The macOS I/O scheduler suffers contention with many parallel accesses to the same storage volume.
  • SPM needs to write independent build artifacts for each sandbox — tens of gigabytes of parallel I/O.

The result is that each individual compilation becomes slower when executed in parallel with 14 others. The cold-sandbox compilation that cost 1.60s sequentially costs ~2.40s in parallel — because it is competing for disk with 14 other simultaneous compilations.

Parallelism compensates for this individual degradation, but the net gain is 1.70x, not 15x. This is not a design bug — it is a property of the problem. The real bottleneck is recompilation, not serialization.


The Reporter Ecosystem

With the execution pipeline working, the reporting layer was natural. I defined a minimal protocol:

protocol Reporter: Sendable {
    func report(_ summary: MutationSummary) throws
}

And built four implementations:

TextReporter — human-readable terminal output. Score per file, list of surviving mutants with exact location, overall score.

JsonReporter — output in the mutation-testing-elements format (Stryker’s standard schema), compatible with the open-source HTML visualizer. Produces the file dictionary with source, mutants, status, and location.

HtmlReporter — wraps the JsonReporter internally (via temporary file), embeds the JSON in an HTML template that loads the mutation-testing-elements web component via CDN. A static .html file that opens in the browser and displays a full interactive dashboard.

SonarReporter — output in the SonarQube Generic Issue format. Only surviving mutants (survived -> MAJOR) and no-coverage mutants (noCoverage -> MINOR) are emitted as issues — killed mutants do not represent a problem. Compatible with sonar.externalIssuesReportPaths.

The reporters are independent and additive. A single run can produce text, JSON, HTML, and SonarQube output simultaneously — each activated by its respective CLI flag.


Conclusion

Building a mutation testing tool from scratch in Swift 6 was, above all, an exercise in architectural discipline. Every pipeline decision — value types, Sendable, stateless stages — was not aesthetic. It was the only way to make the compiler accept the code without compromising correctness.

The transition from sequential to parallel execution revealed something counterintuitive: the performance problem in mutation testing is not the serialization of executions, but the cost of per-mutant recompilation. Parallelism reduced time by 41%. But the real gain — orders of magnitude — will come from eliminating redundant recompilations, not from more parallelism.

The pipeline is correct, the executor is parallelized, the reporters cover the main use cases (terminal, HTML visualization, SonarQube). What comes next — cache, call graph filtering, schemata — are performance optimizations, not correctness fixes. That is exactly where you want to be when you start thinking about production.

The tool is still far from fast enough for large projects. But it is correct. And correctness, in mutation testing, is the only invariant that cannot be negotiated.


swift-mutation-testing is open source at github.com/ericodx/swift-mutation-testing.

Back to blog