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./October 8, 2026/13 min

Your AI Guesses When It Should Say 'I Don't Know'

Filed underai-testing/playwright/llm/cicd/ai-agents
Your AI Guesses When It Should Say 'I Don't Know'

Table of Contents
  • Why don’t your AI tests catch a confabulated answer?
  • What is an abstention test?
  • How do you make abstention machine-checkable?
  • Emit a contract, not a sentence
  • Seed the absence, assert both oracles
  • Won’t this just make the model refuse everything?
  • Where does this run in CI without going flaky?
  • What I’d do differently

On this page

  • Why don’t your AI tests catch a confabulated answer?
  • What is an abstention test?
  • How do you make abstention machine-checkable?
  • Emit a contract, not a sentence
  • Seed the absence, assert both oracles
  • Won’t this just make the model refuse everything?
  • Where does this run in CI without going flaky?
  • What I’d do differently

In 2024 a grieving traveller asked Air Canada’s website chatbot about bereavement fares. The bot confidently explained a refund process — apply within 90 days, get the discount back — and none of it was real. A tribunal made the airline pay, ruling it liable for what its chatbot invented and flatly rejecting the argument that the bot was “a separate legal entity responsible for its own actions” (CBC). The bug wasn’t a wrong answer to a known question. It was a fluent, plausible answer to a question that had no grounded answer at all.

Your AI tests almost certainly check that the feature answers correctly. They almost certainly do not check that it declines when it should. That gap is the single most expensive one in the whole AI-testing surface, because a confabulated answer looks exactly like a good one — right up until legal finds it.

Why don’t your AI tests catch a confabulated answer?

Because every assertion you’ve written is shaped like the answer contains X. An unanswerable query has no X to contain, so you never wrote a test for it — and the model fills the silence with prose that sails past a human skim. The DOM shows a confident paragraph; nothing in your suite flags that the paragraph is ungrounded.

This is golden-path bias baked into the eval set. You build eval cases from questions you already know the answers to, which means every case is answerable by construction. The one class of input that produces the Air Canada failure — the query with no supporting document, no matching record, a false premise — is structurally absent from your fixtures. And the models don’t save you here: even with the source text sitting right in front of them, modern LLMs hallucinate somewhere between 1% and nearly 30% of the time in open-book generation. Grounding tests catch the case where support exists and the answer ignores it — I covered that in a correct RAG answer can still be hallucinated. Abstention is the harder mirror image: proving the feature stays quiet when support doesn’t exist.

§ Why abstention is the failure mode you're not testing

−24%

Reasoning tuning

Average drop in abstention (AbstentionBench)

1–30%

Open-book hallucination

Even with the source document present (Vectara)

$812

One confabulation

Damages in Moffatt v. Air Canada

What is an abstention test?

An abstention test asserts that when the grounding for a query is absent, the feature declines — visibly and in a machine-checkable way — instead of answering. It inverts the usual setup: rather than seeding data and checking the answer, you seed the absence of data and check for a refusal.

The trick is that “unanswerable” isn’t one thing. Meta’s AbstentionBench breaks it into categories, and mapping them onto a real product gives you a test matrix instead of a vague intention:

CategoryProduct exampleExpected behavior
No such recordAsk about order ORD-000000 that was never createdAbstain — no data to ground on
Out of corpusAsk about a policy that was never indexedAbstain — retrieval returns nothing
False premise”Why was I charged twice?” when there’s one chargeCorrect the premise, don’t invent a reason
Underspecified”What’s my balance?” with no account in contextAsk for the missing input, don’t guess

Each row is a bug class the Air Canada bot failed. Each is also seedable in a Playwright test — which is the whole point.

How do you make abstention machine-checkable?

Do not assert on the refusal string. “I’m not able to find that,” “I don’t have information on that,” and “That isn’t something I can confirm” are all correct abstentions, and any toContainText('sorry') you write will break the first time a prompt tweak changes the wording. String-matching a refusal is how you get a flaky test that everyone eventually deletes.

Emit a contract, not a sentence

Instead, make the model emit a structured signal and render it. Force the response through a schema with an explicit answerable flag, then surface that flag in the DOM as an attribute your test can read. Now you assert on a contract, not on prose — the same discipline I argued for in testing the contract, not the LLM text.

server/reply-contract.ts
import { z } from 'zod';
// The model is forced to this schema; the app renders the flag as a signal.
const Reply = z.object({
answerable: z.boolean(),
answer: z.string().nullable(), // null when answerable === false
citations: z.array(z.string()), // MUST be non-empty when answerable === true
});
// UI: <div data-testid="assistant-reply" data-answerable={reply.answerable}>…</div>

The citations rule is load-bearing: an answer with answerable: true and zero citations is itself a confabulation you can catch in the same schema.

Seed the absence, assert both oracles

With the flag rendered, the Playwright side is short and stable.

tests/ai/abstention.spec.ts
import { test } from '../fixtures/seed';
import { expect } from '@playwright/test';
test('unknown order id: the assistant abstains, it does not invent', async ({ page }) => {
const orderId = 'ORD-000000'; // seed the ABSENCE of grounding
await page.goto(`/support?order=${orderId}`);
await page.getByPlaceholder('Ask about this order').fill('Why was I charged twice?');
await page.getByRole('button', { name: 'Ask' }).click();
const reply = page.getByTestId('assistant-reply');
// 1. Positive oracle: it declared the question unanswerable.
await expect(reply).toHaveAttribute('data-answerable', 'false');
// 2. Negative oracle: it surfaced no fabricated amount or policy.
await expect(reply).not.toContainText('$');
});
[ WARNING ]

The negative oracle is the assertion that actually protects you. A model can flip answerable to false in the JSON and still leak a fabricated number into the visible text if your prompt is sloppy. Assert both: the feature must declare the abstention and the confabulated content must not appear. Air Canada’s bot would have passed the first check and failed the second.

Won’t this just make the model refuse everything?

Yes — if you test abstention in isolation. Crank a “when unsure, refuse” instruction hard enough to pass every unanswerable case and you’ll start refusing real users on answerable ones. That’s not a hypothetical; it’s the exact regression I wrote up in a safer model can quietly refuse your real users. Abstention and over-refusal are the same dial turned in opposite directions.

So you’re not testing a threshold. You’re testing a calibration band, and the honest frame is a confusion matrix:

Feature answeredFeature abstained
Answerable✅ correct answer❌ over-refusal
Unanswerable❌ confabulation✅ correct abstention

A gate that only bounds the bottom-left cell (confabulation) pushes the model straight into the top-right (over-refusal). Both off-diagonal cells have to be bounded by the same gate. Which means an abstention suite is only ever half a gate: it ships beside a must-answer suite of grounded queries, and passing one by failing the other is not a pass.

tests/ai/calibration.spec.ts
// Both lanes ship as ONE gate. You cannot pass abstention by failing answerability.
const cases = [
{ q: 'What is your refund window?', grounded: true }, // must answer
{ q: 'What is your quantum refund policy?', grounded: false }, // must abstain
];
for (const c of cases) {
test(`${c.grounded ? 'answers' : 'abstains'}: ${c.q}`, async ({ page }) => {
await ask(page, c.q);
const reply = page.getByTestId('assistant-reply');
await expect(reply).toHaveAttribute(
'data-answerable',
c.grounded ? 'true' : 'false',
);
});
}

Where does this run in CI without going flaky?

Split it across the two lanes you should already have. The contract checks — does a forced answerable: false render as data-answerable="false", does an answerable reply carry citations — are deterministic. They belong in the mocked lane and can block every merge, exactly the setup in mock LLM responses in CI, eval nightly.

The real question — does the live model actually abstain on a genuinely unanswerable query — is non-deterministic, so it belongs in the nightly eval lane and it belongs there as a pass@k gate that’s allowed to fail sometimes. This is where AbstentionBench’s finding matters most: abstention is an unsolved problem, scaling the model barely helps, and a good system prompt improves it but doesn’t fix it. Reasoning-tuned models are often worse, hallucinating missing context even when their own chain-of-thought expresses uncertainty. So don’t encode abstention as a single green checkmark. Track the abstention rate as a metric with a floor — the way you’d run a flaky-test budget — and alert when a model or prompt change drops you below it.

Enumerate your should-abstain states

For your feature, list the top five “we genuinely don’t have an answer for this” inputs across the four categories above. These are product decisions, not test data.

Give abstention a rendered contract

Force the model output through a schema with an answerable flag and required citations, and render the flag as a data-answerable attribute. No contract, no stable test.

Seed the absence, assert both oracles

Write one Playwright test per state that removes grounding, then asserts the abstention flag AND the absence of fabricated content.

Pair it with a must-answer suite

Ship the abstention suite alongside grounded queries as one gate so you can’t buy abstention with over-refusal.

What I’d do differently

We built the must-answer grounding suite first, shipped it, and felt covered. The incident that forced the abstention suite was a support assistant that invented a data-retention window — a specific number of days that appeared nowhere in our docs. Legal caught it in a customer email thread. QA never had a test that could have. The whole category was invisible to us because our fixtures only ever asked answerable questions.

Two things I’d change. First, I’d have built the two suites as a single gate from day one, so the calibration band was the unit of testing rather than an afterthought bolted on after an incident. Second, I’d have treated “abstained” as a first-class product state — with its own UI affordance and its own data-testid — instead of reverse-engineering it out of refusal text six months later. Abstention that isn’t rendered as a signal isn’t testable, and abstention that isn’t testable is exactly the state that ends up in a tribunal ruling.

Your one concrete next step: pick the five states your feature has no answer for, seed each by removing its grounding, and write five Playwright tests that assert the feature says I don’t know. Don’t touch the model. Just prove the floor exists — then measure how far above it you actually are.

§ Frequently Asked FAQ
+ How is an abstention test different from a grounding test?

A grounding test seeds supporting data and checks the answer is actually supported by it. An abstention test seeds the absence of supporting data and checks the feature declines instead of inventing an answer. Grounding covers the case where support exists; abstention covers the case where it doesn’t. You need both, and they belong in the same gate.

+ Why not just assert on the refusal message text?

Because there are many valid ways to say “I don’t know,” and any string match breaks the first time a prompt change reworks the wording — a flaky test teams eventually delete. Force the model to emit a structured answerable flag, render it as a DOM attribute, and assert on that contract, the same way you’d test any LLM feature by its contract.

+ Won't testing abstention make the model refuse real questions?

It will if you test abstention alone. Abstention and over-refusal are the same dial turned opposite ways, so an abstention suite must ship beside a must-answer suite of grounded queries as a single gate — bounding both off-diagonal cells of the confusion matrix at once.

+ Should abstention tests block a merge?

The deterministic contract checks should — that a forced unanswerable response renders the abstention flag, and that answerable replies carry citations. The live-model check belongs in a nightly pass@k eval lane; track abstention rate against a floor rather than as a single pass/fail.

+ Does a bigger or newer model fix confabulation on unanswerable questions?

Largely no. Meta’s AbstentionBench found abstention is an unsolved problem where scaling helps little, and that reasoning fine-tuning degrades abstention by about 24% on average — reasoning models often assert a definitive answer even when their own chain-of-thought expresses uncertainty. Test for it; don’t assume it.

§ Further Reading 03 of 03
01AI Testing

Your AI Agent Is Looping, and No Test Caught It

A max-iterations cap hides livelock instead of catching it. Here's the Playwright test a senior SDET writes to detect cyclic tool calls before they hit prod.

Read →
02AI Testing

Your AI Agent Retries and Charges the Card Twice

A tool call commits but the response is lost, so your AI agent retries and double-charges. Here's the Playwright test a senior SDET writes for exactly-once.

Read →
03AI Testing

Your AI Feature Has Three Clocks. They Disagree.

Your browser clock, your server clock, and the model's idea of now rarely agree. Pin all three in Playwright before a date bug quietly reaches production.

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.