End-to-end testing: when the cost is worth it
E2E tests cover what other layers can't — but they're expensive, slow, and prone to flakiness. When to use them, how many to write, and why retry isn't the fix.
The pipeline broke on a Friday afternoon. The culprit: a flaky E2E test that had been unreliable for three weeks, never failed locally, and decided that particular Friday was the day the "Submit" button exceeded its hardcoded timeout by 3ms. The deploy sat blocked for forty minutes while someone ran it locally, confirmed everything was fine, and triggered a rerun in CI.
That's the real cost of poorly calibrated E2E. Not just execution time — it's the cognitive cost of not trusting your own tests.
Why E2E is different from the other two
If you've read the post on integration vs unit testing, you already know the pyramid logic: many unit tests, fewer integration tests, very few E2E. The pyramid isn't an aesthetic suggestion — it reflects the real cost of each layer.
Unit tests run in milliseconds, have no external dependencies, and when they fail you know exactly where the problem is. Integration tests already have real dependencies — database, cache, internal services — but are still controllable. E2E tests spin up everything: headless browser, server, database, external services, sometimes even emails and webhooks. It's the closest to production, and that's exactly what makes them expensive and brittle.
The problem isn't writing E2E. The problem is writing E2E as if it were integration — too many, covering cases that other layers handle better.
What E2E covers that other layers don't
Before discussing quantity, it's worth understanding what justifies the cost.
Flows that cross multiple stateful systems. An integration test can validate that your checkout API returns 200. An E2E test validates that the user clicked "Buy", filled in card details, received the confirmation on screen, and the confirmation email landed in the send queue. That includes session, cookies, redirects, DOM state — things integration tests don't exercise.
UI regressions with no backend representation. A form component that sends null instead of undefined because of a library change won't show up in your service tests. It will show up in E2E.
Contracts between frontend and backend that neither team noticed had changed. The field became user_id instead of userId. Both sides have passing tests. The E2E fails — because it's the only test that exercises both together for real.
These three cases have something in common: the bug only exists in the interaction. There's no way to test interaction without testing end-to-end.
How many E2E tests make sense
The honest answer: fewer than you're inclined to write.
The most common mistake is using E2E to cover variants. You have a signup flow. It has validations: invalid email, weak password, existing user, malformed tax ID. You write an E2E for each. Now you have eight tests that take forty seconds each, depend on database state, sometimes fail due to race conditions in the form, and cover cases that a unit test of the validation function handles in 3ms.
The heuristic that works in practice:
- One E2E per critical business flow. Not per variant, not per edge case — per flow. Login, checkout, onboarding, subscription cancellation. What, if broken, stops the product.
- Zero E2E for validation logic. That's unit testing.
- Zero E2E for isolated API contracts. That's integration testing.
- E2E for the happy path of each critical flow, plus two or three error scenarios with a history of regression.
In medium-sized projects, this usually means 10 to 30 E2E tests. In large projects, maybe 50-80 if the surface area is genuinely large. When you go past 100 E2E tests, you're probably covering things other layers should be covering.
Flakiness: the problem nobody talks about honestly
A flaky test is a test that fails occasionally without anything having changed. It's endemic in E2E. And the standard response from most teams — "add a retry" — is worse than the problem.
Retry hides the problem. The test keeps breaking, you keep ignoring it, and when it fails three times in a row on a critical deploy you'll spend hours debugging something that could have been fixed weeks ago.
The most common causes of flakiness in E2E:
Arbitrary timeouts. await page.waitForTimeout(2000) is the classic code smell. You're betting that two seconds is enough. In CI under load, it isn't. The fix is to wait for the element or state, not for time: await page.waitForSelector('[data-testid="confirm-button"]').
Race conditions in async operations. The test clicks "Save" and immediately checks if the toast appears. The toast depends on an API response that hasn't returned yet. The solution is to wait for the action, not assume speed.
Shared data between tests. Two E2E tests reading from and writing to the same database record will collide eventually. Each test should create its own data or use per-test database isolation.
Dependency on external services. An E2E that makes real calls to Stripe, SendGrid, or any third-party service imports that service's instability into your pipeline. Mock or sandbox — never production in automated tests.
The rule I've adopted: if a test has been flaky more than twice in two weeks, it gets fixed or deleted. A test you don't trust is worse than no test — because you spent time writing and maintaining something that will mislead you at the wrong moment.
The maintenance cost nobody budgets for
When you decide to write an E2E, you're taking on a maintenance commitment. The cost isn't the hour to write it — it's the accumulated cost of maintaining it over time.
The UI changes. The selector that was #submit-btn became [data-testid="submit"]. The login flow gained an optional 2FA step. The checkout screen got redesigned. Each of these changes breaks some E2E — not because the product is wrong, but because the test was coupled to implementation, not behavior.
The antidote is writing E2E by user behavior, not DOM structure. getByRole('button', { name: 'Confirm order' }) is more resilient than #confirm-order-btn. The user doesn't know the button's ID — they know what it says.
The other practice that reduces maintenance cost: Page Object Model (POM). Instead of repeating await page.fill('#email', user.email) in ten tests, you have a LoginPage class with a login(user) method. When the selector changes, you change it in one place.
When E2E genuinely pays off
It pays off when the risk of regression is high and the difficulty of testing another way is also high.
Payment flows. Authentication flows with OAuth. Multi-step onboarding with persisted state. Critical integrations with external services where a mock might give false confidence. Features that have already caused production bugs and that you want to make sure don't regress.
It doesn't pay off for: simple CRUD with validations covered in unit tests, paginated listings without complex logic, static pages, configurations that have snapshot tests.
The question I always ask before writing an E2E: "if this test didn't exist, how would I know this feature broke?" If the answer is "via another faster and cheaper test," the E2E doesn't need to exist.
Frequently asked questions
Should E2E tests run on every PR or only on merge?
Depends on execution time. If the full E2E suite runs in under five minutes, it makes sense on every PR. If it exceeds ten minutes, most teams move it to pre-merge (branch protection on merge to main) or run it in parallel without blocking the PR. What doesn't work: running E2E on every commit of a long feature branch — you'll have dozens of runs per day against code that's constantly changing.
Cypress or Playwright in 2026?
Playwright won. That's not an opinion — it's what adoption data shows. Native multi-browser support, better context isolation, more consistent API, and the trace debugging mode is genuinely good. Cypress still works fine for teams with an existing suite, but for new projects Playwright is the default choice.
Is it worth mocking the backend in E2E tests?
Partially. Fully mocking the backend turns E2E into an expensive UI test — you're testing that the frontend renders data you invented yourself. The value of E2E is precisely exercising the real integration. What makes sense to mock: external services (Stripe, email, SMS), rate limits, and error responses that are hard to reproduce organically.
How do you handle authentication in E2E without repeating login in every test?
Playwright has storageState support — you authenticate once, save the browser state (cookies + localStorage), and reuse it in other tests. This eliminates the authentication overhead from each test without compromising isolation. The state file is generated in global setup and should not be committed to the repository.
The takeaway
E2E isn't at the top of the pyramid because it's the best — it's at the top because it's the most expensive. The cost pays off for flows where the regression risk is high and the interaction between layers is what you need to test. For everything else, unit and integration tests do the job better and cheaper.
The metric I use: if the E2E suite is taking more than twenty minutes, it's grown too large. Time to audit what it's covering and move what can be covered by integration or unit tests.
Before writing the next E2E, the question is worth asking: what's the cheapest layer that can detect this regression?
Note: the editorial content ends here. What follows is a pointer to a related tool.
Related tool
When reviewing outputs from different test runs — comparing CI logs between a passing and a failing run, or checking whether an API response snapshot changed between versions — I use the Text Diff Checker to see exactly what diverged. Particularly useful for diagnosing flakiness that produces slightly different outputs on each run.
- 01 UTF-8 and encoding problems: why accents break and how to fix them What UTF-8 is, code points, mojibake, and why accented text turns to garbage — the mechanism explained end to end.
- 02 MIT, Apache, and GPL Licenses: Practical Differences for Developers MIT, Apache 2.0, and GPL are not the same thing. Understand permissive vs copyleft, patent protection, and compatibility before shipping code to production.