Blog / How to Test Error Handling End-to-End: Force the Failure
How-To

How to Test Error Handling End-to-End: Force the Failure

Error handling is the part a green suite never runs. Force a 500 in an end-to-end test, then assert the user sees what went wrong and the app recovers.

A payment API that returns 500 sends your checkout down its error-handling branch, one no happy-path test has ever run. Unless a test forces it, that branch runs for the first time in front of a customer.

The fix is a fire drill. You trigger the failure yourself, on a quiet Tuesday, and see whether the app gets everyone out. Here is how to run one.

What you’ll learn

  • Which technique forces which failure: interception, service virtualization, or a fault proxy
  • The four layers a failure test has to assert, from the error message to the logs
  • A Playwright test that forces a 500 in checkout and asserts the user can recover
  • How to list the failure states nobody scripted, starting from a HAR file
  • Where Pie fits, and what your framework has to force

How to Force a Failure Without Editing Your App

Pick the technique by where the failure happens. Interception covers what the browser receives, service virtualization covers what your backend receives, and a fault proxy covers the connection itself. Each one adds a failure to the end-to-end testing you already run, without touching application source.

  • Network interception: Your test framework catches the browser’s request and answers it for you. Playwright’s page.route() can fulfill a request with any status or abort it as 'timedout' or 'connectionreset', and context.setOffline(true) takes the context offline. Cypress’s cy.intercept() takes a statusCode, a delay, or forceNetworkError: true. To slow a response in Playwright, call route.fetch(), wait, then route.fulfill(). The route.fetch() version does hit your real backend.
  • Service virtualization: A programmable fake stands in for a dependency your backend calls, which interception cannot reach. Cypress documents that it only intercepts requests made by your front-end application, and Playwright’s page.route() is scoped to requests the page makes. WireMock returns any error status, fixed or random delays, or a connection fault such as CONNECTION_RESET_BY_PEER. Mockoon sets a status code and latency per route.
  • Fault injection: A proxy sits between the app and a dependency and breaks the connection instead of the response. Toxiproxy adds latency, randomly drops chunks of traffic, and resets or closes a connection mid-request. Reach for it when the thing to reproduce is a slow network or a partial outage.

Start with interception. Add the other two when the failure lives somewhere the browser cannot see.

Failure you need to reproduceTechniqueWhat it cannot see
Your own API returns a 4xx or 5xx, or never answersNetwork interception (Playwright, Cypress)Calls your backend makes to other services. In Playwright, also requests a Service Worker handles, unless the context sets serviceWorkers: ‘block’.
A third-party API your backend calls returns an errorService virtualization (WireMock, Mockoon)The browser. You have to point your staging backend at the fake.
A slow, flaky, or dropped connectionFault injection (Toxiproxy)Traffic that bypasses the proxy. It also needs a proxy in the request path, so it takes setup in staging or CI.

Four Layers to Assert After You Force a Failure

A failure test passes when four layers recover, because a failure handled at one layer can leak at another. Check all four:

  1. Front-end UI: Assert the screen does not go blank, an explicit and actionable message renders (not a raw stack trace), and any loading spinner exits. The user has to know what happened and what to do next.
  2. State and rollback: If the call failed mid-transaction, assert that optimistic updates roll back and the app returns to a usable state. A retry button that re-enables belongs here.
  3. API and gateway: Inspect the network payload in the test. Confirm the backend returned your standard error schema and did not leak a stack trace to the client. OWASP flags this as improper error handling because those messages reveal implementation details.
  4. Observability: Assert the failure was recorded. Query your logging or monitoring API inside the test to confirm an error event fired with the right correlation ID. A failure nobody can see in the logs is a second bug behind the first.
The false-pass test

A test that stops at “an error message appeared” passes on a checkout that showed a toast and lost the order. Assert the rollback and the retry too.

Force a 500 in a Playwright Checkout Test

The test below answers the payment call with a 500, drives the real checkout, and asserts layers one and two.

import { test, expect } from '@playwright/test';

test('checkout shows a recoverable error when the payment API 500s', async ({ page }) => {
  // 1. Arrange: answer the payment call from the test, so the backend never sees it
  await page.route('**/api/checkout/pay', (route) =>
    route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ code: 'GATEWAY_DOWN', message: 'Payment failed' }),
    })
  );

  // 2. Act: drive the flow a real user would
  await page.goto('/cart');
  await page.getByRole('button', { name: 'Proceed to Checkout' }).click();
  await page.getByRole('button', { name: 'Submit Payment' }).click();

  // 3. Assert layers 1 and 2: the user is told, and can recover
  await expect(page.getByRole('alert')).toContainText(/payment could not be processed/i); // your app's own banner copy
  await expect(page.getByRole('button', { name: 'Submit Payment' })).toBeEnabled();
  await expect(page).toHaveURL(/\/checkout/);
});

Three details carry the test:

  • Re-enabled button: It proves the user can recover instead of staring at a spinner, which is the state layer.
  • URL check: It catches a nastier bug, where a swallowed error sends the user to a confirmation page for an order that never went through.
  • Role-based banner lookup: If getByRole('alert') cannot find your error banner, screen readers do not treat it as an alert either.

Swap route.fulfill() for route.abort('timedout') and the same test checks a dropped request, though not your app’s own timeout. Whatever the abort code, your app gets one generic network error, and it arrives before the app’s timer runs out. To test the timeout branch, leave the route unanswered. Playwright stalls a routed request until the handler continues, fulfills, or aborts it.

Layers three and four need your real backend in the loop. A stubbed 500 never touches it, so it cannot leak a stack trace or write a log line. Point staging at a WireMock stub of the payment gateway, or a fault proxy in front of it, and let your backend handle the failure itself. Then read its reply with page.waitForResponse() and query your logs for the error event.

When a failure lets part of a request reach the backend, reset that data in an afterEach hook. A half-created record otherwise turns your failure test into a source of flaky tests for everything that runs after it.

Find the Flows You Never Scripted

Point Pie at your app. Get test cases before you write one.

Book a Demo

Which Failures Belong in an End-to-End Test

Reserve end-to-end tests for failures with a product-wide blast radius, and push field-level checks down to unit and integration tests. End-to-end tests are slow to run and maintain, so each one should confirm that the system recovers together, front end to backend.

Failure to forceWhy it earns an end-to-end testWhat to assert
Payment gateway returns 500 at checkoutThe revenue path crosses the UI, the API, and order stateA clear message, a working retry, no order created
Auth service unreachable at loginIt blocks every sessionAn actionable error and no redirect loop
Primary API returns 500 mid-sessionMost screens depend on itNo blank screen and a retry that works
Network drops while a form submitsIt puts user input at riskInput preserved and no duplicate submit on retry
429 after a double-clickUsers trigger it without tryingA wait message that matches the Retry-After header (RFC 6585) when the server sends one, and a button that is not stuck disabled

TLDR: automate the breaks a customer would rage-quit over and leave the rest to unit and integration tests. Exhaustive input checks are negative testing, and they run in milliseconds at the unit layer.

How to Find the Failure States Nobody Scripted

Let the traffic tell you what can break. Record one critical flow, list every call it makes, and force each one to fail.

  1. Record the flow: Capture it with the recordHar option on browser.newContext(), or export a HAR from the browser’s Network tab.
  2. List the dependencies: Group the calls by host, such as your API, auth, payments, and shipping rates. Each host is a way for the page to break.
  3. Force three failures per host: An error status, a timeout, and a dropped connection. Note which ones leave the UI blank, stuck, or lying.
  4. Script the worst first: Rank by blast radius and write tests for the top few.

Say a checkout calls six services. Three failures each is eighteen states, before you combine any of them.

AI-assisted code leaves more of those states unhandled. CodeRabbit, a code-review vendor, analyzed 470 open-source pull requests for a December 2025 report and found error-handling and exception-path gaps nearly twice as common in AI-co-authored changes as in human-only ones. A suite scripted by hand covers whatever one engineer remembered to break, and hand-testing AI-generated code branch by branch does not scale. The HAR list catches the states memory misses.

How to Test Failure States With Pie

Pie tests through the UI, so the failure has to exist in the environment Pie is testing. Your framework tests and fault proxy force the 500, and Pie works alongside them to check what the user sees afterward. Three steps put a failure state under a Pie test:

  1. Start from discovery: Pie’s autonomous QA platform explores the reachable flows of your app like a first-time user and turns them into test cases. Use that list to choose where failure checks belong, then write each failure case, such as a declined card or a failed submit, as its own Custom Test.
  2. Set the failing state: Use a switch you already control, such as a pinned feature flag, a staging dependency pointed at a fake, or a fault proxy in front of one service. A Pie script step can run a configured shell command before the test, often a curl call to a reachable, authenticated flag or fault-control endpoint. Add a closing step that flips it back, since the failing state lives in your system and outlasts the run.
  3. Describe the recovery: Add a Custom Test such as “Submit payment. The page shows a payment error message and no order confirmation appears.” Pie runs the test like a user, and you write it without maintaining a selector. Assert a state that stays on screen. A toast that disappears after a few seconds can be missed, so give the error a persistent test-mode signal or a separate check.

Test the Failure Before Your Users Do

Every failure state ships tested or untested. The untested ones get their first drill during the fire itself, mid-checkout, on the flow you least wanted to break.

Start with one forced 500 on your highest-stakes flow and assert all four layers. Then work through the list your HAR file gave you, worst first. A red build in CI is cheap, and a lost order is not.

Your tests force the failures you can name. Pie finds flows you forgot to list, and a Custom Test you write in plain English checks what the screen shows after a failure. Ship the first forced 500 this week and let the rest of the list wait.

Test the Failure First

Force the first 500 this week. Let Pie explore the rest of your app.

Book a Demo

Frequently Asked Questions

Intercept the request and answer it yourself. In Playwright, page.route() with route.fulfill() returns any status you choose. In Cypress, cy.intercept() takes a statusCode. Your backend never has to break.

Interception only sees requests the browser makes, so use WireMock when your backend calls the failing service.

In Playwright, route.abort('timedout') or route.abort('connectionreset') fails a matched request at once, and context.setOffline(true) takes the browser context offline. To make your app's own timeout fire, leave the routed request unanswered.

Cypress offers forceNetworkError and a delay option. For slow or flaky traffic between services, put Toxiproxy in the path.

Test failures with a product-wide blast radius, such as a payment gateway 500 at checkout, an unreachable auth service at login, or a dropped connection during a form submit. Each test should assert a clear message, a working retry, and no half-applied state.

Negative testing sends invalid input, such as a malformed email, and checks the app rejects it cleanly. Error-handling testing covers how the app responds to any failure, including valid input meeting a broken dependency, such as an API or the network.

The two overlap, and a complete suite runs both.

Split them by blast radius. Field-level checks, such as whether a function returns the right error for a bad value, belong in unit and integration tests, where they run in milliseconds.

Keep end-to-end tests for the few breaks that cross every layer of the stack.

Yes, through the UI. Pie's agents explore the reachable flows of your app like a first-time user, and for each failure case you write a plain-English Custom Test that checks what the screen shows afterward.

The failure itself has to come from your environment.

Put the environment in the failing state with a feature flag, a fake dependency, or a fault proxy. A Pie script step can call a reachable, authenticated flag or fault-control endpoint with curl before the test.

Then describe the recovery in plain English, checking a state that stays on screen.

Adithya Aggarwal
Adithya Aggarwal
CTO & Co-founder at Pie

Eight years building search and delivery systems at Amazon. The kind of scale where flaky tests block billion-dollar releases. Now CTO at Pie, building AI agents that adapt when your UI changes. LinkedIn →