Prompt Engineering Testing: Building Regression Suites for Prompt Changes

Prompt Engineering Testing: Building Regression Suites for Prompt Changes

Changing a prompt is a code change. It can break features silently — the LLM still returns output, but the quality drops. Without a regression suite, you discover prompt regressions in production through user complaints, not tests. This guide covers how to build CI-integrated prompt regression testing that catches quality drops before they ship.

The Prompt Regression Problem

Prompt changes are common:

  • Adding a new instruction
  • Adjusting tone or format requirements
  • Fixing one edge case that breaks others
  • Model upgrades that require prompt adjustments

Any of these can silently degrade output quality. The challenge: how do you detect "this prompt change made the output worse" in a CI pipeline?

Strategy: Golden Dataset + Score Tracking

The foundation of prompt regression testing is a golden dataset — a curated set of inputs with known good outputs — combined with score tracking over time.

Golden dataset requirements:

  • Cover happy paths and edge cases
  • Include examples of behaviors you care about most
  • Be small enough to run in CI (20-100 examples max)
  • Be maintained like test code

Creating a Golden Dataset

# evals/golden_dataset.py
"""
Golden dataset for customer support chatbot.
Run: python -m pytest tests/evals/ -v
"""

GOLDEN_DATASET = [
    {
        "id": "pricing-001",
        "category": "pricing",
        "input": "How much does HelpMeTest cost?",
        "context": "HelpMeTest uses usage-based pricing: $0.003 per test run ($3 per 1,000 runs), no base fee.",
        "expected_behavior": "Mentions usage-based pricing and the per-run rate",
        "required_elements": ["$0.003", "run"],
        "forbidden_elements": ["free tier", "enterprise"],  # Should not conflate plans
    },
    {
        "id": "pricing-002",
        "category": "pricing",
        "input": "Do you have a free tier?",
        "context": "No free tier. A 14-day free trial is available (card required, not charged if cancelled before the trial ends).",
        "expected_behavior": "Mentions the 14-day trial and that a card is required",
        "required_elements": ["trial", "card"],
        "forbidden_elements": ["free tier", "no credit card"],
    },
    {
        "id": "scope-001",
        "category": "out-of-scope",
        "input": "Can you write my Python code for me?",
        "context": "",
        "expected_behavior": "Politely declines, focuses on testing help",
        "required_elements": [],
        "forbidden_elements": ["here is", "def ", "import"],  # Should not write code
    },
    {
        "id": "format-001",
        "category": "format",
        "input": "List all the features of HelpMeTest's pricing",
        "context": "Usage-based plan: unlimited tests, parallel execution, 6-month data retention.",
        "expected_behavior": "Uses a list format",
        "required_elements": [],
        "format_check": "contains_list",  # Checks for bullet points or numbered list
    },
]

The Evaluation Engine

# evals/evaluator.py
import json
import re
from dataclasses import dataclass
from typing import Callable

@dataclass
class EvalResult:
    test_id: str
    passed: bool
    score: float
    details: dict

class PromptEvaluator:
    def __init__(self, llm_fn: Callable[[str, str], str]):
        """
        llm_fn: function(prompt, context) -> str
        The function wrapping your LLM with the current prompt.
        """
        self.llm_fn = llm_fn
    
    def evaluate_case(self, case: dict) -> EvalResult:
        actual_output = self.llm_fn(case["input"], case.get("context", ""))
        
        checks = []
        
        # Check required elements
        for required in case.get("required_elements", []):
            present = required.lower() in actual_output.lower()
            checks.append(("required_element", required, present))
        
        # Check forbidden elements
        for forbidden in case.get("forbidden_elements", []):
            absent = forbidden.lower() not in actual_output.lower()
            checks.append(("forbidden_element", forbidden, absent))
        
        # Format checks
        if case.get("format_check") == "contains_list":
            has_list = bool(re.search(r'(\n[-•*]\s|\n\d+\.\s)', actual_output))
            checks.append(("format_check", "contains_list", has_list))
        
        passed_checks = sum(1 for _, _, result in checks if result)
        total_checks = len(checks) if checks else 1
        score = passed_checks / total_checks
        passed = score >= 1.0  # All checks must pass
        
        return EvalResult(
            test_id=case["id"],
            passed=passed,
            score=score,
            details={
                "actual_output": actual_output,
                "checks": [{"type": t, "target": target, "passed": r} for t, target, r in checks],
            }
        )
    
    def evaluate_dataset(self, dataset: list[dict]) -> dict:
        results = [self.evaluate_case(case) for case in dataset]
        
        by_category = {}
        for result, case in zip(results, dataset):
            cat = case.get("category", "uncategorized")
            if cat not in by_category:
                by_category[cat] = []
            by_category[cat].append(result)
        
        return {
            "total": len(results),
            "passed": sum(1 for r in results if r.passed),
            "overall_score": sum(r.score for r in results) / len(results),
            "by_category": {
                cat: {
                    "passed": sum(1 for r in cat_results if r.passed),
                    "total": len(cat_results),
                    "score": sum(r.score for r in cat_results) / len(cat_results),
                }
                for cat, cat_results in by_category.items()
            },
            "failures": [
                {"id": r.test_id, "score": r.score, "output": r.details["actual_output"][:200]}
                for r in results if not r.passed
            ],
        }

Pytest Integration

# tests/evals/test_prompt_regression.py
import pytest
import json
import os
from pathlib import Path

from evals.golden_dataset import GOLDEN_DATASET
from evals.evaluator import PromptEvaluator
from src.chatbot import ChatBot  # Your LLM wrapper

@pytest.fixture(scope="module")
def evaluator():
    bot = ChatBot(model="gpt-4o-mini")
    return PromptEvaluator(llm_fn=bot.respond)

@pytest.fixture(scope="module")
def eval_results(evaluator):
    return evaluator.evaluate_dataset(GOLDEN_DATASET)

def test_overall_score_above_baseline(eval_results):
    """Overall score must not drop more than 5% from baseline."""
    baseline_path = Path("evals/baseline_scores.json")
    
    if not baseline_path.exists():
        pytest.skip("No baseline recorded yet. Run: python evals/record_baseline.py")
    
    with open(baseline_path) as f:
        baseline = json.load(f)
    
    current_score = eval_results["overall_score"]
    baseline_score = baseline["overall_score"]
    
    assert current_score >= baseline_score - 0.05, (
        f"Overall score regressed: {current_score:.3f} vs baseline {baseline_score:.3f}\n"
        f"Failures: {eval_results['failures']}"
    )

def test_pricing_category_score(eval_results):
    """Pricing questions are business-critical — require 100% pass rate."""
    pricing = eval_results["by_category"].get("pricing", {})
    
    if not pricing:
        pytest.skip("No pricing test cases in dataset")
    
    assert pricing["passed"] == pricing["total"], (
        f"Pricing accuracy degraded: {pricing['passed']}/{pricing['total']} passed"
    )

def test_out_of_scope_handling(eval_results):
    """Out-of-scope requests must be rejected correctly."""
    oos = eval_results["by_category"].get("out-of-scope", {})
    
    if not oos:
        pytest.skip("No out-of-scope test cases")
    
    assert oos["score"] >= 0.9, (
        f"Out-of-scope handling degraded: score {oos['score']:.2f}"
    )

@pytest.mark.parametrize("test_case", GOLDEN_DATASET)
def test_individual_case(test_case, evaluator):
    """Parametrized: run each golden case as an individual test."""
    result = evaluator.evaluate_case(test_case)
    
    assert result.passed, (
        f"Test {test_case['id']} failed (score: {result.score:.2f})\n"
        f"Input: {test_case['input']}\n"
        f"Output: {result.details['actual_output'][:300]}\n"
        f"Failed checks: {[c for c in result.details['checks'] if not c['passed']]}"
    )

Prompt Versioning

Treat prompts like code — version them and track what changed:

# src/prompts/customer_support/v1.py
SYSTEM_PROMPT_V1 = """You are a helpful customer support agent for HelpMeTest.
Answer questions about our product using only the provided context.
Be concise and friendly."""

# src/prompts/customer_support/v2.py  
SYSTEM_PROMPT_V2 = """You are a helpful customer support agent for HelpMeTest.
Answer questions using only the provided context.
Always mention relevant pricing tiers when discussing features.
Format lists as bullet points.
Be concise and professional."""
# tests/evals/test_prompt_v2_regression.py
"""
Regression test for v2 prompt changes.
V2 adds: pricing tier mentions, bullet point formatting.
Run before merging prompt v2 changes.
"""
import pytest
from evals.golden_dataset import GOLDEN_DATASET
from evals.evaluator import PromptEvaluator
from src.chatbot import ChatBot

def test_v2_does_not_regress_v1_scores():
    """V2 must score >= V1 on the existing golden dataset."""
    v1_bot = ChatBot(model="gpt-4o-mini", prompt_version="v1")
    v2_bot = ChatBot(model="gpt-4o-mini", prompt_version="v2")
    
    v1_evaluator = PromptEvaluator(llm_fn=v1_bot.respond)
    v2_evaluator = PromptEvaluator(llm_fn=v2_bot.respond)
    
    v1_results = v1_evaluator.evaluate_dataset(GOLDEN_DATASET)
    v2_results = v2_evaluator.evaluate_dataset(GOLDEN_DATASET)
    
    assert v2_results["overall_score"] >= v1_results["overall_score"] - 0.05, (
        f"V2 prompt regressed: "
        f"v1={v1_results['overall_score']:.3f}, v2={v2_results['overall_score']:.3f}"
    )

def test_v2_improves_format_compliance():
    """V2 specifically aims to improve list formatting — verify this."""
    v1_bot = ChatBot(model="gpt-4o-mini", prompt_version="v1")
    v2_bot = ChatBot(model="gpt-4o-mini", prompt_version="v2")
    
    format_cases = [c for c in GOLDEN_DATASET if c.get("format_check")]
    
    v1_eval = PromptEvaluator(llm_fn=v1_bot.respond)
    v2_eval = PromptEvaluator(llm_fn=v2_bot.respond)
    
    v1_format_score = v1_eval.evaluate_dataset(format_cases)["overall_score"]
    v2_format_score = v2_eval.evaluate_dataset(format_cases)["overall_score"]
    
    assert v2_format_score > v1_format_score, (
        f"V2 should improve format compliance but didn't: "
        f"v1={v1_format_score:.3f}, v2={v2_format_score:.3f}"
    )

CI Pipeline Configuration

# .github/workflows/prompt-regression.yml
name: Prompt Regression Tests

on:
  pull_request:
    paths:
      - 'src/prompts/**'
      - 'src/chatbot.py'

jobs:
  prompt-regression:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.11'
      
      - name: Install dependencies
        run: pip install -r requirements-test.txt
      
      - name: Run prompt regression tests
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
        run: |
          pytest tests/evals/ \
            -v \
            --tb=short \
            --junitxml=results/prompt-regression.xml
      
      - name: Check score regression vs main
        if: always()
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
        run: |
          # Score current branch
          python evals/score_current.py --output results/current_scores.json
          
          # Compare against baseline (from main branch artifact)
          python evals/check_regression.py \
            --baseline results/baseline_scores.json \
            --current results/current_scores.json \
            --max-regression 0.05 \
            --fail-on-regression
      
      - name: Upload results
        uses: actions/upload-artifact@v4
        with:
          name: prompt-regression-results
          path: results/

Recording and Updating Baselines

When you intentionally improve prompts, update the baseline:

# After verifying a prompt improvement is real:
python evals/record_baseline.py --commit  # Records current scores as new baseline

# This command should:
# 1. Run full eval suite
# 2. Write scores to evals/baseline_scores.json
# 3. Commit the updated baseline (automated via CI)

Handling Model Version Changes

When the underlying model updates (GPT-4 → GPT-4o), run regression across the full golden dataset:

# scripts/model_migration_check.py
"""
Run before migrating to a new model version.
Compares old model + current prompts vs new model + current prompts.
"""

import argparse
from evals.golden_dataset import GOLDEN_DATASET
from evals.evaluator import PromptEvaluator
from src.chatbot import ChatBot

def compare_models(old_model: str, new_model: str):
    old_eval = PromptEvaluator(ChatBot(model=old_model).respond)
    new_eval = PromptEvaluator(ChatBot(model=new_model).respond)
    
    old_results = old_eval.evaluate_dataset(GOLDEN_DATASET)
    new_results = new_eval.evaluate_dataset(GOLDEN_DATASET)
    
    print(f"\n{'='*60}")
    print(f"Model Migration: {old_model}{new_model}")
    print(f"{'='*60}")
    print(f"Overall: {old_results['overall_score']:.3f}{new_results['overall_score']:.3f}")
    
    for category, old_cat in old_results["by_category"].items():
        new_cat = new_results["by_category"].get(category, {})
        change = new_cat.get("score", 0) - old_cat["score"]
        print(f"  {category}: {old_cat['score']:.3f}{new_cat.get('score', 0):.3f} ({change:+.3f})")
    
    if new_results["failures"]:
        print(f"\nNew failures ({len(new_results['failures'])}):")
        for failure in new_results["failures"]:
            print(f"  - {failure['id']}: {failure['output'][:100]}...")

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--old", default="gpt-4-turbo")
    parser.add_argument("--new", default="gpt-4o")
    args = parser.parse_args()
    
    compare_models(args.old, args.new)

Key Takeaways

  • Build a golden dataset of 20-100 curated examples covering critical behaviors
  • Test quality dimensions (required elements, forbidden elements, format) not exact strings
  • Version your prompts like code — use version numbers and track changes in git
  • Run regression tests in CI on every prompt file change, not just code changes
  • Record baselines before merging improvements so future regressions are detectable
  • Run model migration checks before switching underlying models — the same prompt can behave differently across model versions
  • Weight test categories by business importance — pricing accuracy failures are more critical than formatting failures

Read more

Start now free