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 28, 2026/13 min

Your Agent Tests Pass Because Its Tools Never Fail

Filed underai-testing/ai-agents/playwright/llm/framework-design
Your Agent Tests Pass Because Its Tools Never Fail

Table of Contents
  • Why is the failed-tool path the one that actually breaks?
  • What does “recover honestly” actually mean?
  • How do you inject faults at the tool boundary in Playwright?
  • Step 1: Catalogue the faults worth injecting
  • Step 2: Build a per-test fault fixture
  • Step 3: Assert on the side effect and the narration
  • Why not just unit-test the tool wrapper’s error handling?
  • What to watch for when a fault test goes flaky
  • The one thing to do this week

On this page

  • Why is the failed-tool path the one that actually breaks?
  • What does “recover honestly” actually mean?
  • How do you inject faults at the tool boundary in Playwright?
  • Step 1: Catalogue the faults worth injecting
  • Step 2: Build a per-test fault fixture
  • Step 3: Assert on the side effect and the narration
  • Why not just unit-test the tool wrapper’s error handling?
  • What to watch for when a fault test goes flaky
  • The one thing to do this week

Here’s the uncomfortable truth about your agent test suite: every test you have runs against tools that work. The calendar API returns 200, the payment service settles, the search index answers in 40ms. So the only behavior you’ve ever verified is the happy path — the one path production is least likely to keep serving you. The interesting question isn’t “does the agent book the meeting when the API works?” It’s “what does the agent tell the user when the API times out?” And almost nobody tests that, which is exactly why agents ship a specific, dangerous bug: they narrate success over a failed tool call.

Stop testing agents against a perfect backend. Inject faults at the tool boundary — timeouts, 5xxs, malformed payloads, schema-valid-but-wrong responses — and assert that the agent recovers honestly instead of hallucinating a confirmation. That’s the test that separates a demo from a product.

Why is the failed-tool path the one that actually breaks?

Because that’s where the agent has the most freedom and the least supervision. When a tool succeeds, the model has a clean result to summarize. When a tool fails, the model has to decide — retry, pick a fallback, or tell the user it couldn’t. Left to its own devices, an LLM’s most fluent option is often to keep talking as if everything worked. That’s not a rare glitch; it’s the structural incentive of a next-token predictor rewarded for plausible-sounding replies.

The benchmarks make the failure rate concrete. On τ-bench, Sierra’s benchmark for agents operating in dynamic tool environments, even frontier models complete fewer than half of the retail and airline tasks reliably — and the consistency metric (pass^k across repeated trials) collapses far faster than the single-run pass rate, meaning agents that succeed once routinely fail the same task on a retry. The Berkeley Function-Calling Leaderboard shows the same models make measurable errors in argument construction even when the tool is available and healthy. Now degrade the tool and you’ve stacked a second failure surface on top.

§ How fragile the tool path really is

<50%

τ-bench

Reliable task completion, frontier models

pass^k

Consistency

Drops sharply vs. single-run success

57%

Orgs surveyed

Run agents in production already

I hit this head-on at a logistics client last year. Their support agent could reschedule a delivery by calling a reschedule_shipment tool. In staging, everything green. In production, the downstream scheduling service had a p99 of 9 seconds and occasionally returned a 504. When it did, the agent replied “All set — your delivery is now booked for Thursday” and called nothing else. Customers showed up Thursday to no delivery. The tool had 504’d; the agent invented the confirmation. Our entire E2E suite missed it for eight weeks because not one test had ever made that tool fail. The fix took an afternoon. The trust took a quarter to rebuild.

What does “recover honestly” actually mean?

It means the agent’s observable behavior after a tool failure matches one of three acceptable outcomes — and never the fourth. This is the contract you’re testing, and writing it down is half the battle because most teams have never specified it.

The three acceptable outcomes: retry the same tool with backoff (and eventually succeed or give up), fall back to an alternative tool or a degraded path, or surface the failure to the user with an honest, actionable message. The one unacceptable outcome — the bug — is hallucinated success: a confirmation message with no successful side effect behind it. Google’s SRE guidance on graceful degradation frames the same principle for services: a degraded-but-honest response beats a confident-but-wrong one every time. Agents need the identical discipline, and it has to be tested, not hoped for.

[ WARNING ]

The bug is not “the tool failed.” Tools fail; that’s fine. The bug is the agent claiming the action succeeded when it didn’t. Your assertion target is the gap between the agent’s narration and the actual side effect — assert that a failure in the tool produces a failure acknowledgment in the reply, not a fabricated confirmation.

This builds directly on a boundary shift I’ve argued before: assert on the tool calls your agent makes, not the text it says. Fault injection is the natural next move — once you’re watching the tool boundary, you can also control it.

How do you inject faults at the tool boundary in Playwright?

You interpose a controllable fault layer between the agent and its tools, then drive it per-test. In most architectures the agent’s tool calls surface as network requests — either directly from the browser or from a backend endpoint you can front with a proxy. Playwright’s page.route handles the browser-observable case cleanly; for server-side tools, a header-driven fault mode on a test build does the same job.

Step 1: Catalogue the faults worth injecting

Start by cataloguing the faults worth injecting. Not every failure is interesting — these five cover the classes that actually change agent behavior.

Hard error

A 500/503/504. Tests whether the agent retries or surfaces the failure.

Timeout / slow

Response delayed past the agent’s deadline. Tests deadline handling, not just error handling.

Malformed body

Non-JSON or truncated payload. Tests whether the agent validates before trusting a tool result.

Schema-valid, wrong

Well-formed but semantically wrong (empty result, wrong entity). The nastiest — no parser catches it.

Partial success

First call succeeds, retry duplicates. Tests idempotency handling in multi-step flows.

Step 2: Build a per-test fault fixture

Now the fixture. The key is a single injection point you can configure per test, so a fault is one line in the test body — not a bespoke mock per scenario.

tests/fixtures/tool-fault.ts
import { test as base } from '@playwright/test';
type Fault = 'error_504' | 'timeout' | 'malformed' | 'empty_result';
export const test = base.extend<{ injectFault: (tool: string, fault: Fault) => Promise<void> }>({
injectFault: async ({ page }, use) => {
await use(async (tool, fault) => {
// Intercept the agent's outbound tool call by tool name
await page.route(`**/api/agent/tools/${tool}`, async (route) => {
if (fault === 'timeout') return; // never fulfill — force the deadline
if (fault === 'error_504') return route.fulfill({ status: 504 });
if (fault === 'malformed') return route.fulfill({ status: 200, body: '{"trunc' });
if (fault === 'empty_result') return route.fulfill({ status: 200, json: {} });
});
});
},
});

Step 3: Assert on the side effect and the narration

With the fixture in place, the recovery test reads like a spec. Notice what it asserts on: the side effect (no shipment was actually booked) and the narration (the reply admits failure). Both must hold — that’s the honest-recovery contract.

tests/agent/reschedule-recovery.spec.ts
import { test } from '../fixtures/tool-fault';
import { expect } from '@playwright/test';
test('agent surfaces failure when reschedule tool 504s', async ({ page, injectFault }) => {
await injectFault('reschedule_shipment', 'error_504');
await sendMessage(page, 'Move my delivery to Thursday');
// 1. No fabricated confirmation — the dangerous bug
await expect(page.getByRole('status')).not.toContainText(/booked|all set|confirmed/i);
// 2. Honest acknowledgment the user can act on
await expect(page.getByRole('status')).toContainText(/couldn.t|try again|unable/i);
// 3. The side effect never happened
expect(await getBookedShipments(page)).toHaveLength(0);
});
[ TIP ]

Assert the negative first. not.toContainText(/confirmed/) is the assertion that catches the hallucinated-success bug — the one that cost my client eight weeks. If you only assert the positive (“shows an error”), a reply that says both “There was an error, but I’ve confirmed your booking!” slips through. Agents produce exactly that kind of contradiction under failure.

Why not just unit-test the tool wrapper’s error handling?

Because the tool wrapper isn’t where the decision lives — the model is. You can prove your reschedule_shipment client throws cleanly on a 504 all day long, and the agent will still hallucinate a confirmation, because the model is the component that decides what to do with that thrown error. The whole point of an agentic system is that control flow runs through a probabilistic component, and you can only test a probabilistic component end-to-end, with the real prompt and the real tool result in the loop.

This is chaos engineering applied to the agent stack. The Principles of Chaos Engineering argue you build confidence in a system’s resilience by injecting real-world failures and verifying steady-state behavior holds — and that you do it against the integrated system, not isolated units. Netflix proved it for microservices; the same logic transfers directly to agents, where the “unpredictable dependency” is both the network and the model reacting to it. Injecting the fault at the tool boundary and observing the agent’s full response is the only test that exercises the actual failure path.

§ Delta · What the failed-tool path proves Wrapper correctness → agent behavior

Before

Unit test: client throws on 504 ✓

→ ↓

After

E2E: agent admits failure, books nothing ✓

There’s a scaling concern worth naming: every injected fault is another LLM call, and a full fault matrix across every tool multiplies your CI token bill fast. Be selective — inject faults only on tools with real side effects (writes, payments, external actions), and keep read-only tools on the happy path. If token cost in CI is already a constraint for you, the same token-budget discipline for AI tests in CI applies here, and failed-tool tests are worth the spend precisely because they catch the bugs that reach customers.

What to watch for when a fault test goes flaky

Fault-injection tests have a failure mode of their own: non-determinism in the recovery itself. A 504 might trigger a retry that succeeds on the second call, so the same test passes or fails depending on how the model felt that run. Don’t fight this with retries in the test runner — that hides the signal. Instead, make the fault sticky (fail every call, not just the first) when you’re testing the give-up path, and make it transient (fail once, then succeed) only when you’re specifically testing retry recovery. Separate those into two tests with two fault modes. When a genuinely flaky agent response does slip through, route it through your CI failure triage flow rather than papering over it with retries: 2.

The one thing to do this week

Pick your single highest-stakes tool — the one that moves money, sends something, or changes a booking. Write one test that makes it return a 504, and assert the agent does not claim success. That’s it. One test, one tool, the negative assertion. If it passes, you’ve earned real confidence in your worst-case path. If it fails — and on a suite that’s only ever seen healthy tools, it very often does — you just found the bug that was going to erode your users’ trust one silent failure at a time, and you found it in CI instead of in a support queue.

[ NOTE ]

Get battle-tested QA automation strategies like this every week — no fluff, just patterns from 10+ years of shipping test infrastructure at enterprise scale. Front-load the failure paths before production does it for you.

§ Frequently Asked FAQ
+ How is fault injection different from mocking the tool?

A mock replaces a tool with a fixed success response so the test runs deterministically on the happy path. Fault injection deliberately makes the tool fail — timeout, 5xx, malformed body — to test how the agent reacts. Mocking proves the agent works when tools work; fault injection proves it recovers when they don’t.

+ Where do I inject the fault if my tools run server-side?

Front the tool layer with a proxy, or add a header-driven fault mode to a test build of your agent backend. The test sets x-inject-fault: reschedule_shipment=504, and the backend’s tool dispatcher honors it only in the test environment — same contract as page.route, different interception point.

+ Won't injecting faults make my agent tests slow and expensive?

Each fault test is a full LLM round-trip, so it costs tokens. Keep them targeted: inject faults only on tools with real side effects (writes, payments, bookings), not read-only tools. A handful of failed-tool tests on your highest-stakes actions catches the bugs that reach customers.

+ What's the single most important assertion in a fault test?

The negative one: assert the agent does not claim success. not.toContainText(/confirmed|booked|done/) catches hallucinated success — an agent narrating a completed action over a tool that actually failed. That’s the specific bug fault injection exists to catch.

+ How do I keep fault tests from being flaky?

Make the fault deterministic for the behavior under test. Test the give-up path with a sticky fault (fails every call); test retry recovery with a transient fault (fails once, then succeeds). Split them into separate tests, and never mask a flaky recovery with runner-level retries — that hides the non-determinism you’re trying to measure.

§ Further Reading 03 of 03
01AI Testing

Your Agent's Confirm Dialog Is Safety Theater

Your Playwright test checks the confirm dialog renders — not that the irreversible action was blocked. Test the human-in-the-loop approval gate properly.

Read →
02AI Testing

Your Agent Acts on Half-Streamed Tool-Call JSON

Streamed tool-call arguments are invalid JSON until the final chunk — here's the Playwright pattern that stops your AI agent from acting on half-parsed args.

Read →
03AI Testing

Test What Your AI Agent Does, Not What It Says

Your AI agent test is green because you asserted on its reply. Assert on the tool calls it makes in Playwright instead — the pattern for agentic features.

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.