Your Playwright test is green. Your checkout is broken

Here is a Playwright test I've seen in almost every codebase I've worked on:
test("places an order", async ({ page }) => { await page.goto("/"); await page.getByLabel("Name").fill("Ada"); await page.getByRole("button", { name: "Place order" }).click(); await expect(page.getByRole("status")).toContainText("Order placed"); }); Good locators, a web-first assertion, no sleeps. It passes.
It also passes when the order API returns 500 and nothing is saved.
Why the test can't see it
The assertion checks the page, and the page is lying. Three very common frontend patterns produce a success message whether or not the server agreed:
// 1. Optimistic UI: show success first, send the request after showToast("Order placed"); await fetch("/api/orders", { method: "POST", body }); // 2. A catch that swallows the error await fetch("/api/orders", { method: "POST", body }).catch(() => {}); showToast("Order placed"); // 3. Checking that a response arrived, not that it succeeded const res = await fetch("/api/orders", { method: "POST", body }); if (res) showToast("Order placed"); // res.ok is never checked None of these throw. None of them log anything a test would notice. The user sees "Order placed", the test sees "Order placed", and the order never exists.
Here are three ways to catch it, from most precise to most general.
Fix 1: assert on the response
Wait for the request the click triggers, and check its status:
const [res] = await Promise.all([ page.waitForResponse((r) => r.url().endsWith("/api/orders") && r.request().method() === "POST"), page.getByRole("button", { name: "Place order" }).click(), ]); expect(res.status()).toBe(201); await expect(page.getByRole("status")).toContainText("Order placed"); This is the best check for a single test: exact endpoint, exact status. The catch is that you have to remember it on every test that writes data, and most suites don't. Mine didn't.
Fix 2: read the data back
A 201 still doesn't prove the order was stored. If your API has a list endpoint, ask it:
await expect .poll(async () => { const orders = await (await page.request.get("/api/orders")).json(); return orders.some((o) => o.name === "Ada"); }) .toBe(true); This catches the nastiest version of the bug: the server answers 200 and silently drops the write. expect.poll retries, so eventual consistency doesn't make it flaky.
Fix 3: a safety net for the whole suite
Fixes 1 and 2 only protect the tests you add them to. For everything else, you can make any test fail when the app answers with a server error, using an automatic fixture:
// fixtures.ts import { test as base, expect } from "@playwright/test"; export const test = base.extend<{ failOnServerErrors: void }>({ failOnServerErrors: [ async ({ page }, use) => { const problems: string[] = []; page.on("response", (res) => { if (res.status() >= 500) problems.push(`${res.request().method()} ${new URL(res.url()).pathname} returned ${res.status()}`); }); page.on("requestfailed", (req) => { if (req.method() !== "GET") problems.push(`${req.method()} ${new URL(req.url()).pathname} failed: ${req.failure()?.errorText}`); }); await use(); expect(problems, "the app reported errors during this test").toEqual([]); }, { auto: true }, ], }); export { expect }; Then import test from ./fixtures instead of @playwright/test. The test from the top of this post now fails:
Error: the app reported errors during this test - Array [] + Array [ + "POST /api/orders returned 500", + ] Twenty lines, no dependencies. I'd encourage you to try it on your suite today. You might be surprised what's been hiding.
Where the 20-line version falls short
I ran a version of this for a while and hit these problems:
- Third-party noise. An analytics or ads endpoint returns a 500 and your checkout test fails. You need to watch only your own site.
- Aborted requests. Navigating away cancels in-flight requests (
net::ERR_ABORTED). Those aren't bugs. - Popups and new tabs. Listening on
pagemisses requests from other pages. Listen on the browser context instead. - Tests that expect an error. A test for "show a friendly message when the server is down" needs a way to opt out, per test or per endpoint.
- 4xx on writes. A rejected POST is often a real bug, but validation tests expect a 422. It should warn, not fail, unless you ask for strict.
- The error lands as one assertion message. It's more useful as an attachment in the report, next to the trace.
I ended up packaging this as an open-source drop-in, @vedhajs/playwright-test. It handles the cases above. You change one import:
import { test, expect } from "@vedhajs/playwright-test"; // instead of "@playwright/test" The default is to fail on any 5xx or failed write from your own site, and warn on 4xx writes and page errors. It ignores third parties and attaches vedha-guard.json to each test. Opt out with test.use({ vedhaGuard: false }) or { ignore: ["/api/flaky"] }. It's still Playwright's runner, so retries, sharding and traces work as before.
It does not replace Fix 2. A write that returns 200 and stores nothing needs a read-back, and only you know which endpoint to read.
This matters more now that agents write tests
If Copilot, Claude Code or Cursor writes your tests, you get the test at the top of this post, every time: it checks what's on screen and almost never adds a response assertion. Put Fix 3 (either version) in the fixture they import from, and add a line to your AGENTS.md / CLAUDE.md telling them to use it. Agents copy the patterns they find.
Summary
| Check | Catches | Cost |
|---|---|---|
toContainText("Order placed") | UI regressions | Free, but trusts the page |
waitForResponse + status | 5xx/4xx on that request | One block per write, easy to forget |
Read-back with expect.poll | Writes that weren't stored | Needs a read endpoint |
| Suite-wide fixture | 5xx and failed writes in every test | One import |
Have you shipped a bug that a green test was hiding? I'd like to hear what it was.
Code, demo and benchmark data for the package: https://github.com/HMAKT99/VEDHA (Apache-2.0)
原文:https://www.a8q9.cn/en/article/your-playwright-test-is-green-your-checkout-is-broken-46dc66.html