ego (lite) is just a browser, ego is your personal agent across devices.
Join waitlist
PlaywrightAPI mockingTest retriesQAego (lite)

How Playwright Test should fail, retry, or pass when an API returns 500

Sep 21, 202610 min read
Playwright Test distinguishing a mocked API 500 from a live server failure

An API returning 500 does not automatically tell Playwright Test whether the test should pass, fail, or retry. A mocked 500 may be exactly what the test asked for if the goal is to verify an error state in the UI. The same status from a required live API can mean the happy path is broken. Those two cases need different assertions, annotations, and retry rules.

Playwright Test is where that behavior should become deterministic. You can intercept a request, return a controlled 500, assert both the response and the user-visible error state, and make the report show whether the run passed, failed, or became flaky. If a 500 only appears with a real account, feature flag, or signed-in session that cannot be reproduced cleanly in the suite, ego (lite) can help you inspect that failure in a visible Space first. Once the behavior is understood, the useful finding should go back into a repeatable Playwright test.

The important part is knowing what the 500 means in the test you are running. Mocked error paths, live backend failures, and genuine infrastructure flakes should not collapse into the same red result or the same retry loop. For agent-driven exploratory QA outside the test suite, see Claude Code browser testing.

What should a 500 do to the test?

Decide the product contract first. If the page has to show an error banner when checkout fails, a mocked 500 that produces that banner is a pass. If the page has to load data, a live 500 is a fail. Still designing the error UI? Mark the test test.fail() or test.fixme() so the report stays honest.

Four situations cover almost everything you'll hit. Read the middle column as the outcome you want, and the right column as the reflex that makes the report lie.

SituationWanted Playwright stateDo not
Error UI under a mocked 500passedCall the real API
Happy path hits a live 500failedRetry until the backend recovers
Known broken error UIfailed expected (test.fail)Skip and forget
Intermittent infra 500flaky only after a classified retryRaise retries for the whole file

How do you intercept an API 500?

Playwright's network guide and mock APIs guide land on the same two calls: page.route (or context.route) to intercept, then route.fulfill to answer. Register the handler before the request fires. A glob such as **/api/checkout is enough when the method matches too.

Here's the difference that matters. The fruit example below fulfills a 200 body, so Strawberry appears. A failure-state test keeps the same route and changes one field.

Official Playwright Mock APIs docs showing route.fulfill returning a Strawberry fruit JSON instead of calling the API
Official mock is a 200 body swap. The fruit example fulfills JSON so Strawberry appears. It does not set status 500. A failure-state test still has to add that status itself.
import { test, expect } from "@playwright/test";

test("shows an error banner when checkout returns 500", async ({ page }) => {
  await page.route("**/api/checkout", async (route) => {
    await route.fulfill({
      status: 500,
      contentType: "application/json",
      body: JSON.stringify({ error: "internal" }),
    });
  });

  await page.goto("/checkout");
  await page.getByRole("button", { name: "Pay" }).click();
  await expect(page.getByRole("alert")).toContainText("could not complete");
});

We restaged that intercept on 2026-09-20 from OpenCode with headed Chromium. The public URL was https://jsonplaceholder.typicode.com/todos/1. After route.fulfill set status 500 and body {"error":"internal"}, the tab showed that JSON instead of the live todo. An earlier headless run the same day with Playwright 1.59.1 returned response.status() 500, response.ok() false, and the same body. Same run, two modes.

OpenCode session titled Playwright mocked API 500 test beside headed Chromium showing jsonplaceholder.typicode.com/todos/1 with body {"error":"internal"}
Mocked 500 in the page. The URL is still jsonplaceholder, but the body is the fulfill payload, not the live todo JSON.

The page only proves the body the tab rendered. It doesn't prove Playwright classified the run as passed, and it doesn't prove the 500 came from fulfill instead of jsonplaceholder. Open the same-run Trace Viewer and read the Network row before you trust the fixture.

OpenCode reporting testResult passed beside Playwright Trace Viewer with GET https://jsonplaceholder.typicode.com/todos/1 status 500 Internal Server Error
Same mock run in Trace Viewer. OpenCode printed testResult passed. The Network panel shows GET todos/1 as 500 Internal Server Error. That is a passing error-path fixture, not a failed happy path.

That's a mock. The origin never served the error. Keep the route registered before navigation, or the page shows the real 200 todo and the trace stops proving the fixture.

To patch a live response instead of replacing it, fetch inside the route handler and fulfill with the original response plus a new status. You keep the real headers and force the 500. Abort is a different failure: the UI sees a network error, not HTTP 500.

What should you assert on a 500?

A 500 test needs two independent signals. First, the response status, or a waitForResponse predicate that checks status() === 500. Second, a user-visible state: an alert, an error page, a disabled submit, a 'try again' link. Wait only for a success selector and the 500 turns into a timeout. A timeout proves nothing about the status code.

const responsePromise = page.waitForResponse(
  (res) => res.url().includes("/api/checkout") && res.status() === 500,
);
await page.getByRole("button", { name: "Pay" }).click();
const response = await responsePromise;
expect(response.status()).toBe(500);
await expect(page.getByRole("alert")).toBeVisible();

How do you mark fail, flaky, skip, or expected failure?

Official annotations are the failure-state vocabulary. test.skip when the environment can't produce a 500 you own. test.fail when the error UI is supposed to be broken and you want Playwright to complain if it starts passing. test.fixme when running the test is slow or crashes. Tags such as @error-path let CI grep the 500 suite without mixing it into smoke.

test("checkout error banner @error-path", {
  annotation: { type: "contract", description: "mocked 500" },
}, async ({ page }) => {
  // ...
});

test("live catalog must not 500", async ({ page }) => {
  const res = await page.goto("/api/catalog");
  if (res?.status() === 500) {
    test.info().annotations.push({
      type: "live-500",
      description: res.url(),
    });
  }
  expect(res?.ok()).toBeTruthy();
});

When should retries and timeouts apply?

Playwright retries re-run a failed test in a new worker. passed means first try. flaky means failed, then passed. failed means every attempt failed. A mocked 500 that should show a banner doesn't get retried. A live 500 on a required API doesn't get retried until it's green either. One bounded retry is for classified transients, such as a known 429 with Retry-After. It isn't for HTTP 500 on a deterministic fixture.

Turn retries on globally and you have to claw them back for the error path. Override that file with test.describe.configure({ retries: 0 }). Raising the test timeout because a 500 is slow just hides a hung spinner. Reach for expect.poll or waitForResponse with an explicit timeout around the request instead.

How do you tell a mock 500 from a real one?

Name the source in the test title or annotation: mocked 500 versus live 500. In the trace, a fulfilled route never hits the origin. A live 500 shows a server timing and a real response body. Elio Struyf's 2023 note is still the right split for 429: mock a few times, then route.continue() to test retry logic. Don't copy that pattern onto 500 unless the product actually retries them. A trace is evidence. A title is a claim.

OpenCode session titled Playwright live 500 fail, retries 0 beside headed Chromium on httpbin.org/status/500 showing HTTP ERROR 500
Live 500, no mock. Chromium shows HTTP ERROR 500. OpenCode printed status 500, ok false, testResult failed, retries 0. That is a failed contract, not a flaky retry.

Service workers can hide requests from page.route. If the events are missing, block service workers the way the Playwright network guide describes. Otherwise you'll think the mock failed when MSW swallowed the call.

What should the report show?

The HTML reporter lists failed tests with the assertion and attachments. JSON and JUnit are what CI greps. Push the status code into test.info().annotations or an attachment so a 500 is searchable. Dot reporter prints F for failed and ± for flaky. If a 500 shows up as ±, retries ate the signal.

Attach the response body only when it has no secrets. A redacted JSON snippet and the URL usually carry the whole finding. Trace viewer already stores the network panel for the fulfilled route.

When does a real login session belong in the check?

Keep the suite on mocks for error UI. Use a real session when the 500 only appears behind an account, a feature flag, or a payment provider you can't stub honestly. That's a diagnostic, not CI. ego (lite) 0.5.0.32, dated 2026-09-12 on the changelog, can open that page in a Space you watch, take over, or stop. It doesn't replace Playwright Test. Setup is the quick start.

ego-browser nodejs <<'EOF'
const task = await taskSpace("watch live 500");
const page = task.page("p1");
await page.goto("https://example.com/status");
const failed = await page.waitForResponse((res) => res.status() === 500);
console.log({ url: failed.url(), status: failed.status() });
EOF

If the public status page or an official health API already returns the 500, you don't need ego (lite). Stay in Playwright.

FAQ

Should a Playwright test fail when any API returns 500?

No. A test fails when the 500 violates the path under test, and passes when the 500 was mocked to prove the error UI. Annotate live 500s so CI can tell the two apart.

How do I mock a 500 without calling the server?

Call page.route before the request, then answer with route.fulfill({ status: 500, body }). The origin never sees the call. Confirm in the trace that the route was fulfilled, not sent to the server.

Should I retry tests that see HTTP 500?

Not for a mocked 500 or a required live contract. Save retries for classified transients, such as a 429 with Retry-After. A blanket retry turns a recovered first failure into a flaky result, which hides the real signal.

How is this different from test.fail()?

test.fail() expects the test body to fail. A passing error-path test is a successful assertion against a mocked 500, not an expected failure.

When should I leave Playwright and watch the page?

When the 500 is account-specific and can't be mocked honestly. Watch it in a visible session, then encode the finding as a mocked test. ego (lite) is free to download for that diagnostic, not for CI.