Prompt Regression Testing in CI: A Golden Set That Blocks Bad Prompt Changes
Hands-on lab · IDE in your browser

Prompt Regression Testing in CI: A Golden Set That Blocks Bad Prompt Changes

Build the test suite and CI gate for a production prompt. Write deterministic asserts for a golden set, add an LLM judge for rules that string checks cannot express and measure it against hand labels, run each case several times to separate regressions from noise, and gate pull requests on critical cases and pass-rate drops with a GitHub Actions workflow.

Time
50 min
Checked steps
5
Level
Intermediate
Setup
None
Read step 1

Hands-on labs require Pro · $29.99/mo · cancel anytime

Lab cockpit50 min · 5 stepsSession running
3 / 5 steps passingThe gate · step 4 of 5
evals.py▶ Run✓ Check
# ---------- Step 4: the gate ----------def compare(baseline, current, cases, tolerance=TOLERANCE):    """Why the current run must not ship (an empty list means it may). Blocking reasons:    - a critical case whose baseline status was "pass" is not "pass" now (name the case id);    - the pass rate fell by more than `tolerance` below the baseline's (give both rates)."""        
TerminalOutput

The job

Brightline Broadband's support team edits the prompt that triages every customer message, and each edit can quietly break something that used to work. You build the golden-set tests and the CI gate that decide whether a prompt change can merge.

5 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Deterministic checks

    Brightline Broadband's support tool uses prompts/triage.md to sort every incoming message and draft the first reply.

    You writecheck_case()
  2. 2

    A model as the judge

    The empathy candidate drops the line "never promise a refund".

    You writejudge()
  3. 3

    Summarise a run

    Production runs this prompt at temperature 0.7, so the same message can get a different answer each time.

    You writesummarize()
  4. 4

    The gate

    A gate turns two summaries into one decision.

    You writecompare()
  5. 5

    Wire it into CI

    A gate helps only if it runs on every change without anyone remembering to run it.

    You writeci()

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Deterministic checks

Brightline Broadband's support tool uses prompts/triage.md to sort every incoming message and draft the first reply. People keep editing that prompt: a friendlier tone, a shorter version, more empathy. Each edit fixes something and can quietly break something else. You are building the test suite that decides whether an edit ships.

golden.jsonl holds 30 real-looking messages. Each one lists the asserts a good answer must pass: valid JSON, the right category and priority, the ticket number in the reply, a word limit. Eight are tagged critical: the cases where a wrong answer hurts someone.

Do this

1. Write check_case(case, output) in evals.py: run each assert and return one readable message per failure. An empty list means the case passed.

2. Run the current prompt and the empathy candidate, one sample each. Both look fine. Read the empathy candidate and look for what the checks cannot see yet.

evals.py, the file you edit88 lines
"""The prompt test suite: checks every golden case, summarises a run and compares it with the baseline."""
import json
import re
from concurrent.futures import ThreadPoolExecutor

import harness

GOLDEN = "golden.jsonl"
SAMPLES = 3          # draws per case: production runs at temperature 0.7, so one draw proves little
TOLERANCE = 0.05     # how far the overall pass rate may fall before the gate fails


def load_golden(path=GOLDEN):
    return [json.loads(line) for line in open(path, encoding="utf-8") if line.strip()]


def parse_json(text):
    """The JSON object in the reply (a ```json fence around it is fine), or None."""
    m = re.search(r"\{.*\}", text or "", re.S)
    if not m:
        return None
    try:
        obj = json.loads(m.group(0))
    except json.JSONDecodeError:
        return None
    return obj if isinstance(obj, dict) else None


# ---------- Step 1: deterministic checks ----------
def check_case(case, output):
    """Run every assert of the case on the model's output; return the failure messages (empty = passed).
    Types: json; equals (ignores case and surrounding spaces); contains, contains_any, not_contains
    (case-insensitive; "{ticket}" means the case's ticket); max_words; judge (asks judge() the assert's
    question about the field; fails unless the answer equals "expect"; skipped while judge() returns None)."""
    # TODO (Step 1): parse_json(output) (None -> ["output is not a JSON object"]); then for each assert other than
    # "json", read the field (not a string -> "<field> is missing") and add a message for every assert that fails.
    # For "judge", call judge(a["question"], value) and fail only if it returns something other than None and a["expect"].
    raise NotImplementedError("Step 1: write check_case()")


# ---------- Step 2: a model as the judge ----------
JUDGE_PROMPT = """You check replies written by a customer support assistant.

Reply to check:
<<<
{text}
>>>

Question: {question}
Answer only if the reply itself does it. Warnings such as "never share your password" and statements that a
team will review a request do not count. Answer with one word: YES or NO."""


def judge(question, text):
    """"yes" or "no": the judge model's answer to a yes/no question about text; "unclear" if the answer is
    neither."""
    return None


def run_suite(template, cases, samples=SAMPLES):
    """Every case `samples` times. Returns {case id: {"passes": n, "samples": samples, "failures": [...]}}."""
    jobs = [(c, s) for c in cases for s in range(samples)]
    with ThreadPoolExecutor(8) as pool:
        outputs = list(pool.map(lambda j: harness.generate(template, j[0], j[1]), jobs))
        checked = list(pool.map(lambda j: check_case(j[0][0], j[1]), zip(jobs, outputs)))
    results = {c["id"]: {"passes": 0, "samples": samples, "failures": []} for c in cases}
    for (c, _), fails in zip(jobs, checked):
        if fails:
            results[c["id"]]["failures"] += fails
        else:
            results[c["id"]]["passes"] += 1
    return results


# ---------- Step 3: summarise a run ----------
def summarize(results, cases):
    """{"status": {case id: "pass" (every sample passed) | "flaky" (some did) | "fail" (none did)},
    "pass_rate": share of cases with status pass, "critical_pass_rate": the same over cases tagged critical
    (1.0 if there are none)}, rates rounded to 3 places."""
    raise NotImplementedError("summarize() arrives in Step 3")


# ---------- Step 4: the gate ----------
def compare(baseline, current, cases, tolerance=TOLERANCE):
    """Why the current run must not ship (an empty list means it may). Blocking reasons:
    - a critical case whose baseline status was "pass" is not "pass" now (name the case id);
    - the pass rate fell by more than `tolerance` below the baseline's (give both rates)."""
    raise NotImplementedError("compare() arrives in Step 4")
Provided for you:golden.jsonlharness.pyjudge_labels.jsonlprompts/candidates/empathy.mdprompts/candidates/friendlier.mdprompts/candidates/shorter.mdprompts/triage.mdtry_it.py

Frequently asked questions

How do you regression test an LLM prompt?

Keep a golden set of inputs with asserts on the outputs, run every prompt change against it, and compare the results with the current prompt's baseline. Block the change when important cases regress or the pass rate drops.

When should a prompt test use an LLM judge?

For rules that string matching cannot express, such as 'the reply does not promise a refund'. Check the judge against hand-labelled examples first, and use deterministic asserts for everything they can cover.

How do you deal with nondeterministic LLM output in CI?

Run each case several times at the production temperature, treat cases that pass only sometimes as flaky, and gate on cases that used to pass reliably plus a tolerance on the overall pass rate.

Regression testing prompts in CI

A prompt is code that changes behaviour in ways no compiler catches. A harmless-looking edit can make the model promise refunds, or stop it flagging an urgent case. Teams that ship LLM features test prompts the way they test code: a golden set of cases, asserts on every output, and a CI gate that blocks regressions. In this lab you build that gate end to end: deterministic asserts, an LLM judge checked against hand labels, repeated samples to separate noise from regressions, a gate on critical cases and pass-rate drops, and a GitHub Actions workflow that runs it on every pull request.