End-to-end tests drive the real application the way a user does: a browser opens the deployed frontend, which talks to the real backend, which talks to a real database. They catch a class of failure nothing else can, such as a frontend calling an endpoint that was renamed, a missing environment variable in staging, or a CORS header that blocks the login request. They’re also the slowest, most expensive and most fragile tests you’ll write. The skill interviewers look for isn’t writing them; it’s deciding what not to test with them.
What only an end-to-end test can tell you
Start from the failures you’re trying to prevent. Unit tests check logic in isolation. Integration tests check one service against its real database or a real dependency. Contract tests check that a consumer and a provider agree on an API. What’s left after those layers is the question “does the assembled system, as deployed, let a user finish this task?”
That includes:
- wiring: the frontend’s configured API base URL, routing, authentication cookies crossing domains
- infrastructure: TLS, a CDN caching an old bundle, a load balancer timeout
- cross-service flows: a checkout that touches cart, payment and order services in sequence
- real browser behaviour: a form that can’t be submitted because a third-party script throws
Notice what’s not on the list: “a 10% discount applies to orders over $100”, “the email field rejects foo@”, “the cart total rounds correctly”. Those are rules inside one component. Testing them through a browser costs seconds per case instead of milliseconds, and when they fail the report says “timed out waiting for text ‘$90.00’” instead of pointing at the function that’s wrong.
Choosing journeys: a worked inventory
Take a mid-sized online shop. List the user journeys and score each one on two questions: how bad is it if this breaks in production, and how much of the risk lives in the connections between components rather than inside one of them?
| Journey | Cost if broken | Integration risk | E2E? |
|---|---|---|---|
| Sign up, sign in | High: no new customers | High: auth service, cookies, redirects | Yes |
| Search, view product, add to cart, check out with a card | Very high: no revenue | High: four services, payment provider sandbox | Yes |
| Apply a coupon at checkout | Medium | Low: rule lives in the pricing service | One case in the checkout test; rules tested below |
| Change shipping address | Medium | Medium: account service plus form | Maybe, if incidents justify it |
| Every validation message on the sign-up form | Low per message | None: frontend-only logic | No: component tests |
| Admin report export | Low for customers | Medium | No: API-level test |
| Password reset by email | High: locked-out customers | High: email delivery, token link | Yes, with a test inbox |
The result is four to six end-to-end tests, not sixty. Each covers a journey where a failure would trigger an incident, and where the risk crosses component boundaries. Everything else is tested lower, where it’s faster and the failure message is precise.
This is a good answer to the common interview prompt “how many E2E tests should we have?” Not a number, but a method: list journeys, rank by cost of failure and integration risk, take the top of the list, and push the rest down.
Writing tests that don’t flake
A small suite only stays useful if it’s trusted. The habits that matter most are below, with an illustrative Playwright test for the checkout journey. (It’s written against Playwright’s documented API but wasn’t run for this article, since there’s no shop to run it against.)
import { test, expect } from '@playwright/test';
test('a signed-in customer can buy an in-stock item with a card', async ({ page, request }) => {
// Arrange through the API, not the UI.
const sku = `E2E-${Date.now()}`;
await request.post('/test-api/products', { data: { sku, name: 'Canvas tote', priceCents: 2400, stock: 5 } });
await page.goto(`/products/${sku}`);
await page.getByRole('button', { name: 'Add to cart' }).click();
await page.getByRole('link', { name: 'Cart' }).click();
await page.getByRole('button', { name: 'Check out' }).click();
await page.getByLabel('Card number').fill('4242 4242 4242 4242');
await page.getByLabel('Expiry').fill('12/30');
await page.getByLabel('CVC').fill('123');
await page.getByRole('button', { name: 'Pay $24.00' }).click();
await expect(page).toHaveURL(/\/orders\/\w+/);
await expect(page.getByRole('heading', { name: 'Thanks for your order' })).toBeVisible();
});Walk through the decisions.
State is created through an API, not by clicking. Creating the product through an admin screen would add a second journey to this test and double the ways it can fail. Teams typically expose seeding endpoints only in test environments, or call the database directly from a setup script. Sign-in works the same way: Playwright can save an authenticated storageState once and reuse it, so only the dedicated sign-in test exercises the login form.
Data is unique per run. The SKU includes a timestamp, so parallel runs and reruns don’t collide over “the” test product, and leftover data from a failed run doesn’t break the next one.
Selectors describe what a user sees. getByRole('button', { name: 'Add to cart' }) survives a CSS refactor and fails if the button loses its accessible name, which is a real bug. Selectors like div.cart > button:nth-child(2) break whenever someone moves a div. A data-testid is a reasonable fallback for elements with no good role or label.
There are no sleeps. Playwright’s actions and expect assertions wait automatically until the element is ready or a timeout passes. A waitForTimeout(3000) makes the test slower every run and still fails on the day the payment sandbox takes 3.1 seconds.
The card is a test card from the payment provider’s sandbox, so the journey goes through the real integration without moving money.
It asserts the outcome, not every step. The test checks that the customer lands on an order page and sees the confirmation. It doesn’t check the cart badge count, the tax line or the button colours; those belong to component tests.
Keeping the suite healthy over time
The common failure mode isn’t a bad first version. It’s growth: every bug fix adds “an E2E test so this never happens again”, and after two years there are 400 browser tests, a 50-minute pipeline and a culture of clicking “re-run”. Some rules prevent it:
- Set a budget. For example, the E2E stage must finish in under 10 minutes on CI. New tests have to fit, which forces the “could this be tested lower?” conversation.
- For each regression, write the test at the lowest level that would have caught it. If the bug was a pricing rule, the regression test is a unit test. Only wiring and cross-service bugs earn a new end-to-end test.
- Quarantine flaky tests fast. A test that fails intermittently gets moved out of the blocking suite within a day, with a ticket and an owner. Automatic retries hide the problem and teach people to ignore red builds; if you use retries, report the tests that needed them.
- Run against a production-like environment. E2E tests that run against a dev server with mocked services are really component tests in a slow browser. The point is the real wiring.
- Run a few against production, read-only or with a dedicated test account, as smoke tests after each deploy. A synthetic sign-in and product search every few minutes catches a broken deploy faster than a customer does.
Interview exercise: the 45-minute suite
You join a team with 320 Cypress tests that take 45 minutes and fail on roughly one run in three; people re-run until green. The tech lead asks for a plan to make the suite useful again within a quarter. What do you propose?
Answer: I’d start by measuring rather than rewriting. From the last few weeks of CI history, I’d list each test’s runtime and failure rate, and group tests by the journey they exercise. That usually shows three things: a few tests cause most of the flakiness, many tests cover the same journey with small variations, and a large share check rules (validation, pricing, permissions) that don’t need a browser.
Then four steps. First, quarantine the flakiest tests immediately so the blocking suite becomes trustworthy, and fix or delete them from the quarantine list over the following weeks; common causes are fixed sleeps, shared test accounts and order-dependent data. Second, agree on the list of critical journeys with product and support, using incident history, and keep one solid end-to-end test per journey. Third, for each remaining test, ask what it actually checks and move that check down to a component, API or unit test, deleting the browser version once the lower test exists. Fourth, set a budget (say, 10 minutes and a flake rate under 1%) and track it on a dashboard, with a named owner for the suite. I’d expect to end with perhaps 20 to 40 end-to-end tests. The success measure is that a red E2E build is believed and investigated, not re-run.