BXUP Track progress & test yourself — free
QA & Testing · Hands-on lab · ~5 min read
QA & TESTING · HANDS-ON LAB

Lab 11 · First LLM-Feature Test Suite

Take a real summarization feature, prove to yourself with your own runs why expect(output).toBe(x) is dead on arrival — run-to-run variance is the seeded lesson — then build the layered suite that works: deterministic format checks, semantic similarity, a judge only where nothing cheaper suffices, all wrapped in repeat-run statistical gates that pass reliably.

Sara is an SDET taking her first step into AI evaluation engineering, and the first wall every tester hits is the same: the feature returns a different string every run, and the entire assertion toolbox assumes it won't. The wrong responses to that wall are giving up on testing ("it's non-deterministic, we'll eyeball it") or over-reaching for an LLM judge on every assertion (slow, costly, and itself non-deterministic). The right response is the layered hierarchy — pin everything that is deterministic, measure meaning where wording varies, and spend judge calls only on qualities nothing cheaper can see — with pass criteria defined statistically over N runs, not existentially over one.

Time, tier & cost

~90 minutes hands-on · Core tier (maps to module M6 and playbooks PB-34/PB-35). Cost: $0 with Ollama local; ≤$2 via a hosted API.

Setup

mkdir -p qa-lab-11 && cd qa-lab-11
python3 -m venv .venv && source .venv/bin/activate
pip install pytest requests sentence-transformers
ollama pull llama3.1:8b        # or set an API key for a hosted model
npm install -g promptfoo       # optional path for step 7

feature.py — the feature under test (a support-ticket summarizer):

import requests, json

PROMPT = """Summarize this support ticket for the engineering triage board.
Rules: max 40 words; must state the affected feature; must state customer
impact; must not include the customer's name or email; end with a severity
guess formatted exactly as [SEV:high|medium|low].

Ticket:
{ticket}"""

def summarize(ticket: str, temperature: float = 0.7) -> str:
    r = requests.post("http://localhost:11434/api/generate", json={
        "model": "llama3.1:8b",
        "prompt": PROMPT.format(ticket=ticket),
        "stream": False,
        "options": {"temperature": temperature},
    })
    return r.json()["response"].strip()

tickets.py — the golden inputs with expected properties (not expected strings):

TICKETS = [
  {
    "id": "T1",
    "text": ("From: meera.k@bigcorp.example — Checkout fails for our whole "
             "team since 9am. Card page throws error after OTP. We process "
             "300 orders/day and are fully blocked. Please escalate."),
    "must_mention": ["checkout"],
    "must_not_mention": ["meera", "bigcorp", "meera.k@bigcorp.example"],
    "expected_sev": "high",
    "reference": ("Checkout is failing after OTP entry, fully blocking a "
                  "customer who processes 300 orders daily. [SEV:high]"),
  },
  {
    "id": "T2",
    "text": ("From: dev@tinyshop.example — The CSV export of last month's "
             "invoices has the date column in US format instead of ISO. Not "
             "urgent, we reformat manually, but please fix eventually."),
    "must_mention": ["export"],
    "must_not_mention": ["tinyshop", "dev@tinyshop.example"],
    "expected_sev": "low",
    "reference": ("Invoice CSV export uses US date format instead of ISO; "
                  "customer has a manual workaround. [SEV:low]"),
  },
]

Steps

  1. Prove the exact-match failure with data, not doctrine. Write explore.py: call summarize(TICKETS[0]["text"]) five times at temperature 0.7, print all five outputs and whether any two are byte-identical. Then run five more at temperature 0. Record both results in rationale.md. Expected: zero identical pairs at 0.7; at temperature 0 you may see identical or near-identical outputs — and you must not rely on it, because provider-side changes, hardware nondeterminism, and model updates break byte-stability even at temp 0. One paragraph: why assert output == expected has a false-failure rate near 100% here.
  2. Name the flakiness-vs-variance distinction. In rationale.md, write the two definitions in your own words: flakiness is the same system giving different pass/fail results for reasons unrelated to quality (bad test); variance is the system legitimately producing different outputs of equivalent quality (property of the feature — the test must assert over the distribution, not the instance). Every design choice below hangs off this distinction.
  3. Build Layer 1 — deterministic checks. In test_layer1.py, write pytest tests that must hold on every single output with zero tolerance: word count ≤ 40 (the spec says so); the severity tag present and exactly matching \[SEV:(high|medium|low)\]$; none of the must_not_mention strings present case-insensitively (the PII rule); non-empty output. These are cheap, instant, and non-negotiable — a PII leak in 1 of 20 runs is a failed feature, not variance. Run each test over 5 samples per ticket with the rule: any Layer-1 failure in any sample fails the test.
  4. Build Layer 2 — semantic similarity. In test_layer2.py, embed each output and its reference with sentence-transformers (all-MiniLM-L6-v2) and assert cosine similarity above a threshold. Calibrate the threshold empirically, not by folklore: compute similarity for 5 good outputs (from step 1) and for two deliberately bad ones you write by hand — a summary of the wrong ticket, and a fluent-but-vacuous "A customer reported an issue. [SEV:medium]". Set the threshold to separate the clusters (typically lands around 0.55–0.70 for this model; yours may differ — the method is the skill). Record the calibration table in rationale.md.
  5. Build Layer 3 — judge, narrowly. Only two qualities here genuinely need a judge: "states customer impact" and "severity guess is appropriate to the ticket." In test_layer3.py, call the model as judge with a binary rubric per quality ("Answer PASS or FAIL only: does this summary state the customer impact? Summary: ..."), and assert on the parsed verdict. Keep the judge prompt output-constrained and log raw judge responses to a file — you will thank yourself when a verdict looks wrong. Note in rationale.md why coherence/fluency did not get a judge: Layer 2 already correlates with it, and judges cost latency and money per assertion.
  6. Wrap it in statistical gates. Refactor into test_suite.py with an N-run harness: for each ticket, generate N=5 outputs once (a session-scoped fixture — do not re-call the model per assertion), then apply: Layer 1 must pass 5/5 (zero-tolerance), Layer 2 must pass ≥4/5, Layer 3 must pass ≥4/5. Justify each threshold in rationale.md: which failures indicate a broken feature at any frequency, and which indicate acceptable tail variance. Run the full suite three times; all three runs must be green — a gate that flakes across suite-runs has its thresholds set on the noise floor.
  7. Port the suite shape to Promptfoo (optional but recommended). Express the same design declaratively in promptfooconfig.yaml: the prompt, the Ollama provider, per-ticket tests with contains/regex/not-contains asserts (Layer 1), a similar assert with your calibrated threshold (Layer 2), and an llm-rubric assert (Layer 3), with repeat: 5. Run promptfoo eval and confirm it agrees with pytest. Knowing both idioms — pytest-native and config-native — is the employable skill.
  8. Finish the rationale. Complete rationale.md: the variance evidence, the layer table (assertion → layer → cost → tolerance → why), threshold calibration data, and the one-line summary of the hierarchy rule: cheapest sufficient layer first; zero tolerance only where any failure is a defect; statistics everywhere wording varies.
Pro Tip

The layers are ordered by cost and determinism on purpose — regex is free and never lies about what it checks, embeddings are cheap and stable, judges are expensive and need their own QA. Every assertion pushed down a layer makes the suite faster, cheaper, and less flaky; every assertion pushed up without need imports a new source of non-determinism into your gate.

Verify

  • rationale.md contains the 5+5 run evidence with the identical-pair count, and the flakiness-vs-variance definitions.
  • Layer 1 tests: word cap, severity regex, PII absence, all at 5/5 zero-tolerance.
  • Layer 2 threshold set from a recorded calibration table including the two hand-written bad outputs.
  • Layer 3 judges exactly two qualities, binary rubric, raw responses logged.
  • Full suite green three consecutive runs; model called once per ticket-batch via fixture, not per assertion.
  • Layer table in the rationale maps every assertion to layer, cost, and tolerance.

Self-grading rubric (pass = 7/10):

Dimension012
Variance evidenceAsserted from folkloreRan onceBoth temperatures, counts recorded, implication drawn
Layer disciplineJudge used everywhereLayers exist, leakyEvery assertion at cheapest sufficient layer, justified
Threshold calibrationGuessedGood outputs onlyGood + bad clusters separated, table recorded
Statistical gatesSingle-run assertsN-runs, arbitrary thresholdsN-runs, per-layer tolerance justified, 3× stable
PII zero-toleranceMissingPresent, sampledPresent, 5/5 enforced, rationale states why
▸Answer key — the seeded lesson, expected numbers, and common wrong turnsoptional deep dive — click to expand

The seeded lesson (step 1). At temperature 0.7, 5 runs of T1 produce 0 identical pairs essentially always — that is the point being planted. At temperature 0, local Ollama frequently produces byte-identical repeats, which tempts the wrong conclusion ("just pin temp 0 and exact-match"). The key facts to have written down: temp 0 reduces but does not guarantee determinism across versions, hardware, and providers; and even where it holds, exact-match makes every intentional prompt improvement a test failure — you would be pinning wording, not quality. Full credit requires the implication, not just the observation.

Expected Layer-1 findings. Llama-3.1-8B on this prompt violates the 40-word cap in roughly 1–3 of 10 runs and occasionally emits the SEV tag in a wrong format ([SEV: high] with a space, or prose like "Severity: high"). Both are feature bugs your suite should catch, not test noise — the correct response documented in the rationale is tightening the feature (lower temperature for format-critical output, or post-processing the tag), then keeping the zero-tolerance gate. If all your runs passed Layer 1, note it and move on; the gate's design is what's scored, not the model's luck. The PII check must be zero-tolerance: one leaked email in 100 runs is a compliance incident, and any suite that averages it away is wrong.

Expected Layer-2 calibration shape. Good T1 outputs vs T1 reference: cosine ≈ 0.65–0.90. The wrong-ticket bad output: ≈ 0.2–0.45. The vacuous output: ≈ 0.35–0.55 — this is the instructive one, because it sits closer to the good cluster than the wrong-ticket one and shows why the threshold must be set above the vacuous cluster, and why Layer 2 alone can't certify impact-statement quality (that's what Layer 3 is for). A threshold below 0.5 that "passes everything" is the classic wrong turn.

Expected Layer-3 behavior. The binary-rubric judge on "states customer impact" typically agrees with your eyeball on 9/10 cases; where it disagrees, the logged raw response usually reveals verbosity bias (crediting longer summaries) — which is the trailhead for Lab 13. Judging more than the two named qualities, or using a 1–10 scoring rubric here, are the flagged over-reaches: unparseable scores and threshold ambiguity are exactly what binary rubrics exist to avoid.

Gate math check. 5/5 on Layer 1 + ≥4/5 on Layers 2–3, suite run 3× green: with honest per-run pass rates around p≈0.9 for the soft layers, P(≥4/5) ≈ 0.92 per gate and the 3×-green requirement holds comfortably; if your suite flaked across runs, the recorded fix should be threshold adjustment with reasoning (or N increase), never deleting the assertion — deleting the assertion is the E2E-sleep anti-pattern reborn at the AI layer.

Key Takeaway:

Non-determinism doesn't end testing; it ends single-run string equality. The working pattern is the layered hierarchy under statistical gates — regex and PII checks at zero tolerance on every sample, calibrated semantic similarity over the distribution, and a narrowly-scoped binary judge for the qualities only judgment can see. Cheapest sufficient layer first, always.

This is the free reading view. Open BXUP to track your progress, run the hands-on labs, and prove it with original, exam-pattern assessments.
© Anvi Innovate · BXUP — Be Exponentially Upskilled · BXUP is an independent learning platform, not affiliated with or endorsed by AWS, Microsoft, Google, NVIDIA, IBM, Salesforce, Databricks, Oracle or Anthropic. All trademarks belong to their respective owners. · bxup.in