teachyou.ai academy
← All posts
LLM Eval

Prompt Regression Suites: Structuring Tests Like a Codebase

Pramod Dutta · May 14, 2026 · 15 min read

Why your prompt changes keep breaking things nobody warned you about

Somewhere in every AI team's history there is a Slack message that reads something like "did we change the summarization prompt? support tickets are up 40% today." Someone tweaked a system prompt to fix one complaint, shipped it, and three other things silently broke. Nobody noticed until customers did. This is the single most common failure mode in teams building on top of LLMs, and it happens because prompts are treated like configuration — a string you edit and redeploy — instead of code, which is something you test before you trust.

The fix is not exotic. Software engineering solved this problem decades ago with regression suites: a body of tests that captures how the system is supposed to behave, run automatically before every change ships, so that a fix for one bug can't quietly reintroduce three others. Prompts deserve the same treatment. A prompt is a function. It takes structured input, applies logic (in natural language instead of code), and produces structured or semi-structured output. It has edge cases, adversarial inputs, and behaviors you depend on downstream. If you would not ship a function without tests, you should not ship a prompt without a regression suite.

This article is about how to actually build one — not the theory, the file layout, the test types, the tooling, and the workflow that makes prompt testing feel like pytest instead of vibes. We teach this exact discipline in our AI Engineering courses at teachyou.ai because it is the difference between a prototype and a product.

Stop thinking of a prompt as a string, start thinking of it as a unit under test

The first mental shift is definitional. A "prompt" in production is really three things bundled together:

  • The instruction text (system prompt, few-shot examples, formatting rules)
  • The input contract (what variables get interpolated in, what shape they take)
  • The output contract (what downstream code expects to parse out)

When you change any one of these, you are changing a function signature or its implementation. Software engineers would never let a function signature change merge without running the test suite that depends on it. Yet prompt changes routinely ship with nothing more than "I tried it three times in the playground and it looked good."

Treating the prompt as a unit under test means every prompt in your system should have an associated test file, the same way every module in a well-maintained codebase has an associated test_module.py or module.test.ts. The directory structure should mirror your code:

prompts/
  summarizer/
    prompt.txt
    schema.json
  classifier/
    prompt.txt
    schema.json
tests/
  prompts/
    summarizer/
      test_summarizer.py
      fixtures/
        cases.jsonl
    classifier/
      test_classifier.py
      fixtures/
        cases.jsonl

This is not busywork. It means when someone opens a PR that touches prompts/summarizer/prompt.txt, CI knows exactly which test file to run, and a reviewer knows exactly which fixtures to check for new edge cases.

The anatomy of a prompt regression suite

A mature prompt regression suite has layers, just like a mature software test suite has unit tests, integration tests, and end-to-end tests. Skipping layers is how teams end up either shipping broken prompts (too few tests) or burning thousands of dollars a week re-running slow LLM-judged evals on every commit (too many expensive tests, no cheap ones underneath).

1. Golden output tests (deterministic, cheap). For prompts that have a genuinely correct answer — extraction, classification, structured formatting — you write exact-match or schema-match assertions. These run in milliseconds and cost nothing because you often don't even need to call the model if you're testing a parser; but when you do call the model, the assertion itself is free.

2. Property-based tests (deterministic, cheap). Instead of asserting the exact output, you assert properties of the output: the JSON is valid, the summary is shorter than the source, the response never contains a banned phrase, every extracted date is in ISO format. These catch a huge share of regressions without needing a "correct" answer to compare against.

3. Semantic similarity tests (cheap-ish). For free-text output where there's no single correct phrasing, you compare embeddings of the new output against a reference output and assert a similarity threshold. Cheap to run, catches wild drift, but doesn't catch subtle quality regressions (a fluent, on-topic, wrong answer scores fine).

4. LLM-as-a-Judge tests (expensive, most powerful). For everything semantic-similarity can't catch — tone, correctness of reasoning, adherence to nuanced instructions — you use a second LLM call to grade the output against a rubric. This is the most expensive and slowest layer, so it should be the smallest layer: run on a curated subset, not your entire fixture set, on every commit.

The mistake most teams make is jumping straight to layer 4 for everything. It's slow, it's non-deterministic (a judge model can flip its own verdict between runs), and it hides regressions that a five-millisecond regex assertion would have caught instantly. Build the pyramid bottom-up.

Fixtures are your test data — version them like you version code

In software testing, nobody writes assert add(2,2) == 4 and calls it a suite. You write dozens of cases covering zero, negatives, overflow, type coercion. Prompt fixtures need the same density, and they need to live in version control, not in someone's head or a spreadsheet that only one person can find.

A good fixture file is a flat, append-only list of cases. JSONL works well because it diffs cleanly in git — one case per line, easy to see exactly which case was added or changed in a PR.

# fixtures/cases.jsonl — one JSON object per line
{"id": "sum-001", "input": "Quarterly earnings beat expectations by 12%...", "expected_contains": ["earnings", "beat"], "max_words": 40}
{"id": "sum-002", "input": "", "expected_error": "empty_input"}
{"id": "sum-003", "input": "N/A N/A N/A N/A N/A N/A N/A N/A N/A", "expected_error": "low_signal_input"}
{"id": "sum-004", "input": "<script>alert(1)</script> Meeting notes: budget approved.", "expected_not_contains": ["<script>"]}
{"id": "sum-005", "input": "Le chiffre d'affaires a augmenté de 12% ce trimestre.", "expected_contains": ["12%"], "language": "fr"}

Every fixture case needs an id. This is non-negotiable — when a test fails in CI, you want to say "sum-003 regressed" not "test case #17 in a 200-line array regressed." IDs are also how you track a case across renames and refactors of the test file itself.

Where do fixture cases come from? Four sources, roughly in order of value:

  • Production failures. Every time a prompt misbehaves in the wild, the input that caused it becomes a permanent fixture. This is the single highest-leverage source — it's a bug you already paid for once; don't pay for it twice.
  • Edge cases you can predict. Empty input, extremely long input, non-English input, adversarial/injection attempts, malformed structured data.
  • Representative production samples. A random (not cherry-picked) sample of real traffic, so your suite reflects actual distribution, not just the happy path you imagined at design time.
  • Manually authored cases for new features. Before you ship a new capability, write the fixtures for it first — this is prompt TDD, and it works.

Writing the actual test harness

Here's a minimal but realistic harness in Python, using pytest because it parametrizes cleanly and most teams already have it in their toolchain. This example tests a classification prompt that should assign a support ticket to one of a fixed set of categories.

import json
import pytest
from pathlib import Path
from my_llm_client import run_prompt  # thin wrapper around your model call

FIXTURES = Path(__file__).parent / "fixtures" / "cases.jsonl"

def load_cases():
    with open(FIXTURES) as f:
        return [json.loads(line) for line in f if line.strip()]

CASES = load_cases()
CASE_IDS = [c["id"] for c in CASES]

@pytest.mark.parametrize("case", CASES, ids=CASE_IDS)
def test_classifier_regression(case):
    result = run_prompt(
        "classifier",
        input_text=case["input"],
        temperature=0,       # deterministic sampling for regression tests
        seed=42,
    )

    # Layer 1: schema/contract check — fail fast, no model reasoning needed
    assert result.category in {"billing", "bug", "feature_request", "abuse", "other"}

    # Layer 2: property assertions from the fixture
    if "expected_category" in case:
        assert result.category == case["expected_category"], (
            f"{case['id']}: expected {case['expected_category']}, got {result.category}"
        )

    if "expected_confidence_above" in case:
        assert result.confidence >= case["expected_confidence_above"]

    if case.get("expected_error"):
        assert result.error_code == case["expected_error"]

Three details in that snippet matter more than they look.

First, temperature=0 and a fixed seed. Regression tests need to be as close to deterministic as your model provider allows. If you run the same test twice and get different pass/fail results, you don't have a test, you have a coin flip with extra steps. Not every provider honors seeds perfectly, so pin what you can and treat any residual flakiness as a signal to move that specific case up to the LLM-judge layer with a similarity threshold instead of exact match.

Second, the test IDs come from the fixture data itself, not from pytest's auto-numbering. When test_classifier_regression[sum-003] fails in your CI logs, you know instantly which case broke without cross-referencing line numbers.

Third (and this one is easy to skip): the schema/contract assertion runs unconditionally, even when the fixture doesn't specify an expected category. This catches an entire class of regressions — the model starts returning categories that don't exist, or wraps output in markdown fences your parser chokes on — for free, on every single case, regardless of whether anyone thought to write that specific assertion.

Snapshot testing for free-text prompts

Classification is easy because outputs are enumerable. Summarization, rewriting, and conversational prompts are harder because there's no fixed correct string. This is where snapshot testing earns its keep — the same pattern frontend engineers use for UI components, borrowed for prose.

def test_summarizer_snapshot(case, snapshot):
    result = run_prompt("summarizer", input_text=case["input"], temperature=0)

    # Compare against last approved output, not a hardcoded string
    snapshot.assert_match(result.text, case["id"])

The workflow: the first time a case runs, its output is saved as the "approved" snapshot. Every subsequent run compares the new output against that snapshot. If they differ, the test fails — not because the new output is necessarily wrong, but because it *changed*, and a human needs to look at the diff and either approve it (update the snapshot) or reject it (the prompt regressed).

This flips the review burden in a useful way: instead of a reviewer reading a prompt diff and imagining what might change, they read an *output* diff — the actual before/after text — which is far easier to judge. "The new summary dropped the dollar figure" is obvious in a snapshot diff and easy to miss reading prompt instructions.

Snapshot tests need one guardrail: never let CI auto-approve a changed snapshot. Approval must be an explicit, reviewed step (pytest --snapshot-update run locally by the person who reviewed the diff, then committed), or you've built a test that can never fail — the worst kind of test.

Wiring it into CI so nobody can skip it

A regression suite that runs when someone remembers to run it is not a regression suite, it's a manual checklist with extra steps. It has to be in CI, gating merges, the same as your unit tests.

# .github/workflows/prompt-tests.yml
name: prompt-regression
on:
  pull_request:
    paths:
      - "prompts/**"
      - "tests/prompts/**"

jobs:
  cheap-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install -r requirements.txt
      - name: Run deterministic + property tests
        run: pytest tests/prompts -m "not llm_judge" --maxfail=1

  llm-judge-tests:
    runs-on: ubuntu-latest
    needs: cheap-tests
    steps:
      - uses: actions/checkout@v4
      - run: pip install -r requirements.txt
      - name: Run curated LLM-as-a-Judge subset
        run: pytest tests/prompts -m "llm_judge" --maxfail=3
        env:
          JUDGE_MODEL: claude-sonnet-4-5

Two design choices in that pipeline are deliberate. The path filter means this workflow only triggers when prompts or their tests actually change — you don't want to burn API spend running LLM-judge tests on a PR that only touched a CSS file. And the two-job split means cheap deterministic tests run first and gate the expensive judge tests; if a schema check fails, you never spend the API budget on a judge call for output you already know is broken.

Mark judge tests explicitly with a pytest marker (@pytest.mark.llm_judge) so you can run the cheap suite locally in under a second during development, and reserve the slow, costly suite for CI or an explicit --run-judge flag.

LLM-as-a-Judge, used correctly instead of as a magic wand

The layer everyone reaches for first — and over-uses — is LLM-as-a-Judge. It is genuinely powerful for the things deterministic checks can't touch: did the response stay on-topic, was the tone appropriate, did the reasoning actually support the conclusion. But it fails badly when treated as a universal quality oracle instead of a scoped, rubric-bound grader.

The failure mode looks like this: someone writes a judge prompt that says "rate this response's quality from 1-10," runs it against the whole fixture set, and treats a score drop from 8.2 to 7.9 as a regression signal. That number is nearly meaningless — the judge model's own scoring is not well-calibrated at that resolution, and a half-point average shift could be pure sampling noise.

The version that actually works constrains the judge to a narrow, binary or few-option rubric per test, not a free-floating quality score:

JUDGE_PROMPT = """
You are grading whether a customer support response correctly acknowledges
the customer's specific complaint before offering a solution.

Complaint: {complaint}
Response: {response}

Answer with exactly one word: ACKNOWLEDGED or SKIPPED.
Do not explain your reasoning. Do not use any other words.
"""

@pytest.mark.llm_judge
def test_response_acknowledges_complaint(case):
    result = run_prompt("support_responder", complaint=case["input"])
    verdict = run_prompt_raw(JUDGE_PROMPT.format(
        complaint=case["input"], response=result.text
    ))
    assert verdict.strip() == "ACKNOWLEDGED", f"{case['id']}: judge saw complaint skipped"

Narrow, binary, one criterion per judge call. Run each judge prompt against its own fixture subset, not one omnibus "rate everything" prompt against all cases. And because the judge itself is a prompt, it needs its own small regression suite — a handful of hand-labeled cases where you already know the right answer, so you catch it if the judge model's provider quietly changes behavior underneath you.

Handling flaky tests and non-determinism honestly

Some prompt tests will be flaky no matter how carefully you pin temperature and seed, because model providers reserve the right to route your request to slightly different infrastructure or model revisions. Two disciplines keep this from rotting your suite.

First, quarantine, don't delete. If a test flakes, mark it (@pytest.mark.flaky(reruns=2)) and track it — don't silently comment it out, or you've deleted the regression coverage for whatever it was protecting against.

Second — actually the more important one — measure flake rate before you trust a threshold. Run a new test 10-20 times against the current, known-good prompt before merging it. If it fails even once on output you know is correct, your assertion is too strict (an exact string match where a similarity threshold belongs) rather than the model being unreliable.

  • Deterministic tests (schema, regex, exact match): should have zero flake tolerance. A flaky deterministic test means the assertion is wrong, not the model.
  • Similarity-threshold tests: expect occasional near-boundary flakes; widen the threshold rather than rerunning until green.
  • LLM-judge tests: budget for 5-10% flake rate on subjective rubrics and use reruns plus majority vote rather than a single judge call, for anything gating a merge.

Running the suite as part of prompt change review

The last piece is process, not tooling. A prompt change should go through the same PR discipline as a code change: a diff of the prompt text, a diff of the fixture additions if any, and a CI run showing pass/fail with a link to any changed snapshots. The reviewer's job is not to re-read the entire prompt and imagine outcomes — it's to read the test results and the snapshot diffs, exactly like reviewing a code PR by reading the diff plus the test output, not by re-deriving the algorithm from scratch.

Teams that get this right build a habit: no prompt change merges without at least one new fixture case covering *why* the change was needed. If you're fixing a bug, the bug's input becomes fixture case number N+1, permanently, so it can never silently regress again. This is exactly how you'd handle a bug fix in application code — a failing test that reproduces the bug, then the fix that makes it pass — and it should feel exactly that unremarkable when applied to a prompt.

It's also worth naming the trade-off honestly: this discipline has a real cost. Someone has to write fixtures, someone has to maintain the harness, and someone has to pay the API bill for running judge tests in CI. Teams that skip it aren't avoiding the cost, they're deferring it — to the on-call engineer debugging a spike in support tickets at 11pm, to the customer who got the wrong refund amount, to the six months of "we think it got worse but we're not sure when." The upfront cost of a fixture file is always smaller than the downstream cost of a silent regression, and it only gets cheaper to pay as the suite grows, because every bug you catch this way is a bug you never have to catch again.

Prompts are code. They have inputs, outputs, edge cases, and consumers who depend on their contract. Once you structure tests around that reality — deterministic checks doing the cheap heavy lifting, snapshots catching drift in free text, and LLM-as-a-Judge reserved for the narrow, rubric-bound judgments only a model can make — prompt changes stop being a source of production surprises and start behaving like every other change in your codebase: reviewed, tested, and safe to ship.

Prompt Regression Suites: Structuring Tests Like a Codebase · TeachYou Academy