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

18.1 Test Generation Strategies

"Give me a function signature and a docstring, and I will give you forty test cases. Thirty-seven of them will compile. Thirty-four will be meaningful. And at least one will find a bug you did not know you had."

A Large Language Model Moonlighting as a QA Engineer

Prerequisites

This section opens Chapter 18. You should have completed Chapter 9: Vibe Coding, which introduced executable contracts and the verify-or-repair loop, and Chapter 16: AI-Assisted Implementation, which covered code generation at repository scale. Familiarity with pytest fixtures, parametrization, and basic assertion patterns is assumed.

The Big Picture

Test generation is a search problem. The search space is all possible test inputs; the objective is to find inputs that reveal bugs. Traditional test generation explores this space mechanically (random fuzzing, symbolic execution, where a solver explores code paths by treating inputs as algebraic symbols rather than concrete values). AI-assisted test generation adds a powerful heuristic: a language model that has seen millions of test files can predict which inputs are likely to matter based on the structure and semantics of the code under test. This section shows how to combine both approaches, using large language models (LLMs) for intelligent test synthesis and coverage metrics to measure what the resulting tests actually verify.

1. The Three Sources of Test Cases

A 200-test suite passes every night for months, then a production pipeline crashes on real data because no one thought to test a flat input signal where every value is identical. Where should that missing test have come from: the specification, the source code, or the failure itself? Each source produces tests with different strengths. Specification-based tests verify intent. Code-based tests achieve coverage. Failure-based tests prevent regressions. A comprehensive test suite draws from all three.

1.1 Specification-Based Test Generation

The simplest form of AI test generation starts with a function's contract: its name, type signature, docstring, and any formal preconditions or postconditions. The LLM reads the contract and generates test cases that exercise the specified behavior, including boundary conditions, error cases, and typical usage patterns. This approach mirrors how a human tester reads a requirements document and writes acceptance tests, but operates at the speed of token generation.

A function's specification encompasses every promise it makes to callers: input types and constraints (preconditions), output guarantees (postconditions), and declared exceptions. Specification-based test generation anchors tests to intended behavior rather than implementation details, so the tests survive refactoring. The LLM parses the signature, docstring, and type annotations, then samples inputs that exercise each documented behavior, boundary, and error path. Prefer specification-based generation whenever a function has a clear contract; switch to code-based generation (Section 1.2) when the specification is incomplete or you need to target uncovered branches. In short: tell the model what the function promises, and it will find the promises you forgot to keep.

"""
Specification-based test generation using an LLM.
We provide the function signature and docstring, then ask
the model to generate pytest test cases.
"""
import json
from anthropic import Anthropic

def generate_tests_from_spec(
    function_source: str,
    model: str = "claude-sonnet-4-20250514"
) -> str:
    """Generate pytest test cases from a function's specification.

    Args:
        function_source: The complete source code of the function,
            including its docstring and type annotations.
        model: The Claude model to use for generation.

    Returns:
        A string containing valid pytest test code.
    """
    client = Anthropic()

    prompt = f"""Analyze this Python function and generate a comprehensive
pytest test suite. Include:
1. Happy-path tests for typical inputs
2. Boundary tests (empty inputs, single elements, maximum sizes)
3. Error-handling tests (invalid types, out-of-range values)
4. Edge cases suggested by the function's logic

Return ONLY valid Python code, starting with imports.

Function:
```python
{function_source}
```"""

    response = client.messages.create(
        model=model,
        max_tokens=4096,
        messages=[{"role": "user", "content": prompt}]
    )
    return response.content[0].text


# Example: generate tests for a scientific data normalization function
target_function = '''
def normalize_spectrum(
    wavelengths: list[float],
    intensities: list[float],
    method: str = "min-max"
) -> list[float]:
    """Normalize spectral intensity values to [0, 1] range.

    Args:
        wavelengths: Wavelength values in nanometers (must be sorted).
        intensities: Raw intensity values (same length as wavelengths).
        method: Normalization method, one of "min-max", "z-score", "peak".

    Returns:
        Normalized intensity values.

    Raises:
        ValueError: If inputs have different lengths or are empty.
        ValueError: If method is not recognized.
    """
'''

generated_tests = generate_tests_from_spec(target_function)
print(generated_tests)
Specification-based test generation: the LLM reads the function contract and generates a pytest suite covering happy paths, boundaries, and error cases.

The generated tests typically include cases the developer might overlook: what happens when all intensities are identical (min equals max in min-max normalization), when wavelengths are not sorted despite the docstring requiring it, or when the intensity list contains negative values. The LLM appears to infer these edge cases from its training on large corpora of similar functions, applying what resembles analogical reasoning that Chapter 4: Reasoning for Discovery would recognize as case-based reasoning.

Key Insight: Specification Tests Verify Intent, Not Implementation

Tests generated from specifications are black-box tests: they verify what the function should do without depending on how it does it internally. This makes them robust to refactoring. If you rewrite normalize_spectrum to use NumPy vectorized operations instead of a Python loop, specification-based tests continue to pass (or fail) for the right reasons. Code-based tests, by contrast, may break when the implementation changes even if the behavior is preserved. A good test suite needs both, but specification tests should form the foundation.

1.2 Code-Based Test Generation

Code-based generation analyzes the implementation itself: its branches, loops, exception handlers, and data transformations. The goal is to achieve high structural coverage by generating inputs that exercise each execution path. Where specification-based generation asks "what should this function do?", code-based generation asks "what does this function actually do, and have I tested all the paths through it?"

"""
Code-based test generation: analyze branches and generate
inputs that exercise each path.
"""
import ast
import textwrap

def extract_branches(source: str) -> list[dict]:
    """Extract conditional branches from Python source code.

    Returns a list of branch descriptors, each containing the
    condition expression and the line number.
    """
    tree = ast.parse(source)
    branches = []

    for node in ast.walk(tree):
        if isinstance(node, ast.If):
            # Extract the condition as source text
            condition = ast.unparse(node.test)
            branches.append({
                "type": "if",
                "condition": condition,
                "line": node.lineno,
                "has_else": len(node.orelse) > 0
            })
        elif isinstance(node, ast.For):
            branches.append({
                "type": "for",
                "iterable": ast.unparse(node.iter),
                "line": node.lineno
            })
        elif isinstance(node, ast.ExceptHandler):
            exc_type = ast.unparse(node.type) if node.type else "Exception"
            branches.append({
                "type": "except",
                "exception": exc_type,
                "line": node.lineno
            })

    return branches


def generate_branch_covering_tests(
    function_source: str,
    branches: list[dict],
    model: str = "claude-sonnet-4-20250514"
) -> str:
    """Generate tests that target specific uncovered branches."""
    client = Anthropic()

    branch_descriptions = "\n".join(
        f"  - Line {b['line']}: {b['type']} "
        f"({b.get('condition', b.get('exception', b.get('iterable', '')))})"
        for b in branches
    )

    prompt = f"""Given this Python function and its branch structure,
generate pytest tests that exercise EACH branch. For each if/else,
generate at least one test for the true branch and one for the false branch.
For each exception handler, generate a test that triggers it.

Function:
```python
{function_source}
```

Branches to cover:
{branch_descriptions}

Generate one clearly-named test function per branch. Include comments
stating which branch each test targets."""

    response = client.messages.create(
        model=model,
        max_tokens=4096,
        messages=[{"role": "user", "content": prompt}]
    )
    return response.content[0].text


# Example usage
source = textwrap.dedent('''
    def parse_measurement(raw: str) -> tuple[float, str]:
        """Parse a measurement string like '3.14 kg' into (value, unit)."""
        raw = raw.strip()
        if not raw:
            raise ValueError("Empty measurement string")

        parts = raw.split()
        if len(parts) == 1:
            # No unit specified, assume dimensionless
            try:
                return (float(parts[0]), "")
            except ValueError:
                raise ValueError(f"Cannot parse: {raw}")
        elif len(parts) == 2:
            try:
                value = float(parts[0])
            except ValueError:
                raise ValueError(f"Invalid number: {parts[0]}")
            return (value, parts[1].lower())
        else:
            raise ValueError(f"Too many parts in: {raw}")
''')

branches = extract_branches(source)
print(f"Found {len(branches)} branches to cover")
# Found 5 branches to cover
Code-based test generation: extracting branch structure with the ast module, then prompting an LLM to generate tests targeting each conditional path and exception handler.

1.3 Failure-Based Test Generation

The third source of test cases is observed failures: bug reports, stack traces, error logs, and user complaints. When a bug is reported, the first step in a disciplined workflow is to write a test that reproduces it. This test should fail on the current code and pass after the fix, serving as both a verification and a regression guard. AI accelerates this process by reading a stack trace and generating a minimal reproducing test case.

"""
Failure-based test generation: convert a stack trace into a
minimal reproducing test case.
"""

def generate_regression_test(
    stack_trace: str,
    source_files: dict[str, str],
    model: str = "claude-sonnet-4-20250514"
) -> str:
    """Generate a pytest regression test from a stack trace.

    Args:
        stack_trace: The full Python stack trace from the failure.
        source_files: Dict mapping filenames to their source code
            for the files mentioned in the stack trace.

    Returns:
        A pytest test function that reproduces the failure.
    """
    client = Anthropic()

    files_context = "\n\n".join(
        f"# {name}\n```python\n{code}\n```"
        for name, code in source_files.items()
    )

    prompt = f"""A user reported this error. Generate a minimal pytest
test that reproduces the failure. The test should:
1. Set up the minimum state needed to trigger the bug
2. Call the failing function with the inputs that cause the crash
3. Use pytest.raises() to assert the specific exception
4. Include a comment explaining the root cause

Stack trace:
```
{stack_trace}
```

Relevant source files:
{files_context}

Generate ONLY the test function, with imports."""

    response = client.messages.create(
        model=model,
        max_tokens=2048,
        messages=[{"role": "user", "content": prompt}]
    )
    return response.content[0].text


# Example: a ZeroDivisionError in normalization
trace = """Traceback (most recent call last):
  File "pipeline.py", line 42, in process_batch
    normalized = normalize_spectrum(wl, intensities, method="min-max")
  File "spectrum.py", line 18, in normalize_spectrum
    return [(x - min_val) / (max_val - min_val) for x in intensities]
ZeroDivisionError: float division by zero"""

test_code = generate_regression_test(
    trace,
    {"spectrum.py": "... (source of normalize_spectrum)"}
)
print(test_code)
# Generates a test with all-equal intensities triggering max == min
Failure-based regression test generation: the LLM reads a ZeroDivisionError stack trace, identifies the root cause (division by zero when all intensities are equal), and produces a minimal reproducing pytest case.
Practical Example: The Scientific Pipeline That Crashed on Real Data

A computational chemistry team built a spectrum analysis pipeline that passed all 200 hand-written tests but crashed on the third day of production. The failure: a batch of calibration spectra where the detector saturated, producing a flat line (all intensities identical). The min-max normalization divided by zero. Using the failure-based generation approach above, the team generated a regression test in seconds, fixed the function to return a vector of 0.5 values for flat spectra, and added the LLM-generated test to their continuous integration (CI) suite. They then ran specification-based generation against the updated docstring and discovered three more edge cases they had never considered: negative intensities (from baseline subtraction), NaN values (from dead detector pixels), and mismatched list lengths. The LLM found in minutes what months of manual testing had missed.

The 20-Year-Old Bug and the Fuzzers That Followed

In 2014, security researcher Stéphane Chazelas discovered a critical parsing bug in the Bash shell that had survived over two decades of manual testing and code review. The vulnerability (CVE-2014-6271, known as "Shellshock") affected virtually every Linux server on the internet. Once disclosed, fuzzers including American Fuzzy Lop (AFL) quickly uncovered additional related Bash parsing flaws (CVE-2014-6277, CVE-2014-6278, and others) that manual review had also missed. The lesson: even expert human testers systematically miss certain input classes, because they reason about what the code should receive rather than what it can receive. Automated test generation, whether by fuzzer or LLM, excels precisely in this blind spot.

2. Coverage Metrics: Measuring What Tests Actually Verify

Knowing where test cases come from, whether from specifications, code, or failures, raises an immediate follow-up question: once you have generated those tests, how do you know they are thorough enough?

Generating tests is necessary but insufficient; you must also measure what those tests verify. Coverage metrics quantify which code paths execute during testing, and the metric you choose directly affects which bugs you catch.

2.1 Statement Coverage

Statement coverage (also called line coverage) is the simplest metric: what fraction of the program's executable statements were executed by the test suite? If a function has 20 statements and the tests execute 18 of them, statement coverage is \(18/20 = 90\%\).

$$C_{\text{stmt}} = \frac{|\{\text{executed statements}\}|}{|\{\text{total executable statements}\}|}$$

Statement coverage is easy to measure (the standard Python tool is coverage.py) and easy to understand, but it provides a false sense of security. A function with an if/else branch might have 100% statement coverage even though only one branch has been tested, if the other branch contains no unique statements.

Common Misconception

A common misconception is that a high coverage percentage (such as 90% or 100% statement coverage) means the code is well tested. Coverage measures only which lines or branches executed during testing; it says nothing about whether the test assertions checked the correct outputs, handled all meaningful input combinations, or verified important state transitions. A test suite can reach 100% statement coverage by calling every function once with trivial inputs and never asserting anything about the results, leaving serious bugs completely undetected.

2.2 Branch Coverage

Branch coverage measures whether every branch of every decision point has been exercised. For an if/else, branch coverage requires at least one test where the condition is true and one where it is false. For a loop, it requires at least one test that enters the loop and one that skips it (if the loop can be skipped).

$$C_{\text{branch}} = \frac{|\{\text{executed branches}\}|}{|\{\text{total branches}\}|}$$
"""
Measuring statement and branch coverage with coverage.py.
"""
# spectrum.py
def normalize_spectrum(wavelengths, intensities, method="min-max"):
    if len(wavelengths) != len(intensities):
        raise ValueError("Length mismatch")
    if not intensities:
        raise ValueError("Empty input")

    if method == "min-max":
        min_val = min(intensities)
        max_val = max(intensities)
        if max_val == min_val:
            return [0.5] * len(intensities)  # flat spectrum
        return [(x - min_val) / (max_val - min_val) for x in intensities]
    elif method == "z-score":
        mean_val = sum(intensities) / len(intensities)
        std_val = (
            sum((x - mean_val) ** 2 for x in intensities) / len(intensities)
        ) ** 0.5
        if std_val == 0:
            return [0.0] * len(intensities)
        return [(x - mean_val) / std_val for x in intensities]
    elif method == "peak":
        peak = max(abs(x) for x in intensities)
        if peak == 0:
            return [0.0] * len(intensities)
        return [x / peak for x in intensities]
    else:
        raise ValueError(f"Unknown method: {method}")
The normalize_spectrum function with three normalization methods and six guard conditions, illustrating how branch coverage demands tests for every method path and every edge-case check.
# Run tests with branch coverage measurement
pytest tests/test_spectrum.py --cov=spectrum --cov-branch --cov-report=term-missing

# Output:
# Name          Stmts   Miss Branch BrPart  Cover   Missing
# spectrum.py      28      0     14      0   100%
Running pytest with coverage.py in branch mode: the BrPart column reveals branches that are only partially covered, indicating tests that exercise one side of a conditional but not the other.

Checkpoint

So far: test cases can originate from specifications, code structure, or observed failures; once generated, statement coverage measures which lines executed and branch coverage measures which conditional paths were taken, but neither metric guarantees that assertions actually verified correctness.

2.3 Condition and MC/DC Coverage

For safety-critical software (avionics, medical devices, autonomous vehicles), branch coverage is insufficient. Modified Condition/Decision Coverage (MC/DC), where each individual boolean condition must be shown to independently change the overall decision outcome, requires that every individual condition in a compound decision independently affects the outcome. Consider a decision with two conditions: if temperature > 100 and pressure > 50. Branch coverage requires only two tests (both true, at least one false). MC/DC requires that each condition's effect be demonstrated independently, typically needing at least \(n + 1\) test cases for \(n\) conditions. (For a safety interlock with 10 sensor checks, that means branch coverage needs just 2 tests while MC/DC demands 11, each isolating one sensor's influence on the outcome.)

$$C_{\text{MC/DC}}: \forall c_i \in \text{conditions},\; \exists (t_1, t_2) \text{ s.t. } c_i \text{ differs and the decision outcome differs}$$

Mental Model

Think of MC/DC like troubleshooting a string of holiday lights wired in series. If the whole string goes dark, you need to prove that each individual bulb can independently cause the failure. You unscrew one bulb at a time while leaving all others in place; if the string stays dark only when that specific bulb is removed, you have demonstrated that bulb's independent effect. MC/DC works the same way: for each condition in a compound decision, you flip that one condition while holding all others fixed and confirm the overall decision outcome changes. Just as testing every bulb in a single on/off test of the whole string would not isolate which bulb matters, testing every branch without isolating individual conditions would not reveal which condition actually controls the decision.

MC/DC is mandated by DO-178C, the international safety standard governing software in airborne systems, for Level A avionics software. While most scientific software does not require this level of rigor, understanding MC/DC helps you reason about when simpler coverage metrics hide real gaps. In discovery systems where an incorrect decision could invalidate months of experimental results, MC/DC for critical decision logic is a reasonable investment. This connects to the evaluation frameworks discussed in Chapter 56: Evaluating Discovery Systems, where test adequacy directly affects trust in automated scientific conclusions.

"""
MC/DC test case generation for compound conditions.
Each condition must independently affect the decision.
"""

def generate_mcdc_tests(conditions: list[str], decision: str) -> list[dict]:
    """Generate MC/DC test vectors for a compound decision.

    For n conditions, produces n+1 test cases where each condition
    independently affects the decision outcome.

    Args:
        conditions: List of condition variable names, e.g. ["temp_high", "press_high"]
        decision: Boolean expression combining the conditions, e.g. "temp_high and press_high"

    Returns:
        List of test case dicts mapping condition names to bool values,
        plus a "decision" key with the expected outcome.
    """
    n = len(conditions)
    test_cases = []

    # Start with all-true case
    base = {c: True for c in conditions}
    base["decision"] = eval(decision, {}, base.copy())
    test_cases.append(base)

    # For each condition, flip it while keeping others at base values
    for i, cond in enumerate(conditions):
        flipped = base.copy()
        flipped[cond] = not base[cond]
        flipped["decision"] = eval(decision, {}, {
            c: flipped[c] for c in conditions
        })
        # Only include if the decision outcome actually changes
        if flipped["decision"] != base["decision"]:
            test_cases.append(flipped)
        else:
            # Need a different base for this condition; try all-false
            alt_base = {c: False for c in conditions}
            alt_base[cond] = True
            alt_base["decision"] = eval(decision, {}, {
                c: alt_base[c] for c in conditions
            })
            alt_flipped = alt_base.copy()
            alt_flipped[cond] = False
            alt_flipped["decision"] = eval(decision, {}, {
                c: alt_flipped[c] for c in conditions
            })
            if alt_base["decision"] != alt_flipped["decision"]:
                test_cases.extend([alt_base, alt_flipped])

    return test_cases


# Example: safety interlock for a robotic lab system
conditions = ["temp_safe", "pressure_safe", "door_closed"]
decision = "temp_safe and pressure_safe and door_closed"
vectors = generate_mcdc_tests(conditions, decision)

for v in vectors:
    vals = {k: v for k, v in v.items() if k != "decision"}
    print(f"  {vals} -> decision={v['decision']}")
# {temp_safe: True, pressure_safe: True, door_closed: True}  -> True
# {temp_safe: False, pressure_safe: True, door_closed: True}  -> False
# {temp_safe: True, pressure_safe: False, door_closed: True}  -> False
# {temp_safe: True, pressure_safe: True, door_closed: False}  -> False
MC/DC test vector generation for a three-condition safety interlock: four test cases (n+1 for n=3) demonstrate that each sensor condition independently controls the interlock decision.

Step-Through: MC/DC Test Vector Generation

Trace through MC/DC generation for the decision A and B with conditions [A, B]:

Step 1 (base case): Set all conditions true. A=True, B=True. Evaluate: True and True = True. Record: {A:T, B:T} → decision=True.
Step 2 (flip A): Keep B=True, set A=False. Evaluate: False and True = False. Decision changed (True→False), so A independently affects the outcome. Record: {A:F, B:T} → decision=False.
Step 3 (flip B): Keep A=True, set B=False. Evaluate: True and False = False. Decision changed (True→False), so B independently affects the outcome. Record: {A:T, B:F} → decision=False.
Result: 3 test vectors (n+1 = 2+1 = 3). Each condition has a pair of vectors where only it differs and the decision flips: (Step 1, Step 2) for A, (Step 1, Step 3) for B.

Key Insight: Coverage Measures Execution, Not Correctness

100% branch coverage means every branch executed at least once. It does not mean every branch produced the correct output. A test that calls normalize_spectrum([1], [5], "min-max") and never checks the return value achieves coverage but verifies nothing. Coverage tells you where your tests went; assertions tell you what they checked. Section 18.2 introduces mutation testing as a metric that measures whether your assertions are actually strong enough to detect faults.

3. End-to-End Test Generation with Playwright

Coverage metrics tell you whether your unit and integration tests exercise the code thoroughly, but they say nothing about whether the pieces work together from a user's perspective.

Unit and integration tests verify components in isolation. End-to-end (E2E) tests verify the complete system as a user experiences it: clicking buttons, filling forms, waiting for responses, and checking that the right content appears on screen. For Discovery Workbench components (introduced in Chapter 6), E2E tests ensure that the API, database, and UI work together correctly.

Recording and Refining E2E Tests

Playwright provides a codegen mode that records user interactions and generates test scripts automatically. Combined with an LLM, we can transform recorded interactions into robust, maintainable test suites with proper assertions and error handling.

"""
End-to-end test for the Discovery Workbench experiment dashboard.
Generated by Playwright codegen, then refined with LLM assistance
to add proper assertions and data-testid selectors.
"""
import pytest
from playwright.sync_api import Page, expect


@pytest.fixture
def dashboard_page(page: Page) -> Page:
    """Navigate to the experiment dashboard and wait for data load."""
    page.goto("http://localhost:8000/dashboard")
    # Wait for the experiment table to populate
    page.wait_for_selector("[data-testid='experiment-table'] tbody tr")
    return page


def test_experiment_list_loads(dashboard_page: Page):
    """Verify that the experiment list shows at least one experiment."""
    rows = dashboard_page.locator(
        "[data-testid='experiment-table'] tbody tr"
    )
    expect(rows).to_have_count(count=1, timeout=5000)  # at least 1


def test_experiment_filter_by_status(dashboard_page: Page):
    """Verify filtering experiments by status updates the table."""
    # Count initial rows
    initial_count = dashboard_page.locator(
        "[data-testid='experiment-table'] tbody tr"
    ).count()

    # Apply "completed" filter
    dashboard_page.select_option(
        "[data-testid='status-filter']", "completed"
    )

    # Wait for table to update
    dashboard_page.wait_for_timeout(500)

    # Filtered count should be <= initial count
    filtered_count = dashboard_page.locator(
        "[data-testid='experiment-table'] tbody tr"
    ).count()
    assert filtered_count <= initial_count

    # Every visible row should show "completed" status
    status_cells = dashboard_page.locator(
        "[data-testid='experiment-table'] tbody tr "
        "td[data-testid='status-cell']"
    )
    for i in range(status_cells.count()):
        expect(status_cells.nth(i)).to_have_text("completed")


def test_experiment_detail_navigation(dashboard_page: Page):
    """Verify clicking an experiment opens its detail view."""
    # Click the first experiment's name link
    first_name = dashboard_page.locator(
        "[data-testid='experiment-table'] tbody tr:first-child "
        "a[data-testid='experiment-link']"
    )
    experiment_name = first_name.text_content()
    first_name.click()

    # Should navigate to detail page
    expect(dashboard_page).to_have_url(
        pattern=r".*/experiments/\d+"
    )

    # Detail page should show the experiment name
    heading = dashboard_page.locator("h1[data-testid='experiment-title']")
    expect(heading).to_have_text(experiment_name)


def test_create_experiment_form_validation(dashboard_page: Page):
    """Verify that the create-experiment form validates required fields."""
    dashboard_page.click("[data-testid='create-experiment-btn']")

    # Submit without filling required fields
    dashboard_page.click("[data-testid='submit-experiment-btn']")

    # Should show validation error
    error = dashboard_page.locator("[data-testid='validation-error']")
    expect(error).to_be_visible()
    expect(error).to_contain_text("required")
Playwright E2E tests for the Discovery Workbench dashboard: four tests verify experiment listing, status filtering, detail navigation, and form validation using data-testid selectors for resilience against UI changes.
Library Shortcut: Playwright Codegen

Instead of writing E2E tests by hand, use Playwright's codegen to record interactions: playwright codegen http://localhost:8000/dashboard. This opens a browser where your clicks and keystrokes are recorded as Python test code. The generated code uses fragile CSS selectors; run it through an LLM with the prompt "refactor these selectors to use data-testid attributes (custom HTML attributes like data-testid="submit-btn" that give tests a stable handle to locate elements, independent of styling or layout changes) and add meaningful assertions" to produce production-quality tests. In practice, what might take 30 minutes of manual test writing can often shrink to a few minutes of recording plus a single LLM refinement pass.

4. The Coverage-Guided Generation Loop

E2E tests confirm that the full system behaves correctly for specific user scenarios, but they cannot systematically probe every code path the way unit tests can; the real power emerges when you combine both levels of testing inside an iterative feedback loop.

Teams that skip structured test generation pay the price in production: post-mortem analyses across scientific computing projects suggest that many first-day-of-production crashes trace back to input classes no developer thought to test. The coverage-guided loop described next exists because ad-hoc test writing, no matter how diligent, systematically misses the inputs that matter most.

The most effective AI test generation strategy combines all three sources in a coverage-guided loop. Start with specification-based tests for the contract. Measure coverage. Feed the uncovered lines and branches to the LLM for code-based generation. Measure again. If coverage plateaus, use failure-based generation from fuzzing or mutation testing (covered in Section 18.2) to find inputs that exercise stubborn paths. Figure 18.1 illustrates this iterative process. Figure 18.1.1 illustrates the coverage-guided test generation loop.

Coverage-guided test generation loop
Figure 18.1.1: The coverage-guided test generation loop iteratively combines specification-based, code-based, and failure-based test generation, using branch coverage measurements to direct each round of LLM-generated tests toward uncovered paths.
Spec-Based Generation Run Tests + Measure Coverage Target met? Yes Suite Complete No Identify Uncovered Lines + Branches Code-Based / Failure Test Generation Iteration 1: spec-based tests Iteration 2+: gap-targeted tests until coverage target or max rounds Dashed red arrow: feedback loop with updated test file
Figure 18.1: The coverage-guided test generation loop. Spec-based tests seed the suite; coverage measurement identifies gaps; code-based and failure-based generation targets those gaps. The cycle repeats until the coverage target is reached or the iteration budget is exhausted.
"""
Coverage-guided test generation loop.
Iteratively generates tests until a target branch coverage is reached.
"""
import subprocess
import json
from pathlib import Path


def get_coverage_gaps(
    test_file: str,
    source_file: str
) -> dict:
    """Run tests and return uncovered lines and branches."""
    result = subprocess.run(
        [
            "pytest", test_file,
            f"--cov={Path(source_file).stem}",
            "--cov-branch",
            "--cov-report=json:cov.json",
            "--quiet"
        ],
        capture_output=True, text=True
    )

    with open("cov.json") as f:
        cov_data = json.load(f)

    file_cov = cov_data["files"].get(source_file, {})
    return {
        "missing_lines": file_cov.get("missing_lines", []),
        "missing_branches": file_cov.get("missing_branches", []),
        "summary": {
            "line_rate": file_cov.get("summary", {}).get("percent_covered", 0),
            "branch_rate": file_cov.get("summary", {}).get(
                "percent_covered_branches", 0
            ),
        }
    }


def coverage_guided_generation(
    source_file: str,
    test_file: str,
    target_coverage: float = 95.0,
    max_iterations: int = 5
) -> dict:
    """Iteratively generate tests until target coverage is met.

    Returns:
        Final coverage statistics.
    """
    source_code = Path(source_file).read_text()

    for iteration in range(max_iterations):
        gaps = get_coverage_gaps(test_file, source_file)
        current = gaps["summary"]["branch_rate"]
        print(f"Iteration {iteration + 1}: branch coverage = {current:.1f}%")

        if current >= target_coverage:
            print("Target coverage reached!")
            return gaps["summary"]

        # Generate tests targeting uncovered branches
        new_tests = generate_branch_covering_tests(
            function_source=source_code,
            branches=[
                {"type": "branch", "line": line, "condition": "uncovered"}
                for line in gaps["missing_lines"][:10]  # limit context
            ]
        )

        # Append to test file
        with open(test_file, "a") as f:
            f.write(f"\n\n# Generated in iteration {iteration + 1}\n")
            f.write(new_tests)

    return gaps["summary"]
The coverage-guided generation loop in full: get_coverage_gaps parses the coverage.py JSON report, and coverage_guided_generation iterates up to five rounds, appending LLM-generated tests until branch coverage reaches the 95% target.
Research Frontier: Coverage-Guided LLM Testing

CoverUp (Pizzorno and Berger, 2024) implements a production-grade version of the coverage-guided loop shown above, achieving near-complete branch coverage on real-world Python projects. CodaMosa (Lemieux et al., 2023) combines search-based software testing (SBST, an approach that uses evolutionary algorithms to evolve test inputs toward higher coverage) with LLM calls: the search-based engine explores easy branches mechanically, and the LLM is called only for branches where the search engine gets stuck. More recently, MuTAP (Dakhel et al., 2024) closes the loop between test generation and test quality by using mutation testing as the feedback signal instead of coverage alone. MuTAP generates tests with an LLM, runs mutation analysis to identify surviving mutants, then feeds descriptions of those surviving mutants back to the LLM to generate mutant-killing tests in subsequent iterations. On benchmarks from HumanEval and EvalPlus, two widely used code-generation evaluation suites, MuTAP achieves higher mutation scores (the fraction of injected faults that the test suite detects) than coverage-guided approaches alone, demonstrating that optimizing for fault detection rather than line execution produces stronger test suites.

Real-World Application: Google's Test Generation at Scale

Google has publicly described using LLM-assisted test generation within its internal development workflow (circa 2023). The approach generates unit tests from function signatures and existing test patterns, measures coverage, and iteratively targets uncovered branches. Google's broader Assured Open Source Software program applies automated analysis (including fuzz testing via OSS-Fuzz) to over 1,000 critical open-source packages, though the specific integration of LLM-based test generation into that pipeline has not been detailed publicly. The general pattern, combining specification-based generation with coverage feedback at scale, illustrates how the techniques in this section extend to large codebases.

5. Test Quality Beyond Coverage

Coverage-guided generation can achieve impressive coverage numbers, but coverage is necessary, not sufficient, for test quality. A test suite with 100% branch coverage but weak assertions catches no bugs. Two complementary techniques measure test strength: property-based testing (where you declare invariants that must hold for all inputs, and a framework automatically generates hundreds of random inputs to try to violate them) generates inputs that explore the full input space, and mutation testing measures whether assertions detect injected faults. Section 18.2 covers both.

Try It: Measure and Close Coverage Gaps with LLM-Generated Tests

Pick any Python module you have written (or use a small open-source utility) and walk through the coverage-guided generation loop by hand:

1. Install the coverage tool (pip install pytest coverage) and run your existing tests with branch coverage enabled: pytest --cov=your_module --cov-branch --cov-report=term-missing. Record the initial branch coverage percentage and note which lines and branches are marked as missing.
2. Copy the source code of one function with uncovered branches. Write a prompt that includes the function source and asks an LLM (Claude, or any model you have access to) to generate pytest tests specifically targeting the uncovered lines you identified. Save the generated tests to a new file such as test_generated.py.
3. Run the generated tests in isolation (pytest test_generated.py) and fix any that fail to compile or import correctly. Track how many of the generated tests compile, how many pass, and how many fail due to genuine bugs versus incorrect expectations.
4. Re-run coverage with both your original and generated tests: pytest tests/ test_generated.py --cov=your_module --cov-branch --cov-report=term-missing. Compare the new branch coverage to your initial measurement.
5. Inspect the remaining uncovered branches. For each, decide whether it represents dead code (remove it), an error path that needs a specific setup to trigger (write that setup), or a path the LLM missed (refine your prompt and regenerate). Document what you learned about the gap between coverage quantity and test quality.

Exercise 18.1.4

Consider this function signature and docstring:

def merge_intervals(intervals: list[tuple[int, int]]) -> list[tuple[int, int]]:
    """Merge overlapping intervals and return sorted non-overlapping intervals.

    Raises ValueError if any interval has start > end.
    """

Without looking at any implementation, list at least six distinct test cases you would generate from the specification alone (not the code). For each, state whether it is a happy-path, boundary, or error-handling test. Then explain: which of your test cases could not be derived from the specification and would require code-based generation instead?

HintThink about: empty list, single interval, two non-overlapping intervals, two overlapping intervals, fully nested intervals, adjacent intervals (e.g., (1,3) and (3,5)), negative numbers, and the invalid-interval error path. The question about code-based generation is asking whether any implementation branches (such as a special case for already-sorted input) are invisible from the specification.

Lab: Coverage Gap Hunter

Goal: Experience the coverage-guided generation loop firsthand by measuring, generating, and re-measuring test coverage on a real Python library.
Tools needed: Python 3.10+, pytest, coverage (pip install pytest coverage), and access to any LLM (Claude, a local model, or the Anthropic API).
Setup (5 min): Clone the python-dateutil library (pip install python-dateutil) and locate the parser.py module. Run the existing tests with pytest --cov=dateutil.parser --cov-branch --cov-report=term-missing and record the initial branch coverage percentage.
Experiment (15 min): Pick one function with branch coverage below 80%. Copy its source into an LLM prompt and ask for five pytest tests targeting the uncovered branches. Save the generated tests, fix any import or compilation errors, and re-run coverage.
What to vary: Try generating tests from the docstring alone (specification-based) versus including the full source with uncovered line numbers (code-based). Compare how many new branches each approach covers.
What to observe: (1) How many generated tests compile without edits? (2) How many percentage points of branch coverage does each round add? (3) Do the specification-based or code-based tests cover more new branches? (4) Are there branches that neither approach covers, and why?

Exercises

Exercise 18.1.1 (Conceptual): A function has three if/elif/else blocks, each with two branches. What is the minimum number of test cases needed for (a) 100% statement coverage, (b) 100% branch coverage, and (c) 100% MC/DC coverage if each condition is a single boolean? Explain why these numbers differ.

Exercise 18.1.2 (Coding): Write a Python function generate_tests_from_trace(trace: str) -> str that takes a pytest failure traceback (including the assertion error message) and generates a minimal reproducing test. Test it against three real tracebacks from a project of your choice. Measure the compilation rate and reproduction rate of the generated tests.

Exercise 18.1.3 (Analysis): Run coverage.py in branch mode on a Python project with at least 500 lines of code. Identify the three largest uncovered regions. For each, explain whether the gap represents (a) dead code, (b) error-handling paths that are hard to trigger, or (c) genuinely untested functionality. Generate tests for category (c) using the specification-based approach from this section.