page.route(): lying to my app for its own good
Today I'd like to talk about lying. Professionally. 🪄
Some tests need the backend to behave in very specific ways: return an empty list, explode with a 500, take 8 seconds to answer, return a pizza with a 300-character name. Real backends rarely do these things on request. They do them on Friday at 16:58, unprompted.
So instead of waiting for the universe to break things for me, I break them myself, in a controlled way, with page.route().
The basic trick
page.route() lets you catch requests from the browser before they leave and decide what happens to them: answer them yourself, change them or block them.
test('shows the menu from the API', async ({ page }) => {
await page.route('**/api/products', (route) =>
route.fulfill({
json: [
{ id: 1, name: 'Margherita', price: 29 },
{ id: 2, name: 'Pepperoni of Chaos', price: 35 },
],
}),
);
await page.goto('/menu');
await expect(page.getByRole('heading', { name: 'Pepperoni of Chaos' })).toBeVisible();
});
The app asks for /api/products, Playwright answers with my JSON and the real server never even hears about it. Rude? Yes. Effective? Also yes.
Important detail that cost me an embarrassing amount of time: register the route before page.goto(). If the page fetches the data while loading, a route added afterwards is like bringing an umbrella home after the rain.
The fun part: unhappy paths
This is where mocking really shines. Edge cases that are hard to set up for real become one-liners:
// the backend is having a bad day
await page.route('**/api/products', (route) =>
route.fulfill({ status: 500, json: { message: 'Oven on fire' } }),
);
await page.goto('/menu');
await expect(page.getByRole('alert')).toContainText('Something went wrong');
// nobody ordered anything ever (sad)
await page.route('**/api/orders', (route) => route.fulfill({ json: [] }));
// the backend is thinking about its life choices
await page.route('**/api/products', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 3000));
await route.continue();
});
await page.goto('/menu');
await expect(page.getByText('Loading...')).toBeVisible();
Error states, empty states, loading spinners: the parts of the UI that get the least love and the most bugs. Now they get tests too. 💅
Small lies instead of big ones
Sometimes I don't want to invent the whole response, just tweak one thing. route.fetch() gets the real response, and then I edit it:
await page.route('**/api/products', async (route) => {
const response = await route.fetch();
const products = await response.json();
products[0].name = 'A'.repeat(300); // does the card layout survive?
await route.fulfill({ response, json: products });
});
Real data, one tiny evil change. Perfect for checking how the layout handles absurdly long names (spoiler: it usually doesn't).
Checking what the app sends
Mocking isn't only about responses. I can also check that the app sends the right request:
const orderRequest = page.waitForRequest('**/api/orders');
await page.getByRole('button', { name: 'Order' }).click();
const body = (await orderRequest).postDataJSON();
expect(body).toMatchObject({ pizzaId: 2, size: 'large' });
If someone breaks the payload, this test notices before the kitchen gets an order for "undefined, size null".
Bonus: block the stuff you don't need
await page.route('**/*.{png,jpg,jpeg,webp}', (route) => route.abort());
Images, analytics, chat widgets: if they aren't what the test is about, block them. Faster tests and one less third party to blame for flakiness. (Related: flaky tests have commitment issues.)
The one rule: don't mock what you're testing
Here's the trap. If I mock everything, I'm not testing the app anymore. I'm testing that my fake JSON matches my fake JSON. Congratulations, the lie is internally consistent. 🏆
My rule of thumb:
- UI tests with mocks: for edge cases, error states and anything that's hard to reproduce for real.
- A few end-to-end tests with the real backend: to prove the whole thing actually works together.
- API tests (Playwright's
requestfixture) for the backend itself, without the browser.
And if the mocks start to get big, page.routeFromHAR() can replay a recorded HAR file of real traffic, so the fake answers at least look like the real ones.
TL;DR
page.route()beforepage.goto().route.fulfill()to answer,route.fetch()to tweak,route.abort()to block,route.continue()to let it pass.- Mock the unhappy paths. That's where the bugs live.
- Never mock the thing you're actually testing.
Lying to your app is fine, as long as you never lie to yourself about what you tested. Very wise. I should put that on a mug. ☕