Part II: Discovery Through Software Engineering and Vibe Coding
Chapter 18: AI-Assisted Testing and QA

18.2 Property-Based Testing and Mutation

"You gave me one example and called it a test. I gave you ten thousand examples and called it a proof sketch. Neither of us was entirely wrong."

A Hypothesis Strategy With Statistical Ambitions

Prerequisites

This section builds on the test generation strategies from Section 18.1, particularly the distinction between specification-based and code-based testing. You should also be familiar with the property-based testing concepts introduced in Section 9.3: Verification and Repair, where we first used Hypothesis to verify AI-generated code. Here we go deeper: building custom strategies, composing properties, and measuring test strength with mutation analysis.

The Big Picture

Example-based tests verify specific input-output pairs: "given [3, 1, 2], sorting produces [1, 2, 3]." Property-based tests verify universal claims: "for all lists, sorting produces a permutation that is non-decreasing." The shift from examples to properties is the shift from checking to proving, from individual data points to universal laws. This is precisely the same intellectual move that Chapter 2 identifies as the core of scientific discovery: moving from observations to general theories. Property-based testing turns your test suite into a miniature scientific enterprise, and mutation testing is its peer review.

1. The Oracle Problem

In 2019, a widely used genomics pipeline reportedly passed all 400 of its example-based tests while silently truncating floating-point precision in a normalization step, producing months of subtly wrong results before anyone noticed. The root cause was not missing tests; it was that every test checked a specific input against a hardcoded expected output, and none asked whether the function obeyed the mathematical properties it was supposed to preserve.

Your test framework just fired ten thousand random inputs at a function and never saw a crash, yet a silent arithmetic error has been corrupting every result, because no assertion knew what the correct output should be. This is the oracle problem: given a random input to a function, how do you determine the right answer? For a sorting function, you can check that the output is sorted and is a permutation of the input without knowing the exact output sequence. For a machine learning model's prediction, there is no simple oracle. The oracle problem explains why most automated test generators produce tests with weak assertions ("does not crash") rather than strong ones ("produces the correct output").

Properties are the solution to the oracle problem. Instead of specifying the exact output for each input, you specify relationships that must hold between the input and output, or between multiple calls to the function. These relationships are called behavioral invariants, and they fall into several families.

What Makes a Good Invariant

A behavioral invariant is a predicate over a function's inputs and outputs that must evaluate to true for every valid input, not just for a handful of chosen examples. Invariants transform testing from a sampling exercise (checking a few points) into a specification exercise (asserting universal laws the implementation must obey). A property-based testing framework like Hypothesis (a Python library for property-based testing that generates random inputs from declarative descriptions called strategies) draws hundreds or thousands of random inputs from a defined domain, runs each through the function, and checks whether the invariant holds. When any input violates the invariant, the framework shrinks the input (progressively simplifies the failing case to find the smallest counterexample) that still triggers the failure. Use behavioral invariants when you cannot write a complete oracle for every input (the common case in scientific and ML code). Fall back to example-based tests only when the expected output is both known and meaningful to assert directly, such as regression snapshots or golden-file comparisons. In short: You do not need to know the right answer to know the answer is wrong; you only need a property it must obey.

"""
The five families of behavioral invariants.
Each family provides a different angle on correctness
without requiring a complete oracle.
"""
from hypothesis import given, settings, assume
from hypothesis import strategies as st
import math


# Family 1: ROUNDTRIP (encode then decode returns the original)
# If f and g are inverses, then g(f(x)) == x for all valid x.

@given(st.floats(min_value=0.01, max_value=1e6, allow_nan=False))
def test_log_exp_roundtrip(x):
    """exp(log(x)) should return x for positive values."""
    assert math.isclose(math.exp(math.log(x)), x, rel_tol=1e-9)


# Family 2: IDEMPOTENCE (applying twice equals applying once)
# f(f(x)) == f(x)

@given(st.lists(st.integers(), min_size=1))
def test_sort_idempotent(xs):
    """Sorting an already-sorted list should produce the same list."""
    once = sorted(xs)
    twice = sorted(once)
    assert once == twice


# Family 3: INVARIANT PRESERVATION (some property holds before and after)
# P(x) implies P(f(x))

@given(st.lists(st.integers()))
def test_sort_preserves_length(xs):
    """Sorting should not change the number of elements."""
    assert len(sorted(xs)) == len(xs)


@given(st.lists(st.integers()))
def test_sort_preserves_elements(xs):
    """Sorting should not add or remove elements."""
    assert sorted(sorted(xs)) == sorted(xs)  # multiset equality


# Family 4: METAMORPHIC (relating outputs of related inputs)
# If input x becomes x', then output f(x) relates to f(x') in a known way.

@given(
    st.lists(st.integers(), min_size=1),
    st.integers()
)
def test_sort_metamorphic_append(xs, new_element):
    """Adding an element and re-sorting should insert it in order."""
    sorted_original = sorted(xs)
    sorted_with_new = sorted(xs + [new_element])
    # The new sorted list should contain all original elements plus the new one
    assert len(sorted_with_new) == len(sorted_original) + 1


# Family 5: ORACLE COMPARISON (comparing against a reference implementation)
# f_optimized(x) == f_reference(x) for all x

def naive_normalize(values):
    """Reference implementation: simple but obviously correct."""
    min_v, max_v = min(values), max(values)
    if max_v == min_v:
        return [0.5] * len(values)
    return [(v - min_v) / (max_v - min_v) for v in values]

def fast_normalize(values):
    """Optimized implementation under test."""
    import numpy as np
    arr = np.array(values, dtype=np.float64)
    mn, mx = arr.min(), arr.max()
    if mx == mn:
        return [0.5] * len(values)
    return ((arr - mn) / (mx - mn)).tolist()

@given(st.lists(
    st.floats(min_value=-1e6, max_value=1e6, allow_nan=False, allow_infinity=False),
    min_size=2
))
def test_normalize_oracle_comparison(values):
    """Optimized normalization matches naive reference."""
    naive = naive_normalize(values)
    fast = fast_normalize(values)
    for n, f in zip(naive, fast):
        assert math.isclose(n, f, rel_tol=1e-9, abs_tol=1e-12)
The five families of behavioral invariants: roundtrip, idempotence, invariant preservation, metamorphic relations, and oracle comparison. Each property verifies correctness without requiring exact expected outputs.
Key Insight: Properties Are Partial Specifications That Scale

A single property like "sorting preserves length" does not fully specify sorting. But the conjunction of several properties (preserves length, preserves elements, produces non-decreasing output, is idempotent) progressively constrains the function until very few implementations can satisfy all of them. Each property you add eliminates a class of incorrect implementations. This is exactly the specification-gap closing strategy from Section 9.1, but automated and exhaustive.

Exercise 18.2.1

Consider a function clamp(x, lo, hi) that returns lo if x < lo, hi if x > hi, and x otherwise. Write down at least three behavioral invariants from different families (roundtrip, idempotence, invariant preservation, or metamorphic) that together would tightly specify clamp without ever stating the exact expected output for a particular input.

Hint

Idempotence: clamp(clamp(x, lo, hi), lo, hi) == clamp(x, lo, hi). Invariant preservation: the output is always between lo and hi inclusive. Metamorphic: if you widen the bounds (decrease lo or increase hi), the output either stays the same or moves closer to x. Can you find a fourth property from the roundtrip family?

Once you know which families of properties to test, the next question is how to generate the inputs that will exercise them, especially when those inputs must obey domain-specific constraints like sorted wavelengths or physically valid temperature ranges.

2. Building Hypothesis Strategies for Scientific Data

Hypothesis generates test inputs from strategies: composable descriptions of the input space. Built-in strategies cover primitives (integers, floats, strings, booleans), containers (lists, dicts, tuples), and compositions (mapped, filtered, one-of). For scientific applications, you typically need custom strategies that generate domain-specific data: valid chemical formulas, well-formed molecular graphs, plausible time series, or physically meaningful parameter combinations.

"""
Custom Hypothesis strategies for scientific data types.
"""
from hypothesis import strategies as st
from hypothesis.strategies import composite
from dataclasses import dataclass


@dataclass
class Spectrum:
    """A spectral measurement with wavelengths and intensities."""
    wavelengths: list[float]  # nm, sorted ascending
    intensities: list[float]  # arbitrary units, same length


@composite
def spectra(draw, min_points=2, max_points=200):
    """Generate valid Spectrum objects with sorted wavelengths."""
    n = draw(st.integers(min_value=min_points, max_value=max_points))

    # Wavelengths: sorted, positive, in visible/near-IR range
    wavelengths = sorted(draw(st.lists(
        st.floats(min_value=200.0, max_value=2500.0,
                  allow_nan=False, allow_infinity=False),
        min_size=n, max_size=n
    )))

    # Intensities: non-negative (photon counts)
    intensities = draw(st.lists(
        st.floats(min_value=0.0, max_value=1e6,
                  allow_nan=False, allow_infinity=False),
        min_size=n, max_size=n
    ))

    return Spectrum(wavelengths=wavelengths, intensities=intensities)


@composite
def experiment_configs(draw):
    """Generate plausible experiment configuration dicts."""
    return {
        "temperature": draw(st.floats(
            min_value=4.0, max_value=1500.0  # Kelvin
        )),
        "pressure": draw(st.floats(
            min_value=0.001, max_value=100.0  # atmospheres
        )),
        "concentration": draw(st.floats(
            min_value=1e-9, max_value=1.0  # molar
        )),
        "duration": draw(st.floats(
            min_value=0.1, max_value=86400.0  # seconds (up to 1 day)
        )),
        "replicates": draw(st.integers(
            min_value=1, max_value=96  # standard microplate
        )),
    }


# Using the custom strategies in property tests
@given(spectrum=spectra())
def test_spectrum_wavelengths_sorted(spectrum):
    """Generated spectra should always have sorted wavelengths."""
    for i in range(1, len(spectrum.wavelengths)):
        assert spectrum.wavelengths[i] >= spectrum.wavelengths[i - 1]


@given(spectrum=spectra(), config=experiment_configs())
def test_normalize_preserves_spectrum_length(spectrum, config):
    """Normalization should not change the number of data points."""
    # Assume non-trivial spectrum (not all zeros)
    assume(max(spectrum.intensities) > 0)
    normalized = naive_normalize(spectrum.intensities)
    assert len(normalized) == len(spectrum.intensities)
Custom Hypothesis strategies for scientific data: spectra with sorted wavelengths and experiment configurations with physically plausible parameter ranges.
Practical Example: Finding a Floating-Point Bug in Peak Detection

A bioinformatics team used the spectra() strategy above to test their peak detection algorithm. Within 50 iterations, Hypothesis found a spectrum where two adjacent wavelength values were equal (e.g., 532.0 and 532.0), causing the derivative calculation to divide by zero. The team had tested with hundreds of real spectra and never seen duplicate wavelengths because their instrument produced distinct values at each pixel. Hypothesis found the edge case by generating from the full float space, not just the observed data space. The fix took one line (assume(len(set(spectrum.wavelengths)) == len(spectrum.wavelengths)) in the strategy, plus a guard clause in the function), but without property-based testing, this bug would have surfaced only when a user fed in interpolated data with repeated grid points.

Property-based testing strengthens the inputs your tests explore, but it cannot tell you whether your assertions are strong enough to catch the faults that matter; for that, you need a way to measure the detection power of the test suite itself.

3. Mutation Testing: Measuring Test Strength

Coverage tells you which code your tests execute. Mutation testing tells you whether your tests would notice if that code were wrong. The process works as follows: make a small change (a mutant) to the source code, then run your test suite. If at least one test fails, the mutant is killed (your tests detected the fault). If all tests pass, the mutant survived (your tests are blind to that class of fault). Figure 18.2 illustrates this feedback loop.

Figure 18.2: Mutation Testing Feedback Loop Source Code Mutation Engine Mutants Test Suite Classify Results Killed Survived Equivalent Write New Tests per mutant tests caught it no behavior change
Figure 18.2. The mutation testing feedback loop. The mutation engine applies operators to the source code, producing mutants. The test suite runs against each mutant and classifies the result as killed (a test failed), survived (all tests passed), or equivalent (the mutant does not change observable behavior). Surviving mutants feed back into the process: the developer writes new property tests targeting the uncovered behavior, then re-runs the cycle.

Before we quantify mutation results, one more category of mutant needs definition. An equivalent mutant is one that changes the source code without changing the program's behavior (for example, replacing x + 0 with x). Equivalent mutants are the bane of mutation testing because they inflate the denominator without representing real faults. In practice, most mutation testing tools accept a small rate of equivalent mutants as noise.

The mutation score is the fraction of non-equivalent mutants killed:

$$M = \frac{|\{\text{killed mutants}\}|}{|\{\text{total mutants}\} - \{\text{equivalent mutants}\}|}$$

Common Misconception

A common misconception is that "if all my tests pass, the code is correct." Passing tests only confirm that the code behaves as expected for the specific assertions you wrote; mutation testing reveals that a test suite can execute every line of code and still fail to detect real faults because the assertions are too weak or too generic. A surviving mutant is proof that a specific class of bug could hide in your code unnoticed by the entire test suite, so passing tests are a necessary but not sufficient condition for correctness.

3.1 Mutation Operators

A mutation operator defines a class of source-code transformations. Common operators include:

The theoretical foundation is the competent programmer hypothesis: real bugs are small deviations from the correct program. If your tests catch all small deviations (mutants), they will typically also catch most real bugs. The related coupling effect (the empirical observation that tests detecting simple faults also tend to detect more complex faults composed of those simple faults) reinforces this. DeMillo, Lipton, and Sayward (1978) established these hypotheses, and Jia and Harman (2011) validated them empirically, justifying mutation testing as a proxy for real-world fault detection.

Checkpoint

So far: mutation testing injects small faults (mutants) into source code, runs the test suite against each, and classifies the result as killed, survived, or equivalent; the mutation score measures what fraction of real faults the suite detects, and six standard mutation operators define the kinds of faults injected.

Step-Through: Mutation Testing on a Two-Line Function

Trace through mutation analysis on a tiny function: def abs_diff(a, b): return abs(a - b), tested by assert abs_diff(5, 3) == 2.

Mutant 1: replace - with +, giving abs(a + b). Test: abs(5 + 3) = 8 != 2. Test fails. Mutant killed.

Mutant 2: remove abs(), giving a - b. Test: 5 - 3 = 2 == 2. Test passes. Mutant survives. The test does not cover a case where a < b (e.g., abs_diff(3, 5) should return 2, but the mutant returns -2).

Mutant 3: replace return with return None. Test: None != 2. Test fails. Mutant killed.

Score: 2 killed out of 3 total = 66.7%. Adding assert abs_diff(3, 5) == 2 kills Mutant 2, raising the score to 100%.

"""
Running mutation testing with mutmut on a Python module.
"""
# First, install mutmut: pip install mutmut

# The function under test (spectrum.py):
def find_peaks(
    wavelengths: list[float],
    intensities: list[float],
    threshold: float = 0.1,
    min_distance: int = 5
) -> list[int]:
    """Find peaks in a spectrum above a relative threshold.

    Args:
        wavelengths: Wavelength values (sorted ascending).
        intensities: Intensity values.
        threshold: Minimum relative height (0 to 1) above baseline.
        min_distance: Minimum number of points between peaks.

    Returns:
        Indices of detected peaks.
    """
    if len(intensities) < 3:
        return []

    baseline = min(intensities)
    max_intensity = max(intensities)
    abs_threshold = baseline + threshold * (max_intensity - baseline)

    peaks = []
    for i in range(1, len(intensities) - 1):
        # A peak is a local maximum above the threshold
        if (intensities[i] > intensities[i - 1] and
                intensities[i] > intensities[i + 1] and
                intensities[i] >= abs_threshold):
            # Enforce minimum distance from the last peak
            if not peaks or (i - peaks[-1]) >= min_distance:
                peaks.append(i)

    return peaks
A peak detection function with multiple decision points: length guard, threshold comparison, local maximum check, and minimum distance enforcement. Each decision is a target for mutation operators.

With the function under test defined, we can use mutmut (a Python mutation testing tool that automatically applies mutation operators to source code and re-runs the test suite against each mutant) to measure how many of those decision points our tests actually guard.

# Run mutmut on the spectrum module
mutmut run --paths-to-mutate=spectrum.py --tests-dir=tests/

# View results summary
mutmut results

# Example output:
# Legend for output:
# -Ass = survived (test suite did not catch the mutant)
# - Killed = killed (test suite caught the mutant)
# - Timeout = timeout (mutant caused an infinite loop)
#
# Survived:   3
# Killed:    24
# Timeout:    1
# Total:     28
#
# Mutation score: 24/27 = 88.9% (excluding timeouts)

# Inspect surviving mutants
mutmut show 5
# --- spectrum.py
# +++ spectrum.py (mutant 5)
# @@ -15,7 +15,7 @@
# -        if (intensities[i] > intensities[i - 1] and
# +        if (intensities[i] >= intensities[i - 1] and
#
# This mutant changed > to >= in the local maximum check.
# It survives because no test has a plateau (equal adjacent values)
# where the distinction between > and >= matters.
Running mutmut and inspecting surviving mutants. Mutant 5 reveals that no test exercises the case where adjacent values are equal, exposing a gap in the test suite.

Each surviving mutant points to a gap in the test suite. The mutant above (changing > to >= in the local maximum check) survives because no test includes a spectrum with a plateau. This is precisely the kind of edge case that property-based testing excels at finding: flat regions, equal adjacent values, monotonic sequences.

3.2 Killing Surviving Mutants with Property Tests

Mutation testing and property-based testing reinforce each other: mutation testing identifies where the suite is weak, and property-based testing generates inputs that exercise those weak spots. The workflow is: run mutmut, examine surviving mutants, write property tests targeting the uncovered behavior, and re-run to confirm the kills.

"""
Killing surviving mutants with targeted property tests.
"""
from hypothesis import given, assume
from hypothesis import strategies as st


# Mutant 5 survived: > changed to >= in local max check.
# This means no test has equal adjacent values where a "peak"
# would be incorrectly detected. Write a property test for plateaus.

@given(st.lists(
    st.floats(min_value=0.0, max_value=100.0,
              allow_nan=False, allow_infinity=False),
    min_size=5, max_size=50
))
def test_plateau_not_detected_as_peak(intensities):
    """A flat region (constant value) should never contain a peak.

    A peak requires being strictly greater than both neighbors.
    If we make a subarray constant, no index in that subarray
    should appear in the peak list.
    """
    # Create a spectrum with a flat plateau in the middle
    n = len(intensities)
    mid = n // 2
    plateau_value = intensities[mid]
    plateau_start = max(1, mid - 2)
    plateau_end = min(n - 1, mid + 3)

    # Set plateau region to a constant value
    for i in range(plateau_start, plateau_end):
        intensities[i] = plateau_value

    wavelengths = list(range(n))  # dummy wavelengths
    peaks = find_peaks(wavelengths, intensities, threshold=0.0)

    # No peak should be in the plateau interior
    for p in peaks:
        if plateau_start < p < plateau_end - 1:
            # This is interior: neighbors on both sides have the same value
            assert intensities[p] > intensities[p - 1] or \
                   intensities[p] > intensities[p + 1], \
                f"Peak at index {p} is on a plateau (value={intensities[p]})"


# Mutant 12 survived: min_distance changed from >= to >
# This means no test has peaks exactly min_distance apart.

@given(
    st.integers(min_value=3, max_value=10),
    st.floats(min_value=1.0, max_value=100.0,
              allow_nan=False, allow_infinity=False)
)
def test_peaks_at_exact_min_distance(min_dist, peak_height):
    """Two peaks exactly min_distance apart should both be detected."""
    # Construct a spectrum with two peaks at exactly min_distance spacing
    n = min_dist * 3 + 2
    intensities = [0.0] * n
    peak1_idx = min_dist
    peak2_idx = peak1_idx + min_dist  # exactly min_distance apart

    intensities[peak1_idx] = peak_height
    intensities[peak2_idx] = peak_height

    wavelengths = list(range(n))
    peaks = find_peaks(
        wavelengths, intensities,
        threshold=0.0, min_distance=min_dist
    )

    assert peak1_idx in peaks, f"First peak at {peak1_idx} not detected"
    assert peak2_idx in peaks, f"Second peak at {peak2_idx} not detected"
Targeted property tests that kill surviving mutants. Each test is designed to distinguish the correct operator from the mutated one by exercising the exact boundary condition.
Key Insight: Mutation Score Measures Assertion Strength, Not Coverage

A test suite can have 100% branch coverage and a 60% mutation score. That means 40% of the injected faults go undetected: the tests reach the right code but do not check the right properties. Mutation score is a strictly stronger quality metric than coverage because it requires not just execution but detection. For scientific software where an undetected arithmetic error could invalidate published results, mutation score is in practice the metric that matters. A common guideline is to aim for at least 80% mutation score on critical modules; scores below 70% typically indicate that the test suite is fundamentally incomplete.

Real-World Application: Google Maps Route Planning
Real-World Application: Google Maps Route Planning

The Undying Mutant That Proved a Theorem

In 1978, when DeMillo, Lipton, and Sayward first proposed mutation testing, they expected equivalent mutants (those that change syntax but not behavior) to be rare annoyances. Decades later, researchers found that in many studies roughly 5% to 15% of all generated mutants are equivalent, and proving equivalence is, in the general case, undecidable (reducible to the halting problem). In one famous study on the Triangle classification program (a 20-line function used as a testing benchmark since the 1970s), a single mutant that swapped two symmetrically used variables survived every test suite thrown at it for years, until someone formally proved it was equivalent. That tiny survivor helped motivate an entire subfield of compiler-based equivalence detection.

4. Combining Property Tests and Mutation Testing in Continuous Integration (CI)

In a CI pipeline, property tests and mutation tests serve complementary roles. Property tests run on every commit (they are fast, typically under a minute for 100 properties with 100 examples each). Mutation tests run nightly or on pull requests (they are slow, requiring one full test suite run per mutant). The combination provides both rapid feedback (property tests catch regressions immediately) and deep analysis (mutation tests verify that the test suite itself remains strong).

"""
CI configuration for combined property and mutation testing.
This pytest conftest.py configures Hypothesis for CI environments.
"""
from hypothesis import settings, Phase, HealthCheck

# CI profile: more examples, deterministic, no shrinking timeout
settings.register_profile(
    "ci",
    max_examples=500,          # more thorough than dev (default 100)
    derandomize=True,          # reproducible failures
    suppress_health_check=[
        HealthCheck.too_slow,  # allow slow strategies in CI
    ],
    phases=[
        Phase.explicit,        # run explicit examples first
        Phase.reuse,           # replay previously failing examples
        Phase.generate,        # generate new examples
        Phase.shrink,          # shrink failing examples to minimal form
    ],
    deadline=None,             # no per-example time limit
)

# Dev profile: fast feedback
settings.register_profile(
    "dev",
    max_examples=20,           # fast iteration
    derandomize=False,         # explore new inputs each run
    deadline=2000,             # 2-second timeout per example
)

# Load profile from environment variable:
#   HYPOTHESIS_PROFILE=ci pytest tests/
settings.load_profile("dev")  # default to dev
Hypothesis profiles for development (fast, exploratory) and CI (thorough, deterministic). The CI profile runs 5x more examples and disables per-example timeouts.
# .github/workflows/test.yml (excerpt)
# Property tests on every push; mutation tests on PRs only.

jobs:
  property-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -r requirements-test.txt
      - run: HYPOTHESIS_PROFILE=ci pytest tests/ -x --tb=short

  mutation-tests:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -r requirements-test.txt mutmut
      - run: |
          mutmut run \
            --paths-to-mutate=src/ \
            --tests-dir=tests/ \
            --runner="pytest tests/ -x --tb=no -q"
      - run: |
          # Fail the PR if mutation score drops below 80%
          python -c "
          import json
          with open('.mutmut-cache/results.json') as f:
              r = json.load(f)
          killed = r.get('killed', 0)
          total = r.get('total', 1) - r.get('equivalent', 0)
          score = killed / total * 100 if total > 0 else 100
          print(f'Mutation score: {score:.1f}%')
          assert score >= 80, f'Mutation score {score:.1f}% below 80% threshold'
          "
GitHub Actions workflow running property tests on every push and mutation tests on pull requests, with an 80% mutation score gate.
Library Shortcut: mutmut in Three Commands

The entire mutation testing workflow reduces to three commands: pip install mutmut (install), mutmut run --paths-to-mutate=src/ (analyze), and mutmut results (report). For a 500-line module with 50 tests, mutmut typically generates around 150 mutants and completes in under 5 minutes on a modern laptop. Compare this to building a mutation testing framework from scratch (which would require abstract syntax tree (AST) manipulation, process isolation, result aggregation, and caching), roughly 2,000 lines of code. mutmut handles all of this, plus incremental caching: re-running after adding one test only re-tests the surviving mutants.

5. The Metamorphic Testing Pattern for Machine Learning (ML) Systems

ML models present a particularly severe oracle problem: for a given input, no formula can tell you the "correct" prediction. Metamorphic testing (introduced as Family 4 in the invariant taxonomy above) sidesteps this by testing relationships between predictions rather than individual predictions. If rotating an image by 5 degrees changes a classifier's prediction from "cat" to "dog," that is almost certainly a bug, even though we do not know the correct label. This section develops the metamorphic pattern in depth, showing how to choose relations, compose them with Hypothesis, and interpret failures, because metamorphic testing is the primary technique for validating the ML-intensive systems we build in Part III and the evaluation frameworks in Chapter 56.

"""
Metamorphic testing for a scientific classifier.
Tests relationships between predictions, not individual predictions.
"""
from hypothesis import given, assume
from hypothesis import strategies as st
import numpy as np


def classify_material(spectrum: np.ndarray) -> str:
    """Classify a material from its infrared (IR) absorption spectrum.
    (Placeholder for an actual ML model.)
    """
    # In production, this calls model.predict()
    peak_position = np.argmax(spectrum)
    if peak_position < len(spectrum) * 0.3:
        return "polymer"
    elif peak_position < len(spectrum) * 0.7:
        return "ceramic"
    else:
        return "metal"


# Metamorphic relation 1: Scaling invariance
# Multiplying all intensities by a positive constant should not
# change the classification (the material is the same, just measured
# with a different detector gain).

@given(
    st.lists(
        st.floats(min_value=0.1, max_value=1000.0,
                  allow_nan=False, allow_infinity=False),
        min_size=20, max_size=200
    ),
    st.floats(min_value=0.01, max_value=100.0,
              allow_nan=False, allow_infinity=False)
)
def test_classification_scale_invariant(intensities, scale_factor):
    """Scaling intensities should not change the classification."""
    original = np.array(intensities)
    scaled = original * scale_factor

    assert classify_material(original) == classify_material(scaled), \
        f"Classification changed when scaling by {scale_factor}"


# Metamorphic relation 2: Noise robustness
# Adding small Gaussian noise should not change the classification
# (real measurements always have some noise).

@given(
    st.lists(
        st.floats(min_value=1.0, max_value=1000.0,
                  allow_nan=False, allow_infinity=False),
        min_size=20, max_size=200
    ),
    st.integers(min_value=0, max_value=2**31 - 1)  # random seed
)
def test_classification_noise_robust(intensities, seed):
    """Small noise should not change the classification."""
    rng = np.random.default_rng(seed)
    original = np.array(intensities)
    snr = 100.0  # signal-to-noise ratio
    noise = rng.normal(0, original.max() / snr, size=len(original))
    noisy = original + noise

    assert classify_material(original) == classify_material(noisy), \
        f"Classification changed with SNR={snr}"
Metamorphic testing for an ML classifier: verifying that scaling invariance and noise robustness hold without knowing the correct label for any individual input.

Mental Model

Think of metamorphic testing like checking a kitchen scale by comparison rather than by knowing the true weight. You do not need a calibrated reference weight (an oracle). Instead, you place a bag of flour on the scale, read the number, then place a second identical bag on top. If the reading does not double, the scale is broken. You discovered the fault by reasoning about the relationship between two measurements (doubling the load should double the reading), not by knowing the exact weight of the flour. Each metamorphic relation is a different "double the bag" trick: scaling, rotation, adding noise, reversing order. If the system's output violates the expected relationship, you have found a bug, even though you never knew the "correct" answer for any single input.

Real-World Application: Google Maps Route Planning

Google's routing team has reportedly used metamorphic testing to validate their directions engine, where no oracle exists for the "correct" route between two points. One key metamorphic relation: adding a waypoint that lies on the original optimal route should not increase the total travel time. Another: swapping origin and destination on a symmetric road network should yield the same distance (within tolerance for one-way streets). These relations have caught bugs in toll-road cost integration and time-zone boundary handling that conventional regression tests missed entirely.

Research Frontier: Large Language Model (LLM)-Generated Metamorphic Relations

Deng et al. (2023) demonstrated that LLMs can generate effective fuzz tests for complex APIs by reasoning about input constraints and expected behaviors. Building on this, Rao et al. (2024, "MorphAgent: Automated Metamorphic Relation Generation via LLM Agents," ASE 2024) introduced an agentic pipeline that takes a function signature and its docstring, uses chain-of-thought reasoning to hypothesize candidate metamorphic relations, then validates each candidate by running it against the implementation and filtering out relations that produce false positives. On a benchmark of 15 numerical and ML libraries, MorphAgent generated metamorphic relations that killed 23% more mutants than human-written relations, with a false-positive rate under 4%. For example, given a protein function predictor, the agent inferred that reordering atoms within each residue should not change the prediction (the chemistry is identical) but reversing the full sequence should (the protein is structurally different). This automation of metamorphic relation discovery feeds directly into the invariant discovery system we build in Section 18.3.

Each technique covered so far (property tests, mutation analysis, metamorphic relations) captures a different dimension of test quality, raising the question of how to unify these signals into a single actionable metric.

6. From Test Metrics to Test Confidence

Three complementary metrics capture test quality: coverage (what code executes), mutation score (what faults are detected), and property count (how many invariants are verified). None alone is sufficient. A useful composite metric is the mutation-adjusted confidence score:

$$\text{Confidence} = C_{\text{branch}} \times M \times \min\left(1,\; \frac{P}{P_{\text{target}}}\right)$$

where \(C_{\text{branch}}\) is branch coverage (0 to 1), \(M\) is mutation score (0 to 1), and \(P / P_{\text{target}}\) is the ratio of property tests to a target count (capped at 1). A module with 95% branch coverage, 90% mutation score, and 8 out of 10 target properties gets a confidence of \(0.95 \times 0.90 \times 0.80 = 0.684\). This composite score makes the tradeoffs visible: high coverage with low mutation score (weak assertions) pulls the score down just as much as low coverage with strong assertions.

Section 18.3 builds this metric into an automated invariant discovery system that targets all three components simultaneously.

Try It: Property-Test and Mutate a Statistics Module

Build a small statistics module, write property tests for it, then use mutation testing to find gaps in your test suite. You need only Python, Hypothesis, and mutmut (all installable via pip).

1. Create a file stats.py containing three functions: mean(xs), median(xs), and stdev(xs), each operating on a non-empty list of floats. Implement them without using any library (pure arithmetic).

2. Install Hypothesis and write at least four property tests in test_stats.py: (a) the mean of a list where every element is the same constant c equals c (idempotence family), (b) adding a constant k to every element shifts the mean by exactly k (metamorphic family), (c) stdev of a constant list is zero, and (d) median always returns a value between min(xs) and max(xs) (invariant preservation family). Run pytest test_stats.py and confirm all tests pass.

3. Install mutmut (pip install mutmut) and run mutmut run --paths-to-mutate=stats.py --tests-dir=.. Record the mutation score.

4. Run mutmut results and pick two surviving mutants. For each, use mutmut show <id> to see the diff, then write a new property test that distinguishes the correct operator from the mutated one.

5. Re-run mutmut and verify that your new tests kill the previously surviving mutants. Aim for a mutation score above 85%.

Lab: Mutation Score vs. Property Count

Goal: Measure how mutation score improves as you add property tests one at a time, and identify the point of diminishing returns.

Tools needed: Python 3.10+, Hypothesis (pip install hypothesis), mutmut (pip install mutmut), and matplotlib for plotting.

Setup: Implement a 30 to 50 line numeric utility module with functions for clamp, lerp (linear interpolation), and normalize (min-max scaling). Start with zero property tests.

Procedure: Run mutmut run and record the baseline mutation score (with no tests, all mutants survive). Then add one property test at a time (e.g., clamp idempotence, lerp boundary at t=0, normalize output range [0,1]), re-running mutmut after each addition. Record the mutation score after each test. Continue until you have at least eight property tests.

What to vary: Try properties from different families (roundtrip, metamorphic, invariant preservation). Note which family of property kills the most mutants per test added.

What to observe: Plot mutation score (y-axis) against cumulative property count (x-axis). You should see a steep initial rise followed by a plateau. Identify which surviving mutants remain after eight tests, and determine whether they are equivalent mutants or genuine gaps.

Exercises

Exercise 18.2.1 (Conceptual): Explain the difference between a killed mutant, a surviving mutant, and an equivalent mutant. Give an example of each for a function that computes the arithmetic mean of a list of numbers. Why do equivalent mutants make mutation scores misleading if not accounted for?

Exercise 18.2.2 (Coding): Write a Hypothesis strategy that generates valid molecular formulas as strings (e.g., "C6H12O6", "NaCl", "H2SO4"). Use @composite to ensure the formula follows standard chemical notation (element symbol followed by optional count). Then write three property tests for a molecular weight calculator using your strategy.

Exercise 18.2.3 (Analysis): Run mutmut on a Python module of your choice (at least 100 lines of code). Report the total mutants, killed, survived, and timeout counts. For each surviving mutant, classify it as either (a) a genuine test gap, (b) an equivalent mutant, or (c) a mutant in unreachable code. Write tests to kill the mutants in category (a) and re-run to verify the mutation score improves.