Module 7: Prompt Evaluation
5. Regression Testing for Prompts
Description
Detect when a prompt change breaks cases that used to work. Implementing test suites with pytest. CI/CD integration with GitHub Actions. Handling baseline scores and regression alerts. Strategies to cut testing costs.
What Is Regression Testing for Prompts?
In traditional software, regression tests verify that a fixed bug doesn't come back. For prompts, regression is different:
Typical problem without regression testing:
1. You have a classification prompt with 94% accuracy
2. You find a new edge case, you modify the prompt
3. The edge case now works (95% accuracy on that case)
4. But without knowing it, you broke 3 cases that used to work
5. Real accuracy: 91%
6. You find out when users report problems
With regression testing:
1. When you modify the prompt, you run the golden set automatically
2. The system detects: accuracy dropped from 94% to 91%
3. It shows you exactly which cases you broke
4. Don't deploy until the regression is fixed
Difference from Traditional Software Testing
| Aspect | Traditional Software | LLM Prompts |
|---|---|---|
| Determinism | 100% — same input = same output | Not always — same input can vary |
| Tolerance | 0% — a test passes or fails | 2-5% — small variations are normal |
| Cost to run | Milliseconds, no variable cost | Seconds, cost per token |
| Cause of regression | Bug in code | Prompt, model, or temperature change |
| Depth | Exact unit test | Statistical evaluation |
Base Implementation
The Regression Testing Engine
import json
import time
from pathlib import Path
from dataclasses import dataclass
from typing import Callable
from openai import OpenAI
client = OpenAI()
@dataclass
class TestResult:
example_id: str
input: str
expected: str
actual: str
passed: bool
score: float
latency_ms: float
tokens_used: int
def run_prompt(prompt_template: str, input_text: str) -> tuple[str, dict]:
"""
Runs the prompt and returns (output, metadata).
metadata includes latency and tokens.
"""
start = time.time()
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "user", "content": prompt_template.format(input=input_text)}
],
temperature=0
)
latency_ms = (time.time() - start) * 1000
output = response.choices[0].message.content.strip()
metadata = {
"latency_ms": latency_ms,
"prompt_tokens": response.usage.prompt_tokens,
"completion_tokens": response.usage.completion_tokens,
"total_tokens": response.usage.total_tokens
}
return output, metadata
def evaluate_example(
prompt_template: str,
example: dict,
evaluator: Callable | None = None
) -> TestResult:
"""
Evaluates a single example from the golden set.
evaluator: custom function. If not provided, uses normalized exact match.
"""
output, metadata = run_prompt(prompt_template, example["input"])
if evaluator:
passed, score = evaluator(example["expected_output"], output)
else:
# Default: normalized exact match
expected_norm = str(example["expected_output"]).strip().lower()
output_norm = output.strip().lower()
passed = expected_norm == output_norm
score = 1.0 if passed else 0.0
return TestResult(
example_id=example["id"],
input=example["input"],
expected=str(example["expected_output"]),
actual=output,
passed=passed,
score=score,
latency_ms=metadata["latency_ms"],
tokens_used=metadata["total_tokens"]
)
Baseline Management
The baseline is the set of metrics from the current prompt, against which new versions are compared.
class BaselineManager:
"""Manages evaluation baselines for regression comparison."""
def __init__(self, baseline_path: str = "baseline.json"):
self.baseline_path = Path(baseline_path)
self._baselines: dict = {}
if self.baseline_path.exists():
self._load()
def _load(self) -> None:
with open(self.baseline_path) as f:
self._baselines = json.load(f)
print(f"Baseline loaded: {len(self._baselines)} prompts")
def save(self) -> None:
with open(self.baseline_path, "w") as f:
json.dump(self._baselines, f, indent=2)
print(f"Baseline saved to {self.baseline_path}")
def log(self, prompt_name: str, metrics: dict, version: str) -> None:
"""Records the current metrics as the new baseline."""
self._baselines[prompt_name] = {
"version": version,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
"metrics": metrics
}
self.save()
print(f"New baseline recorded for '{prompt_name}' ({version})")
def get(self, prompt_name: str) -> dict | None:
"""Gets a prompt's baseline. None if it doesn't exist."""
return self._baselines.get(prompt_name)
def compare(
self,
prompt_name: str,
new_metrics: dict,
tolerance: float = 0.02
) -> dict:
"""
Compares new metrics against the baseline.
tolerance: Maximum variation allowed before reporting a regression.
0.02 = 2% of margin — small variations are normal.
"""
baseline = self.get(prompt_name)
if baseline is None:
return {
"status": "NO_BASELINE",
"message": f"No baseline for '{prompt_name}'. Record one with baseline_manager.log()",
"regressions": [],
"improvements": []
}
baseline_metrics = baseline["metrics"]
regressions = []
improvements = []
unchanged = []
for metric, new_value in new_metrics.items():
if metric not in baseline_metrics:
continue
previous_value = baseline_metrics[metric]
delta = new_value - previous_value
if delta < -tolerance:
regressions.append({
"metric": metric,
"previous": previous_value,
"new": new_value,
"delta": delta,
"severity": "CRITICAL" if delta < -0.05 else "MINOR"
})
elif delta > tolerance:
improvements.append({
"metric": metric,
"previous": previous_value,
"new": new_value,
"delta": delta
})
else:
unchanged.append(metric)
return {
"status": "FAIL" if regressions else "PASS",
"regressions": regressions,
"improvements": improvements,
"unchanged": unchanged,
"baseline_version": baseline["version"],
"baseline_date": baseline["timestamp"]
}
# Usage:
baseline_mgr = BaselineManager()
# First time: record the baseline of the current prompt
current_metrics = {"accuracy": 0.94, "faithfulness": 0.88, "format": 1.0}
baseline_mgr.log("sentiment_classifier", current_metrics, "v1.0")
# When you change the prompt: compare
new_metrics = {"accuracy": 0.91, "faithfulness": 0.89, "format": 1.0}
result = baseline_mgr.compare("sentiment_classifier", new_metrics)
if result["status"] == "FAIL":
print("❌ REGRESSION DETECTED:")
for r in result["regressions"]:
print(f" {r['metric']}: {r['previous']:.2%} → {r['new']:.2%} ({r['delta']:+.2%}) [{r['severity']}]")
else:
print("✅ No regressions")
for m in result["improvements"]:
print(f" 📈 {m['metric']}: {m['previous']:.2%} → {m['new']:.2%} ({m['delta']:+.2%})")
Test Suite with pytest
To integrate with CI/CD, organize the tests with pytest:
# tests/test_classifier.py
import pytest
import json
from pathlib import Path
from openai import OpenAI
client = OpenAI()
# ===== FIXTURES =====
@pytest.fixture(scope="session")
def golden_set():
"""Loads the golden set once per session."""
path = Path("datasets/sentiment_classifier.json")
with open(path) as f:
return json.load(f)
@pytest.fixture(scope="session")
def baseline():
"""Loads the metrics baseline."""
path = Path("baseline.json")
if not path.exists():
return {}
with open(path) as f:
return json.load(f)
@pytest.fixture(scope="module")
def current_prompt():
"""Loads the current prompt from a file."""
with open("prompts/classifier_v1.txt") as f:
return f.read()
# ===== INDIVIDUAL TESTS =====
class TestSentimentClassifier:
@pytest.mark.parametrize("example_id,expected", [
("001", "POSITIVE"),
("002", "NEGATIVE"),
("003", "NEUTRAL"),
])
def test_critical_cases(self, current_prompt, example_id, expected, golden_set):
"""
Tests that must NEVER fail — the most basic cases.
If these fail, something is seriously wrong.
"""
example = next(e for e in golden_set if e["id"] == example_id)
output, _ = run_prompt(current_prompt, example["input"])
assert output.strip().upper() == expected, \
f"Critical case {example_id} failed: expected={expected}, got={output}"
def test_min_accuracy(self, current_prompt, golden_set):
"""Accuracy across the full golden set can't drop below 85%."""
outputs = []
for ex in golden_set:
output, _ = run_prompt(current_prompt, ex["input"])
is_correct = output.strip().lower() == str(ex["expected_output"]).strip().lower()
outputs.append(is_correct)
accuracy = sum(outputs) / len(outputs)
assert accuracy >= 0.85, \
f"Accuracy {accuracy:.2%} below the acceptable minimum (85%)"
def test_no_regression_vs_baseline(self, current_prompt, golden_set, baseline):
"""Accuracy can't fall more than 2% relative to the baseline."""
if "sentiment_classifier" not in baseline:
pytest.skip("No baseline recorded — skipping regression test")
baseline_accuracy = baseline["sentiment_classifier"]["metrics"]["accuracy"]
outputs = []
for ex in golden_set:
output, _ = run_prompt(current_prompt, ex["input"])
outputs.append(output.strip().lower() == str(ex["expected_output"]).strip().lower())
current_accuracy = sum(outputs) / len(outputs)
assert current_accuracy >= baseline_accuracy - 0.02, \
f"REGRESSION: accuracy dropped from {baseline_accuracy:.2%} to {current_accuracy:.2%} ({current_accuracy - baseline_accuracy:+.2%})"
def test_format_compliance(self, current_prompt, golden_set):
"""The output must always be: POSITIVE, NEGATIVE, or NEUTRAL."""
invalid_outputs = []
for ex in golden_set[:20]: # Only the first 20 for speed
output, _ = run_prompt(current_prompt, ex["input"])
if output.strip().upper() not in ["POSITIVE", "NEGATIVE", "NEUTRAL"]:
invalid_outputs.append({"id": ex["id"], "output": output})
assert len(invalid_outputs) == 0, \
f"Outputs with invalid format: {invalid_outputs}"
def test_edge_cases(self, current_prompt, golden_set):
"""Edge cases must have accuracy >= 70%."""
edge_cases = [e for e in golden_set if e.get("difficulty") in ["hard", "very_hard"]]
if not edge_cases:
pytest.skip("No edge cases in the golden set")
outputs = []
for ex in edge_cases:
output, _ = run_prompt(current_prompt, ex["input"])
outputs.append(output.strip().lower() == str(ex["expected_output"]).strip().lower())
accuracy = sum(outputs) / len(outputs)
assert accuracy >= 0.70, \
f"Accuracy on edge cases ({accuracy:.2%}) below 70%"
def test_acceptable_latency(self, current_prompt):
"""Average latency must be < 3 seconds."""
import time
test_input = "This is an excellent product"
latencies = []
for _ in range(3):
start = time.time()
run_prompt(current_prompt, test_input)
latencies.append(time.time() - start)
avg_latency = sum(latencies) / len(latencies)
assert avg_latency < 3.0, \
f"Average latency {avg_latency:.1f}s exceeds the 3s limit"
CI/CD Integration (GitHub Actions)
# .github/workflows/prompt-regression-tests.yml
name: Prompt Regression Tests
on:
pull_request:
paths:
- 'prompts/**' # Only when the prompts change
- 'datasets/**' # Or when the golden set changes
push:
branches: [main]
schedule:
- cron: '0 2 * * *' # Daily at 2am (to catch model drift)
jobs:
regression-tests:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Cache dependencies
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run critical tests (fast)
run: pytest tests/test_classifier.py::TestSentimentClassifier::test_critical_cases -v
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Run regression tests
run: |
pytest tests/test_classifier.py -v \
--tb=short \
--junitxml=test-results.xml \
-k "not latency" # Exclude latency tests in CI (flaky)
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Upload test results
uses: actions/upload-artifact@v3
if: always()
with:
name: test-results
path: test-results.xml
- name: Comment PR with results
if: github.event_name == 'pull_request'
uses: EnricoMi/publish-unit-test-result-action@v2
with:
files: test-results.xml
Strategies to Cut Testing Costs
The cost of running regression tests in CI can be significant. Strategies to bring it down:
Strategy 1: Test Tiers
# conftest.py — pytest configuration
import pytest
def pytest_addoption(parser):
parser.addoption("--test-tier", action="store", default="smoke",
help="Test tier: smoke | standard | full")
@pytest.fixture(scope="session")
def test_tier(request):
return request.config.getoption("--test-tier")
# In tests:
@pytest.fixture
def golden_set_by_tier(test_tier):
"""Returns a subset of the golden set based on the tier."""
with open("datasets/golden_set.json") as f:
all_examples = json.load(f)
if test_tier == "smoke":
# Critical cases only: fast and cheap
return [e for e in all_examples if e.get("critical", False)][:10]
elif test_tier == "standard":
# Happy path + edge cases: cost/coverage balance
return [e for e in all_examples if e.get("difficulty") in ["easy", "hard"]][:50]
else: # full
return all_examples
# In CI (PRs): smoke tests only
pytest tests/ --test-tier=smoke
# In CI (merge to main): standard tests
pytest tests/ --test-tier=standard
# Overnight: full suite
pytest tests/ --test-tier=full
Strategy 2: Result Caching
import hashlib
import json
from pathlib import Path
class TestCache:
"""Cache of test results to avoid re-running tests with no changes."""
def __init__(self, cache_dir: str = ".test_cache"):
self.cache_dir = Path(cache_dir)
self.cache_dir.mkdir(exist_ok=True)
def cache_key(self, prompt: str, input_text: str, model: str = "gpt-4o-mini") -> str:
content = f"{prompt}|{input_text}|{model}"
return hashlib.sha256(content.encode()).hexdigest()[:16]
def get(self, prompt: str, input_text: str) -> str | None:
key = self.cache_key(prompt, input_text)
path = self.cache_dir / f"{key}.json"
if path.exists():
with open(path) as f:
return json.load(f)["output"]
return None
def set(self, prompt: str, input_text: str, output: str) -> None:
key = self.cache_key(prompt, input_text)
path = self.cache_dir / f"{key}.json"
with open(path, "w") as f:
json.dump({"output": output, "timestamp": time.time()}, f)
def stats(self) -> dict:
files = list(self.cache_dir.glob("*.json"))
return {"cached_results": len(files), "cache_dir": str(self.cache_dir)}
cache = TestCache()
def run_prompt_cached(prompt: str, input_text: str) -> str:
"""Cached version of run_prompt for tests."""
cached = cache.get(prompt, input_text)
if cached:
return cached
output, _ = run_prompt(prompt, input_text)
cache.set(prompt, input_text, output)
return output
Strategy 3: Parallelization
# pytest-xdist to parallelize tests
# pip install pytest-xdist
# In CI:
# pytest tests/ -n 4 # 4 workers in parallel
# In the test code, use asyncio to parallelize the API calls:
import asyncio
from openai import AsyncOpenAI
async_client = AsyncOpenAI()
async def run_prompt_async(prompt_template: str, input_text: str) -> tuple[str, dict]:
"""Async version of run_prompt."""
response = await async_client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt_template.format(input=input_text)}],
temperature=0
)
return response.choices[0].message.content.strip(), {}
async def evaluate_golden_set_async(
prompt_template: str,
golden_set: list[dict],
max_concurrent: int = 10
) -> list[dict]:
"""Evaluates the golden set in parallel with a concurrency limit."""
semaphore = asyncio.Semaphore(max_concurrent)
async def evaluate_with_limit(example):
async with semaphore:
output, _ = await run_prompt_async(prompt_template, example["input"])
return {
"id": example["id"],
"expected": str(example["expected_output"]),
"actual": output,
"passed": output.strip().lower() == str(example["expected_output"]).strip().lower()
}
tasks = [evaluate_with_limit(ex) for ex in golden_set]
return await asyncio.gather(*tasks)
# Run:
# results = asyncio.run(evaluate_golden_set_async(prompt, golden_set))
Regression Report
def generate_regression_report(
prompt_name: str,
results: list[dict],
baseline_metrics: dict | None = None
) -> str:
"""
Generates a markdown report of the regression test results.
Useful for commenting on PRs or sending over Slack.
"""
passed = sum(1 for r in results if r["passed"])
failed = len(results) - passed
accuracy = passed / len(results)
lines = [
f"# Regression Test: {prompt_name}",
f"",
f"**Date:** {time.strftime('%Y-%m-%d %H:%M:%S')}",
f"",
f"## Summary",
f"| Metric | Value |",
f"|---------|-------|",
f"| Total examples | {len(results)} |",
f"| Passed | {passed} ✅ |",
f"| Failed | {failed} {'❌' if failed > 0 else '✅'} |",
f"| Accuracy | {accuracy:.2%} |",
]
# Comparison against the baseline
if baseline_metrics and "accuracy" in baseline_metrics:
baseline_acc = baseline_metrics["accuracy"]
delta = accuracy - baseline_acc
status = "✅ PASS" if delta >= -0.02 else "❌ REGRESSION"
lines.extend([
f"| Baseline accuracy | {baseline_acc:.2%} |",
f"| Delta vs baseline | {delta:+.2%} |",
f"| Status | {status} |",
])
# Failed cases
if failed > 0:
lines.extend([
f"",
f"## Failed Cases ({failed})",
f"",
])
for r in results:
if not r["passed"]:
short_input = str(r.get("input", ""))[:60]
lines.extend([
f"### ❌ ID: {r['id']}",
f"- **Input:** `{short_input}...`",
f"- **Expected:** `{r['expected']}`",
f"- **Got:** `{r['actual']}`",
f"",
])
return "\n".join(lines)
Troubleshooting
Problem 1: False positives in regression tests
Symptom: The tests fail even though the prompt didn't change.
Cause: Natural model variability, even with temperature=0.
Solution:
# Raise the tolerance of the baseline comparison
result = baseline_mgr.compare(
"classifier",
new_metrics,
tolerance=0.03 # 3% instead of 2%
)
# Or run several times and average
def avg_accuracy(prompt, golden_set, n_runs=3):
accuracies = []
for _ in range(n_runs):
outputs = [run_prompt(prompt, e["input"])[0] for e in golden_set]
acc = sum(o.strip().lower() == str(e["expected_output"]).strip().lower()
for o, e in zip(outputs, golden_set)) / len(golden_set)
accuracies.append(acc)
return sum(accuracies) / len(accuracies)
Problem 2: Golden set too small
Symptom: With 20 examples, a single failure moves accuracy by 5%.
Cause: 20 examples = too much variance to tell real changes from noise.
Solution:
# Calculate the minimum size you need
def current_error_margin(n: int, accuracy: float = 0.9, confidence: float = 0.95) -> float:
"""Calculates the margin of error with n examples."""
from scipy.stats import norm
z = norm.ppf((1 + confidence) / 2)
return z * (accuracy * (1 - accuracy) / n) ** 0.5
# n=20: margin ≈ ±13% — way too wide
# n=100: margin ≈ ±6% — acceptable
# n=400: margin ≈ ±3% — good
print(f"n=20: ±{current_error_margin(20):.0%}")
print(f"n=100: ±{current_error_margin(100):.0%}")
print(f"n=400: ±{current_error_margin(400):.0%}")
Problem 3: Excessive cost in CI
Symptom: Each PR costs $5-10 in API calls.
Solution:
# 1. Use small smoke tests on PRs (10-20 critical examples)
# 2. Full suite only on merges to main
# 3. Cache results for prompts that didn't change
# 4. Rate limiting to reduce cost
import time
def run_with_rate_limit(
prompt_template: str,
golden_set: list[dict],
requests_per_minute: int = 30
) -> list[dict]:
"""Runs the golden set with rate limiting."""
delay = 60.0 / requests_per_minute # seconds between requests
results = []
for i, example in enumerate(golden_set):
if i > 0:
time.sleep(delay)
output, metadata = run_prompt(prompt_template, example["input"])
results.append({
"id": example["id"],
"output": output,
"tokens": metadata["total_tokens"]
})
return results
Exercises
Exercise 1: Write your first regression test
Write a test that verifies the following classification prompt keeps accuracy >= 88%:
CLASSIFIER_PROMPT = """Classify the following text as POSITIVE, NEGATIVE, or NEUTRAL.
Respond only with the category.
Text: {input}
Category:"""
See solution
import pytest
import json
from openai import OpenAI
client = OpenAI()
CLASSIFIER_PROMPT = """Classify the following text as POSITIVE, NEGATIVE, or NEUTRAL.
Respond only with the category.
Text: {input}
Category:"""
@pytest.fixture
def sentiment_golden_set():
return [
{"id": "001", "input": "Excellent product, highly recommended", "expected_output": "POSITIVE"},
{"id": "002", "input": "Terrible, I would never buy again", "expected_output": "NEGATIVE"},
{"id": "003", "input": "The package arrived on time", "expected_output": "NEUTRAL"},
{"id": "004", "input": "Incredible quality, it exceeded my expectations", "expected_output": "POSITIVE"},
{"id": "005", "input": "Very bad shopping experience", "expected_output": "NEGATIVE"},
{"id": "006", "input": "The product comes in a blue box", "expected_output": "NEUTRAL"},
{"id": "007", "input": "I love it!", "expected_output": "POSITIVE"},
{"id": "008", "input": "Total fraud, completely useless", "expected_output": "NEGATIVE"},
]
def test_min_accuracy(sentiment_golden_set):
"""Accuracy can't drop below 88%."""
correct = 0
for example in sentiment_golden_set:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": CLASSIFIER_PROMPT.format(input=example["input"])}],
temperature=0
)
output = response.choices[0].message.content.strip().upper()
if output == example["expected_output"].upper():
correct += 1
accuracy = correct / len(sentiment_golden_set)
assert accuracy >= 0.88, f"Accuracy {accuracy:.2%} below the minimum"
print(f"✅ Accuracy: {accuracy:.2%}")
def test_valid_format(sentiment_golden_set):
"""The output must always be POSITIVE, NEGATIVE, or NEUTRAL."""
valid_categories = {"POSITIVE", "NEGATIVE", "NEUTRAL"}
invalid = []
for example in sentiment_golden_set[:5]: # Only 5 for speed
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": CLASSIFIER_PROMPT.format(input=example["input"])}],
temperature=0
)
output = response.choices[0].message.content.strip().upper()
if output not in valid_categories:
invalid.append({"id": example["id"], "output": output})
assert len(invalid) == 0, f"Outputs with invalid format: {invalid}"
Exercise 2: Implement baseline comparison
Write a function that compares current metrics against a baseline and returns whether there's a regression:
See solution
def detect_regression(
new_metrics: dict[str, float],
baseline_metrics: dict[str, float],
tolerance: float = 0.02
) -> dict:
"""
Detects regressions by comparing current metrics against the baseline.
Returns: {"has_regression": bool, "regressions": list, "improvements": list}
"""
regressions = []
improvements = []
for metric, new_value in new_metrics.items():
if metric not in baseline_metrics:
continue
previous_value = baseline_metrics[metric]
delta = new_value - previous_value
if delta < -tolerance:
regressions.append({
"metric": metric,
"previous": f"{previous_value:.2%}",
"new": f"{new_value:.2%}",
"delta": f"{delta:+.2%}",
"severity": "CRITICAL" if delta < -0.05 else "MINOR"
})
elif delta > tolerance:
improvements.append({
"metric": metric,
"delta": f"{delta:+.2%}"
})
return {
"has_regression": len(regressions) > 0,
"regressions": regressions,
"improvements": improvements,
"summary": f"{'❌ REGRESSION' if regressions else '✅ OK'}: {len(regressions)} regressions, {len(improvements)} improvements"
}
# Test
baseline = {"accuracy": 0.94, "format": 1.0, "faithfulness": 0.88}
new_metrics = {"accuracy": 0.91, "format": 1.0, "faithfulness": 0.91}
result = detect_regression(new_metrics, baseline)
print(result["summary"])
# ❌ REGRESSION: 1 regressions, 1 improvements
Summary
- Regression testing: Detect when a prompt change breaks cases that used to work
- Baseline: Save the current prompt's metrics; compare after every change
- pytest: The natural framework for organizing prompt tests; with fixtures and parametrize
- CI/CD: GitHub Actions runs the tests on every PR or push
- Tolerance: 2-3% of margin for the model's natural variation
- Tiers: Smoke (cheap, fast) → Standard → Full (expensive, complete)
- Cache: Reuse test results when the prompt didn't change
- Parallelization: Async to speed up the golden set run
Additional resources
- pytest Documentation — Testing framework
- GitHub Actions — CI/CD
- pytest-xdist — Test parallelization
- OpenAI Rate Limits — API limits for planning tests
- LangSmith Regression Testing — Managed alternative