You changed the prompt, tried three examples, liked how they looked, and shipped. A week later a user reports the model now gives wrong answers on inputs none of you tried. That is vibes-based testing — eyeballing a few outputs and trusting a feeling — and it is exactly how regressions reach production undetected.
The fix is an evaluation harness: a fixed set of test cases with known-expected behaviour that you run the whole pipeline over to produce a number. 'Does it work?' becomes 'it passes 47 of 50 cases', and when you change the prompt, the model, or a guardrail, you re-run and compare. If the number drops, you broke something. If it holds, you ship with evidence. Measurement replaces vibes, and regressions stop being something a user has to report.
The golden dataset
An eval starts with a golden dataset: a list of cases, each pairing an input with the behaviour you expect. The expectation need not be an exact string. It can be a category ('this review is positive'), a property ('the answer cites a source'), or a constraint ('no secrets in the output'). What matters is that each case is fixed, written down, and agreed to represent correct behaviour — the 'golden trace' you measure against.
Building this dataset is real work, and it is the work that pays off forever after. Every tricky input a user has ever reported becomes a case; every edge case you argued about becomes a case. The dataset grows as you learn, and it never forgets a bug you have already fixed.
flowchart LR C["golden cases"] --> R["run pipeline<br/>on each case"] R --> K["checker compares<br/>actual vs expected"] K --> S["score passed / total"] S -.->|"on every change"| C style S fill:#1e7f3d,color:#fff
function extractSentiment(text) {
const t = text.toLowerCase();
if (t.indexOf('great') !== -1 || t.indexOf('love') !== -1) return 'positive';
if (t.indexOf('bad') !== -1 || t.indexOf('hate') !== -1) return 'negative';
return 'neutral';
}
const golden = [
{ input: 'I love this product', expected: 'positive' },
{ input: 'This is bad', expected: 'negative' },
{ input: 'It is a table', expected: 'neutral' }
];
const actual = extractSentiment(golden[0].input);
console.log(actual === golden[0].expected);
Checkers and the harness
For each case you need a checker: a function that takes the pipeline's actual output and the expected value and returns true or false. A checker can be as strict as exact-string equality or as loose as 'the output contains the expected substring' — choose the strictness that matches what correct means for that case.
The harness is the loop that ties it together: for every case, run the pipeline, call the checker, count the passes. It is a dozen lines of plain code, and it is the single most valuable test infrastructure an LLM application has, because it turns 'is it good?' into a number you can track over time.
function evaluate(cases, fn, checker) {
let passed = 0;
for (const c of cases) {
if (checker(fn(c.input), c.expected)) passed += 1;
}
return { passed: passed, total: cases.length };
}
function extractSentiment(text) {
const t = text.toLowerCase();
if (t.indexOf('great') !== -1 || t.indexOf('love') !== -1) return 'positive';
if (t.indexOf('bad') !== -1 || t.indexOf('hate') !== -1) return 'negative';
return 'neutral';
}
const golden = [
{ input: 'I love this', expected: 'positive' },
{ input: 'This is bad', expected: 'negative' },
{ input: 'It is a table', expected: 'neutral' }
];
const score = evaluate(golden, extractSentiment, (a, e) => a === e);
console.log(score.passed + ' of ' + score.total);
The pipeline under test can be a stub
Notice that nothing about the harness requires a real model. The function under test can be a live model call in production and a tiny deterministic stub in your tests — a function that returns canned answers from a lookup. That lets you verify the harness itself: feed it a stub you control, confirm the score is what you expect, and only then trust the number it reports for the real pipeline.
Testing the test infrastructure is not paranoia. A harness that silently miscounts gives you a confidently wrong score, which is worse than no score at all, because you stop looking for the bugs it is hiding.
From counts to a number
Once the harness counts passes, the headline metric is accuracy: passes divided by total, usually shown as a percentage. A single number is easy to track but easy to hide behind, so break it down. If your cases fall into categories — easy versus hard, short versus long, different topics — score each category separately. A pipeline at 90% overall that scores 100% on easy cases and 40% on hard ones is not really at 90%; it is failing on the cases that matter most, and only the breakdown reveals it.
Accuracy is the start of the conversation, not the end. The failing cases are the lesson: each one is a concrete input where the pipeline misbehaves, and a candidate for the golden dataset so it can never silently fail again.
function accuracy(passed, total) {
if (total === 0) return 0;
return Math.round((passed / total) * 100);
}
function extractSentiment(text) {
const t = text.toLowerCase();
if (t.indexOf('love') !== -1) return 'positive';
if (t.indexOf('bad') !== -1) return 'negative';
return 'neutral';
}
const golden = [
{ input: 'I love this', expected: 'positive' },
{ input: 'This is bad', expected: 'negative' },
{ input: 'a plain table', expected: 'neutral' },
{ input: 'I love love love it', expected: 'positive' }
];
let passed = 0;
for (const c of golden) {
if (extractSentiment(c.input) === c.expected) passed += 1;
}
console.log('accuracy: ' + accuracy(passed, golden.length) + '%');
flowchart TD A["change prompt / model / guardrail"] --> B["re-run eval suite"] B --> C["score held?"] C -->|"yes"| D["ship with evidence"] C -->|"dropped"| F["investigate failing cases"] F --> B style D fill:#1e7f3d,color:#fff style F fill:#b91c1c,color:#fff
flowchart LR S["overall 90%"] --> E["easy cases 100%"] S --> H["hard cases 40%"] style S fill:#3776ab,color:#fff style E fill:#1e7f3d,color:#fff style H fill:#b91c1c,color:#fff
What does this print? The harness counts how many cases pass the checker. Here the pipeline is just string length.
function evaluate(cases, fn, checker) {
let passed = 0;
for (const c of cases) {
if (checker(fn(c.input), c.expected)) passed += 1;
}
return passed;
}
const cases = [
{ input: 'hi', expected: 2 },
{ input: 'hello', expected: 5 },
{ input: 'ok!', expected: 3 }
];
console.log(evaluate(cases, (s) => s.length, (a, e) => a === e));
The pipeline returns the length of each input string.
Each length matches its expected value, so all three cases pass.
Write evaluate(cases, fn, checker) that returns the number of cases where checker(fn(c.input), c.expected) is truthy. cases is an array of { input, expected } objects.
function evaluate(cases, fn, checker) {
// return the count of cases whose checker(fn(input), expected) is truthy
}
Start a passed counter at 0 and loop over the cases.
Call checker(fn(c.input), c.expected) for each case; increment passed when it is truthy.
function evaluate(cases, fn, checker) {
let passed = 0;
for (const c of cases) {
if (checker(fn(c.input), c.expected)) passed += 1;
}
return passed;
}
This checker is supposed to compare the pipeline's actual output to the expected value, but it returns true no matter what — so every case 'passes' and the eval reports a fake 100%. Fix it to return true only when actual equals expected.
function checker(actual, expected) {
return true;
}
A checker that always returns true approves everything and hides every regression.
Return the result of comparing actual to expected with strict equality.
function checker(actual, expected) {
return actual === expected;
}
Write failingCases(cases, fn, checker) that returns the indices of cases where checker(fn(c.input), c.expected) is falsy. Return the indices in ascending order.
function failingCases(cases, fn, checker) {
// return indices of the cases that fail the checker
}
Loop over the cases with their index using forEach or entries.
Push the index when the checker is falsy (note the not operator).
function failingCases(cases, fn, checker) {
const failed = [];
cases.forEach((c, i) => {
if (!checker(fn(c.input), c.expected)) failed.push(i);
});
return failed;
}
Your team shipped a new prompt after trying three examples that looked good. A week later a user reports wrong answers on inputs none of you tried. What was missing?
Trying a few examples by eye is vibes-based testing: it cannot catch regressions on inputs you never tried, and it leaves no number to compare against after a change. A golden-trace eval suite scores the pipeline over fixed expected behaviour on every change, so a regression shows up as a dropped score.
Recap
- Vibes-based testing — eyeballing a few outputs — hides regressions; an eval harness replaces it with a number you can track over time.
- A golden dataset pairs each input with the behaviour you expect; it grows as you learn and never forgets a bug you have already fixed.
- A checker compares actual to expected; choose the strictness to match what 'correct' means for that case.
- The harness runs the pipeline over every case, counts passes, and reports accuracy — passes over total, ideally broken down per category.
- Beware the checker that approves everything: a fake 100% hides every regression, so sanity-check each checker against an input that should fail.
- Re-run the suite on every change — a held score ships with evidence, a dropped score points straight at the broken case.
Next you will add observability: tracing each request step by step so that when an eval fails in production, you can see exactly where the pipeline went wrong.