Halmurat T.
Halmurat T.

Senior SDET

Home Blog Books ask About

The Dispatch

Weekly QA notes from the trenches.

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.

© 2026 Halmurat T.

Automation 28
  • Selenium
  • Playwright
  • Appium
  • Cypress
AI Testing 67
CI/CD 12
  • GitHub Actions
  • Slack Reporting
QA Strategy 10
Case Studies 8
Blog/AI Testing
AI TestingHalmurat T./July 16, 2026/13 min

Mocked AI Tests Pass. That's How Model Drift Ships.

Filed underai-testing/llm/playwright/cicd/test-generation
Mocked AI Tests Pass. That's How Model Drift Ships.

Table of Contents
  • Why do mocked AI tests miss real regressions?
  • What should a drift gate actually check?
  • How do you assert on non-deterministic output without flaking?
  • Assert the structural contract first
  • Then grade the meaning, don’t match it
  • Where does this run — and why not in the blocking pipeline?
  • What this caught in production
  • What I’d do differently

On this page

  • Why do mocked AI tests miss real regressions?
  • What should a drift gate actually check?
  • How do you assert on non-deterministic output without flaking?
  • Assert the structural contract first
  • Then grade the meaning, don’t match it
  • Where does this run — and why not in the blocking pipeline?
  • What this caught in production
  • What I’d do differently

Your AI feature has a summarizer, a suggest-a-reply box, or a support chatbot. Its Playwright suite is green, and it’s green because you mocked the model — you stubbed the provider response so the test is deterministic and fast. That mock is correct engineering. It’s also the reason you will find out about a quality regression from a customer instead of from CI. The provider can swap the model behind the same version string, your mock will never notice, and your suite will keep reporting a feature that no longer works the way it did last week.

Here’s the whole argument: mocking the LLM is the right call for your blocking CI gate, but it can only ever test your code — the plumbing, the parsing, the error states. It cannot test the thing your feature actually depends on, which is the model’s behavior. You need a second, separate suite that hits the real model on a schedule, scores the output semantically, and alerts — not blocks — when quality drifts. This post is how to build that second gate with Playwright.

[ NOTE ]

This assumes you’ve already mocked the provider in your main suite — if you haven’t, start with mocking LLM responses in Playwright CI first. The drift gate is the complement to that mock, not a replacement.

Why do mocked AI tests miss real regressions?

Because a mock freezes the one variable your feature can’t control. When you stub the provider, every assertion downstream is really an assertion about your parsing and rendering code against a response you wrote in 2026. The model that ships that response in production is a moving target, and your mock is a photograph of it.

This isn’t hypothetical. The most-cited measurement of it is the Stanford/Berkeley study by Chen, Zaharia, and Zou, which evaluated the same GPT-4 endpoint three months apart: its accuracy at identifying prime numbers fell from 84% in March 2023 to 51% in June 2023 — same API, same version string, a 33-point drop in a single capability. The paper’s own conclusion was that teams need to “continuously monitor” model behavior, because point-in-time validation tells you nothing about next month.

§ What silent drift looks like

84% → 51%

GPT-4 accuracy

Same endpoint, 3 months apart

26%

Teams hit by flakiness

Up from 10% in 2022

0

Mocks that caught it

A stub can't see the model

And it’s worse than “the model changed,” because the model isn’t even stable within a single day. Setting temperature: 0 does not buy you determinism: as Thinking Machines Lab demonstrated in 2025, the real culprit is batch-size-dependent reduction kernels on the inference server — the same prompt dispatched under a different dynamic batch size runs its floating-point reductions in a different order and can return a different token. So your feature’s real backend is non-deterministic and silently versioned. A string-equality assertion against that is a test that will lie to you.

What should a drift gate actually check?

It should answer one question your mocked suite can’t: does the live model still produce output good enough for this feature? Not “is the output byte-identical to a golden string” — that assertion is guaranteed to flake against a real model. The gate checks whether the output still means the right thing and still satisfies the structural contract your UI depends on.

That split isn’t cosmetic — flakiness is already the dominant tax on test suites, with the share of teams reporting flaky tests climbing from 10% in 2022 to 26% in 2025 across millions of analyzed builds. Force a real model into an exact-match assertion and you’re volunteering to grow that number. The split keeps the fuzzy part scored and the strict part strict:

Deterministic contract checks (cheap, strict)

Structure you can assert exactly: valid JSON, required fields present, a summary under 280 characters, no leaked system prompt, a citation array with at least one entry. These are free, fast, and never flaky. Run them first and fail hard.

Semantic quality checks (fuzzy, scored)

Meaning you can only assert approximately: is the summary faithful to the source, does the suggested reply address the customer’s question, is the tone on-brand. You score these, you don’t equality-check them.

The mistake I see teams make is trying to force the second kind into the first — writing expect(summary).toContain("refund") and then chasing the flake for a month. Don’t. Score it.

How do you assert on non-deterministic output without flaking?

Use a graded assertion with a threshold, not an exact match. Two techniques carry the load: embedding similarity for “is this close to a known-good answer,” and an LLM-as-judge rubric for “is this actually good.” Industry eval guides converge on running both — semantic similarity against reference answers catches meaning-level failures cheaply, and an LLM judge catches the quality regressions before they reach users.

Assert the structural contract first

Start with the deterministic contract, because it’s the part that should block:

tests/drift/contract.ts
import { z } from 'zod';
// The shape your UI actually renders — assert it exactly, always.
export const SummarySchema = z.object({
summary: z.string().max(280),
citations: z.array(z.string()).min(1),
// A silent model swap loves to drop this field entirely
confidence: z.number().min(0).max(1),
});
export function assertContract(raw: string) {
return SummarySchema.parse(JSON.parse(raw)); // throws on drift in shape
}

Then grade the meaning, don’t match it

Then the graded layer. Keep the golden set small and representative — the queries that have burned you before, not a thousand random ones. A curated golden subset your team trusts is worth more than volume; it’s the subset used specifically for gating and regression detection.

tests/drift/score.ts
import { cosineSimilarity, embed } from './embeddings';
// Fuzzy match against a known-good reference, not the raw string.
export async function semanticScore(actual: string, reference: string) {
const [a, b] = await Promise.all([embed(actual), embed(reference)]);
return cosineSimilarity(a, b); // 0..1
}
export async function judgeFaithfulness(source: string, summary: string) {
// Rubric prompt: score 1-5, MUST cite a span from source for any claim.
// Validate the judge against human labels before you trust it.
const verdict = await runJudge({ source, summary });
return verdict.score; // 1..5
}
[ WARNING ]

Validate the judge before you rely on it. A judge model that agrees with your human labels only 60% of the time is a random-number generator with a PhD. The rule of thumb from the eval community is 75–90% agreement with human raters before you let a judge gate anything.

Now the Playwright test reads like a threshold check, and thresholds are what make it survive real non-determinism:

tests/drift/summary.drift.spec.ts
import { test, expect } from '@playwright/test';
import { golden } from './golden-set';
import { assertContract, semanticScore, judgeFaithfulness } from './score';
for (const item of golden) {
test(`drift: ${item.id}`, async ({ request }) => {
const raw = await callLiveFeature(request, item.input);
const parsed = assertContract(raw); // hard fail on shape
const sim = await semanticScore(parsed.summary, item.reference);
const faith = await judgeFaithfulness(item.source, parsed.summary);
expect(sim, 'semantic drift from reference').toBeGreaterThan(0.82);
expect(faith, 'faithfulness regression').toBeGreaterThanOrEqual(4);
});
}

The thresholds (0.82, 4) are not magic numbers — you set them by running the gate against the current model for a week and taking the floor of the observed range, minus a margin. That baseline is your regression detector.

Where does this run — and why not in the blocking pipeline?

It runs on a schedule, in its own Playwright project, and it alerts instead of blocking. This is the part people get wrong: they wire the real-model eval into the PR gate, it flakes because the model is non-deterministic, everyone adds retry: 3, and within a month the gate is ignored. A drift check that blocks merges will be disabled by your own team. A drift check that pings Slack at 6 a.m. with “faithfulness dropped from 4.6 to 3.1 overnight” gets read.

You don’t need a new tool for the wiring — Playwright’s custom reporter API already gives you structured pass/fail and per-test scores you can post to a dashboard or Slack. Isolate it as a separate project so it never touches PR runs:

playwright.config.ts
export default defineConfig({
projects: [
{ name: 'e2e', testDir: 'tests/e2e' }, // mocked, blocks PRs
{
name: 'drift',
testDir: 'tests/drift', // real model, scheduled
retries: 0, // NEVER retry a drift eval
fullyParallel: false, // rate limits are real
},
],
});
[ TIP ]

retries: 0 is deliberate. A retry on a drift eval hides exactly the signal you built the gate to catch — if the real model gives a bad answer once in three tries, that is the regression. Retrying it launders drift into a green check.

Then schedule it, cap the spend, and route the result to a human:

.github/workflows/drift-gate.yml
on:
schedule:
- cron: '0 6 * * *' # 6 a.m. daily, off the PR path
workflow_dispatch: {}
jobs:
drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npx playwright test --project=drift --reporter=json
# Post a digest to Slack; do NOT fail the branch protection
[ WARNING ]

Put a hard budget cap on the job. A golden set of 40 items times two model calls each (feature + judge) is 80 real calls a day — trivial. But a for loop over an accidentally-unbounded dataset against a frontier model is how you explain a surprise invoice to finance. Cap the item count and the token budget in code, not in good intentions.

What this caught in production

At a fintech I worked with, the “explain this transaction” feature summarized card charges for a support console. The mocked suite was green for eleven straight weeks. Then the daily drift job flagged a faithfulness drop overnight — the live model had started appending a cheerful “Let me know if you have questions!” to every summary, which blew past the 280-character contract on long transactions and, worse, occasionally invented a merchant category the source data never contained. No code had changed on our side. The provider had rolled a new checkpoint under the same model name.

Because the gate alerted instead of blocking, nobody’s Friday deploy was held hostage. We pinned to the dated model snapshot, tightened the faithfulness rubric, and shipped a prompt fix by Monday — before it reached the support team’s queue. That’s the entire value proposition: the drift gate converts a customer-reported incident into a 6 a.m. Slack message. This is the same failure mode I unpacked in the enterprise AI version gap, just caught on the way in instead of after the fact.

What I’d do differently

I’d build the golden set from real traces on day one instead of writing synthetic examples. The synthetic golden set we started with was too clean — it never included the messy, multi-currency, partially-refunded transactions that actually broke. Once we seeded the golden set from production traces (redacted — see testing LLM features in Playwright for how we scrub PII before it leaves the boundary), the gate started catching drift that mattered instead of drift on inputs no user ever sends.

Your one concrete next step: pick your single most important AI feature, curate ten golden inputs from real traffic, and stand up the deterministic contract check only. Not the semantic layer, not the judge — just the schema assertion running daily against the real model. That alone will catch the class of drift where the provider quietly drops a field or blows a length limit, and you’ll have the skeleton to add semantic scoring next week.

§ Frequently Asked FAQ
+ Should the drift gate block merges or PRs?

No. Run it on a schedule and have it alert, not block. A real-model eval is non-deterministic, so a blocking gate will flake, teams will pile on retries, and the gate becomes noise everyone routes to trash. An alert-only gate keeps the signal trustworthy and keeps your deploys unblocked.

+ Doesn't temperature 0 make the output deterministic enough to string-match?

No. Temperature 0 controls sampling, not inference determinism. As Thinking Machines showed, batch-size-dependent reduction kernels mean the same prompt can return different tokens run to run. Assert on semantic thresholds and structural contracts, never exact strings.

+ How is this different from just mocking the LLM in CI?

Mocking tests your code — parsing, rendering, error handling — against a frozen response. The drift gate tests the live model’s behavior, which the mock cannot see. You need both: the mock as your fast blocking gate, the drift gate as a scheduled quality alarm.

+ How big should the golden set be?

Small and representative beats large and random. Ten to forty curated inputs sourced from real traffic — especially the queries that have broken before — is enough to gate on. Volume raises cost and flakiness without improving regression detection.

+ Can I use an LLM to judge the output quality?

Yes, but validate the judge against human labels first — aim for 75–90% agreement before you let it gate anything. Pair the judge with cheaper embedding-similarity scoring and deterministic schema checks so you’re not leaning on a single fuzzy signal.

§ Further Reading 03 of 03
01AI Testing

Don't Let AI Pick Which Playwright Tests to Skip

Playwright's --only-changed and an LLM both look like smart test selection. One silently skips the spec that breaks. Here's the layered AI approach I trust.

Read →
02AI Testing

A Correct RAG Answer Can Still Be Hallucinated

Your RAG feature returned the right answer and a green Playwright test — but nothing proved it came from what it retrieved. Here's how to test grounding.

Read →
03AI Testing

Your Grounding Tests Pass. Retrieval Recall Dropped.

You upgraded the embedding model, your RAG grounding tests stayed green, and retrieval recall quietly dropped. Here's the recall@k gate that catches it.

Read →

Don't miss a thing

Subscribe to get updates straight to your inbox.

HT

No spam · Unsubscribe anytime

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.

§ Colophon

Halmurat T. — Senior SDET writing about test automation, CI/CD, and QA strategy from 10+ years in the enterprise trenches.

Set in
IBM Plex Sans, Lora, and IBM Plex Mono.
Built with
Astro, MDX, Tailwind CSS & Expressive Code. Served by Vercel.
Privacy
No cookies. No tracking scripts on the main thread — analytics run sandboxed via Partytown.
Source
github.com/Halmurat-Uyghur
Terminal
Try /ask to query Halmurat's notes in a shell prompt.

© 2026 Halmurat T. · Written in plain text, shipped in plain time.

Search
Esc

Search is not available in dev mode.

Run npm run build then npm run preview:local to test search locally.