How I decide between integration tests and e2e tests
Every test suite I’ve inherited has the same problem: forty Playwright specs clicking through a UI to check things a five-line integration test could cover in under a second. Someone wrote e2e tests because they felt more real. Most of them weren’t testing anything an integration test couldn’t. They were just testing it slower, with more places to break along the way.
Where integration tests already do the job
If the thing you’re checking is “does this endpoint update this row and return the right shape,” a browser adds nothing but a browser. I run these against a real database, not a mock: a Postgres container via Testcontainers for Node APIs, SQLite in memory for smaller services, MySQL through Pest for Laravel. The test calls the route handler or controller directly, asserts on the response and the row that landed in the database, and skips the DOM entirely. No selectors to keep in sync with a redesign, no waiting for a spinner to disappear.
What actually justifies spinning up a browser
A handful of things earn the cost. Multi-page flows where state lives in cookies or redirects, like an OAuth login through Auth0. Third-party widgets you don’t control the internals of, like a Stripe Checkout iframe or a Turnstile challenge. Behavior that only exists in rendered CSS, like a modal that has to trap focus or a layout that reflows on a specific breakpoint. In each case the browser itself is part of the contract you’re testing, not an implementation detail standing between you and the logic.
The line I actually use
If I can describe the test without saying “the page,” it’s an integration test. “Applying a coupon recalculates the cart total” doesn’t need a page. “Clicking checkout lands the user on a Stripe-hosted form and returns them to the order confirmation route” does. I keep a short table taped to this decision: login through a redirect, e2e. User creation returns the right fields, integration. Checkout button opens a payment session, e2e. Cart total after a discount code, integration.
What it cost us to get this backwards
One project had a checkout suite of forty e2e tests, and six of them failed most weeks, not from real bugs but from Stripe test-mode timing and animation frames the test hadn’t waited for long enough. CI sat around eleven minutes and nobody trusted a red run enough to look at it closely. We moved the business logic checks into integration tests hitting the same code paths directly and kept five e2e tests for the parts that actually span page boundaries: login, checkout handoff, and the two flows with third-party widgets. CI dropped to about two minutes, and a failure started meaning something again.
What I run today
Integration tests run on every push, since they’re fast enough that skipping them buys nothing. Playwright is reserved for the handful of flows where the browser is the point, and those run on PRs into main rather than every commit. It’s a smaller e2e suite than most teams start with, and that’s the intent, not a compromise.
Next time you’re about to write a browser test, ask if you can describe it without mentioning the page. If you can, it isn’t an e2e test. It’s an integration test wearing a costume.