Table of Contents
1. Classic QA vs. Evals: What Actually Changes
Every classic QA discipline shares one load-bearing assumption: given the same input and unchanged code, the system produces the same output, every time. Unit testing, integration testing, UAT, and regression testing are all really the same idea applied at different scopes: write down the one correct answer, compare the system's output to it, and treat any mismatch as a bug. That works perfectly for deterministic code — and it's exactly the assumption Chapter 1 showed doesn't hold for an LLM or an agent.
| Dimension | Classic QA | Evals |
|---|---|---|
| Core assumption | Deterministic: same input → same output, always | Probabilistic: same input can yield different, still-valid outputs |
| Pass/fail | Binary — exact match against one expected value | Scored — graded against a rubric or multiple valid answers, often as a rate across many runs |
| What one test run tells you | Definitive, given unchanged code | One sample from a distribution — not proof of anything on its own |
| What triggers investigation | A previously-passing test now fails | A pass rate across many runs drifts outside its normal range |
| Likely cause of a failure | A code bug — the root cause is assumed deterministic | Could be a real bug, model version drift, sampling variance, or an underspecified test case |
| Unit of measurement | Test count — "342 of 350 passing" | A metric with a distribution — "recall 91% ± 4% over 50 runs" |
| Reference data | Handwritten exact expected output | A Golden Dataset — verified ground truth plus rubrics |
| Rerunning the same test | Redundant by design | Necessary — it's the only way to measure reliability at all |
| "Is it fixed now?" | Yes/no — the test goes green | Statistical — did the score move more than normal noise, or not? |
This doesn't retire classic QA — it draws a boundary around where it still applies
Go back to the Woody's Hardware validator from Chapter 3. validator.js is 100% deterministic code — no LLM anywhere in it. That file is still exactly as unit-testable as it always was. A classic Pytest or JUnit suite asserting validateApplication(GD_001_INPUT).eligible === true is completely appropriate — in fact, it's how you'd validate that the Golden Dataset's own expected outputs are correct in the first place.
What's new is the layer sitting on top of that deterministic code: an agent asked to read validator.js and predict its behavior, or apply the 20 written rules itself without seeing the code. The rule of thumb: deterministic code keeps its classic QA suite; the probabilistic judgment layered around or on top of it needs evals. Most real agent-based systems are a mix of both.
2. Reusing JUnit and Pytest for Evals
Here's the good news: you don't need new tooling to start running evals. JUnit and Pytest's actual value was never "exact-match assertions" — it was test discovery, fixtures, parameterization, CI integration, and reporting. All of that infrastructure is just as useful for evals. What changes is what you assert, and how you interpret a failure.
Technique 1 — Parameterize over the Golden Dataset
Pytest's @pytest.mark.parametrize and JUnit 5's @ParameterizedTest were built to run one test body across many inputs — exactly what a Golden Dataset needs.
import pytest
from golden_dataset import GOLDEN_CASES # the 10 entries from Chapter 3
from agent_client import ask_agent_to_validate
from eval_metrics import rule_level_recall, rule_level_precision, agreement_rate
@pytest.mark.parametrize("case", GOLDEN_CASES, ids=lambda c: c["id"])
def test_agent_matches_ground_truth(case, eval_report):
runs = [ask_agent_to_validate(case["input"]) for _ in range(5)] # 5 samples per case
avg_recall = sum(rule_level_recall(r, case["expected"]) for r in runs) / len(runs)
avg_precision = sum(rule_level_precision(r, case["expected"]) for r in runs) / len(runs)
consistency = agreement_rate(runs)
eval_report.record(case["id"], recall=avg_recall, precision=avg_precision, consistency=consistency)
assert avg_recall >= 0.90, f"{case['id']}: recall {avg_recall:.0%} below threshold"
assert consistency >= 0.80, f"{case['id']}: consistency {consistency:.0%} below threshold"
Technique 2 — Repeat runs to measure self-consistency
JUnit 5's @RepeatedTest was designed to help surface flaky tests. It turns out to be a near-perfect fit for measuring an eval's self-consistency instead.
@RepeatedTest(5)
void agentFindsKnownBankruptcyFailure(RepetitionInfo repInfo) {
AgentResult result = agentClient.validate(GD_004_INPUT);
consistencyTracker.record("GD-004", repInfo.getCurrentRepetition(), result);
assertTrue(result.failedRules().contains("R10"),
"Run " + repInfo.getCurrentRepetition() + " missed the bankruptcy rule (R10)");
}
@AfterAll
static void checkConsistency() {
double agreement = consistencyTracker.agreementRateFor("GD-004");
assertTrue(agreement >= 0.80, "GD-004 consistency " + agreement + " was below the 80% threshold");
}
Technique 3 — Assert on a threshold, not an exact value
assertEquals(expected, actual) fails the instant one value doesn't match. Evals instead accumulate a score across many runs and assert that the aggregate clears a bar — closer to assertTrue(recall >= 0.90) than to exact equality. Pytest's pytest.approx and JUnit's soft-assertion patterns are useful low-level primitives here, but the real shift is conceptual: you're asserting on a statistic, not a value.
Technique 4 — Use fixtures/extensions for the bookkeeping evals specifically need
A Pytest fixture (or a JUnit TestWatcher extension) that automatically records the model version, temperature, prompt version, and timestamp alongside every result is exactly the kind of cross-cutting concern this tooling was built for — and it's what makes results reproducible later (Chapter 1).
Technique 5 — Keep the CI integration you already have
Because it's still fundamentally a JUnit or Pytest suite, it plugs into the CI pipeline, the JUnit-XML report format most dashboards already consume, and the failure-notification tooling your team already trusts — without standing up new infrastructure from scratch.
The one mindset shift that actually matters
In classic QA, a flaky test — one that passes sometimes and fails sometimes with unchanged code — is treated as a bug in the test to be hunted down and eliminated. In evals, some of that same pass/fail variance is not a defect to eliminate — it's the expected, inherent behavior of the system under test (Chapter 1). The job changes from "stamp out flakiness" to "measure and bound flakiness." That inversion is the single biggest adjustment a QA engineer has to make moving into evals.
3. From Scorecard to Trusted Team Practice
Chapter 3 ended by generating a scorecard: overall accuracy, rule-level precision/recall, and a consistency score. A number sitting in a log file doesn't earn anyone's trust. Here's what turns it into something a team actually watches and believes.
| Dashboard element | Why it matters |
|---|---|
| Current score per metric | The baseline "where do we stand right now" view |
| Per-category / per-rule breakdown | An overall average can stay flat while one rule (e.g. R8's DTI check) silently drops from 95% to 60% recall — only a breakdown catches that |
| Trend lines over time / versions | Turns "we think the new version is better" into a chart anyone can look at |
| Confidence context, not false precision | "87% ± 4% over 50 runs" is honest; a bare "87%" implies certainty the sample size doesn't support |
| Comparison against a baseline | Most decisions are relative — "is this better or worse than what's live" — not about hitting an abstract ideal |
| Drill-down to actual failing runs | A number tells you that something regressed; the transcript tells you why |
Setting thresholds
- Different rules deserve different bars. A rule tied to compliance or safety exposure — citizenship eligibility, consent, bankruptcy — deserves a much higher required recall than a cosmetic completeness check.
- Two tiers: hard gates and soft thresholds. A hard gate automatically blocks a merge/release when a critical metric falls below its bar; a soft threshold flags a metric for human review without blocking anything.
- Check against the noise floor before calling something a regression. A 3-point drop might be real or might be well within normal run-to-run spread — you need to know your metric's typical noise level first.
- This is structurally closer to an SLO than to a pass/fail gate. Site reliability engineering doesn't demand 100% uptime; it sets a target and an error budget, then treats sustained drift as the trigger for action.
What "good enough to ship" actually means for an AI agent
This is not "100% pass rate" — that's a classic-QA instinct that doesn't transfer.
- It's a risk-calibrated band, not one global number.
- It's usually relative to a baseline, not an absolute ideal — "at least as good as what's already shipping" (non-regression).
- It's never a one-time gate — it's continuously monitored after shipping, because the underlying model can drift with zero code changes on your end (Chapter 1).
- It needs an owner and a review process, not just a metric. A team trusts a scorecard once there's a known answer to "who looks at this when a metric turns red, how often, and what happens next."
| Metric | Hard gate (blocks shipping) | Soft threshold (flags for review) |
|---|---|---|
| Rule-level recall on compliance-sensitive rules (R4, R10, R13, R20) | ≥ 99% | — |
| Overall eligibility-verdict accuracy | ≥ 95% | ≥ 98% target |
| Rule-level recall, all other rules | ≥ 90% | ≥ 95% target |
| Self-consistency (agreement across 5 runs, per case) | ≥ 85% | ≥ 95% target |
| Change vs. last shipped version, any metric above | No drop greater than the measured noise floor | Any drop at all, even within noise |
Notice the asymmetry: rules with real business/regulatory consequences get a near-100% hard gate; everything else gets a looser bar with a softer, review-triggering threshold.
4. Key Takeaways
- Classic QA is built on one assumption — same input, unchanged code, same output — that simply does not hold for LLM-based agents.
- The fix isn't to discard classic QA: deterministic code still deserves classic unit tests; evals are for the probabilistic, judgment-driven layer on top of it.
- JUnit and Pytest remain useful for evals — parameterization, repetition, fixtures, and CI integration all transfer directly. What changes is asserting on a threshold/statistic across many runs instead of an exact match.
- The biggest mindset shift: a classic flaky test is a bug to eliminate; eval variance is expected behavior to measure and bound, not stamp out.
- A trustworthy dashboard needs per-category breakdowns, trend lines, confidence context, baseline comparisons, and drill-down to real examples.
- Thresholds work best as tiered, risk-calibrated SLO-style bars checked against a known noise floor — not one blanket pass/fail line.
- "Good enough to ship" is a risk-calibrated band relative to a baseline, continuously monitored after shipping — never a one-time 100%-pass gate.
5. Glossary
| Term | Meaning |
|---|---|
| Flaky test | In classic QA, a test that passes or fails inconsistently with unchanged code — treated as a defect in the test itself. |
| Noise floor | The normal amount an eval metric moves run-to-run even with no real change. |
| Hard gate | A threshold that automatically blocks a merge/release when crossed. |
| Soft threshold | A threshold that flags a metric for human review without blocking anything. |
| SLO (Service Level Objective) | A target reliability bar paired with an error budget, rather than a demand for zero failures. |
| Ship gate | The specific set of metrics and thresholds a team agrees must be cleared before a given agent/model version is allowed to ship. |
| Regression (eval sense) | A statistically meaningful drop in a score across runs — distinct from "a test that used to pass now fails." |
This is Chapter 4 in the Eval Basics series. Next: the common pitfalls that quietly invalidate an eval even when the dashboard looks healthy — overfitting to your own Golden Dataset, Goodhart's Law, and how to keep a benchmark honest. — continue to Chapter 5: Goodhart's Law →