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

9 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

Classify the failing operation before choosing a retry

The status code alone cannot identify a transient, a regression or a third-party failure. Record the endpoint owner, request method, response body, correlation ID and whether the product needs that response to complete the task. A required data endpoint and optional analytics can both return 500 while producing different user-visible outcomes.

Observed conditionEvidence neededAction and test verdict
Required service regressesSame fixture and input; expected data absent; server correlation IDFail the required workflow and investigate the regression. Retain the first response instead of polling it away.
Known transient on a readIncident or documented retry contract; safe read operation; bounded retry budgetAllow only the declared retry and record both attempts. A later pass does not erase the first failure.
Optional third-party request failsDependency ownership and product contract; required workflow still verifiedAssert graceful degradation separately. Report the dependency failure without automatically failing unrelated user behavior.
Write returns 500Readback or transaction status keyed to the intended operationReconcile before retrying; a response failure does not prove the write was rolled back. If outcome remains unknown, stop and escalate.

Playwright APIRequestContext documentation draws another boundary: maxRetries applies to supported network errors, currently ECONNRESET, and does not retry HTTP response codes. failOnStatusCode controls throwing for unsuccessful status codes; it does not turn a failed response into a passing product assertion. Test retries rerun the test and are a separate mechanism.

Our owned local fixture on October 27, 2026 ran three fixed-script conditions through ego-browser, one each. An intentionally transient GET returned 500 then 200 after one explicit retry (2 requests). A deterministic required GET returned 500 and stopped (1 request). A POST stored one synthetic row and then returned 500; a separate GET read back that row, so the driver sent no second POST. The fixture carries a synthetic operation key for matching; it does not implement server-side deduplication.

These injected results demonstrate why classification and reconciliation need different branches. They do not show how often a real service fails, whether Playwright retries automatically, or whether a model can diagnose the cause. The local responses and server code are available below so the verdict can be checked against the actual request counts.

Download the owned HTTP 500 fixture and responses. Use ego(lite) for a visible reproduction of the affected browser workflow and capture the matching page state. A healthy page alone cannot prove the API recovered or the write was absent; reconcile the authoritative record before another submission. Keep the deterministic Playwright assertions for CI.

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.

Keep reading