Skip to content
Web Application Testing Strategy: Unit, Integration, and End-to-End Explained
Web Development8 min read

Web Application Testing Strategy: Unit, Integration, and End-to-End Explained

Scult Team
8 min read

Most teams either write no tests or write the wrong ratio of them. Here's how unit, integration, and end-to-end tests actually divide labor, and how to allocate effort between them.

A test suite with 500 unit tests and zero end-to-end tests can still ship a broken checkout flow, because every individual function worked correctly in isolation while the pieces failed to connect. A test suite with 50 end-to-end tests and no unit tests can catch that same broken checkout, but takes 45 minutes to run, fails intermittently for reasons unrelated to actual bugs, and makes every developer dread running it. Both suites represent real engineering effort spent in the wrong place. The fix isn't "write more tests" — it's understanding what each test type is actually for and building a suite where each layer catches the class of bug it's suited to catch.

The testing pyramid, and why it's shaped that way

The classic model — many unit tests, fewer integration tests, fewest end-to-end tests — isn't a stylistic preference. It's a direct consequence of two properties that trade off against each other as tests get more realistic: speed and isolation at one end, confidence that things actually work together at the other.

A unit test runs a single function or component in complete isolation, with every dependency mocked out. It runs in milliseconds, fails with a precise, unambiguous cause, and can be written for dozens of edge cases per function without meaningfully slowing down the suite. An end-to-end test drives an actual browser against an actual running application, exercising real network requests, a real database, and real rendering. It's the closest thing to "does this work the way a user would experience it," but it's slow (seconds per test, not milliseconds), it depends on the entire stack being up and correctly configured, and when it fails, the cause could be anywhere in the system — the test itself gives you a symptom, not a diagnosis.

The pyramid shape exists because you want the fast, precise layer doing the bulk of the work, catching the majority of bugs cheaply and immediately, with the slow, holistic layer reserved for confirming that the critical paths genuinely work end to end — not for exhaustively covering every edge case, which would make the suite too slow to run on every change.

Unit tests: the foundation

A unit test verifies one function, one component, or one class in isolation. Given this input, is the output correct? Given this component prop, does it render the expected markup? These tests should make up the majority of a suite — often 70% or more of total test count — because they're cheap to write, fast to run, and pinpoint exactly which piece of logic broke.

Unit tests earn their value most clearly on:

  • Business logic with real branching — pricing calculations, discount rules, permission checks, date/timezone handling. Anything with more than one code path deserves a test per path.
  • Pure functions — data transformations, formatters, validators. These are the cheapest possible tests to write because there's no setup or mocking required at all.
  • Edge cases that are tedious to trigger through the UI — an empty array, a null value, a boundary number like zero or a maximum length. Reproducing these through an end-to-end test means navigating the UI into a specific state; a unit test just calls the function with that input directly.

The trap teams fall into is writing unit tests for trivial code — a component that only renders a prop as text, a function that's a one-line wrapper around a library call — while skipping tests for the actual business logic that has bugs worth catching. Test coverage as a percentage metric encourages this trap, because it rewards test count over test value. A better internal question than "what's our coverage percentage" is "which of our functions would cause real damage if they silently broke, and do those have tests."

Integration tests: where the real bugs hide

An integration test verifies that multiple units work correctly together — an API endpoint that touches a real (or realistically test) database, a form component that correctly calls its submit handler and updates state, a service function that correctly composes three smaller functions. This is where a disproportionate share of real production bugs actually live, because individual units can each be perfectly correct while the way they're wired together is wrong — a mismatched data shape between what one function returns and what the next expects, a database query that works with mocked data but fails against a real schema constraint.

For a typical web application, useful integration test targets include:

  • API route handlers, tested against a real (test) database rather than mocks, verifying request validation, correct status codes, and correct data persistence.
  • Form submission flows, verifying that client-side validation, the network request, and the resulting UI state update all connect correctly.
  • Authentication and authorization boundaries — does a request without a valid session actually get rejected; does a user without the right role actually get blocked from an action, tested against the real middleware rather than a mocked version of it.

Integration tests are slower than unit tests and require more setup (a test database, seed data), but they catch an entire category of bug — the "wiring" bug — that unit tests structurally cannot, because unit tests mock away exactly the connections integration tests are meant to verify.

End-to-end tests: fewer, but non-negotiable for critical paths

An end-to-end (E2E) test drives a real browser through a real user flow against a fully running application — sign up, add an item to cart, complete checkout, receive a confirmation. Tools like Playwright and Cypress have made these dramatically more reliable and faster to write than the Selenium-based tests of a decade ago, but they're still the slowest and most expensive layer to maintain, and they should be reserved for the handful of flows where a failure is genuinely business-critical.

The right question for deciding what deserves an E2E test isn't "does this feature exist" — it's "if this broke silently in production for a day, how much would it cost." Checkout, account creation, password reset, and the core action your product exists to perform (booking a slot, submitting an order, publishing a post) are the usual candidates. A settings page toggle three clicks deep in an admin panel that three internal users touch monthly is very likely not worth an E2E test — an integration or unit test covering the same logic is cheaper and nearly as protective.

E2E suites are also where flakiness becomes a real cost. A test that fails 2% of the time for reasons unrelated to actual bugs — a slow network call, an animation that hasn't finished, a race condition in the test itself rather than the app — trains a team to ignore failures, which defeats the entire purpose of having the suite. Keeping an E2E suite small, focused on genuinely critical paths, and rigorously fixing flaky tests rather than tolerating them is what keeps it trustworthy.

Mocking versus real dependencies: a trade-off, not a rule

A recurring design decision at every layer is whether a test should mock a dependency (a database, an external API, a payment gateway) or exercise the real thing. Mocking makes tests fast and deterministic — no network calls, no test data to seed, no third-party service that might be down or rate-limited — but every mock is also an assumption about how the real dependency behaves, and that assumption can quietly drift out of sync with reality. A mocked payment gateway that always returns success tells you nothing about how your code handles the gateway's actual failure responses, which is precisely the scenario worth testing.

The practical resolution most mature teams land on: mock aggressively in unit tests, since isolation is the entire point there; use a real (but disposable, test-only) instance of the dependency in integration tests wherever feasible — a real Postgres test database rather than a mocked query layer, a sandboxed version of a third-party API where the provider offers one; and reserve full end-to-end tests for the real, live-adjacent path on the small number of flows that justify the cost and slowness of the real thing. Where a third-party service offers no test/sandbox mode at all, a contract test — verifying that your mock's shape matches the real API's actual documented responses, checked periodically rather than assumed forever — closes most of the gap mocking otherwise leaves open.

What to measure instead of coverage percentage

Code coverage as a single number is a genuinely poor proxy for test quality, because it counts lines executed, not outcomes verified — a test that calls a function and asserts nothing meaningful about the result still counts as "covered." More useful signals to track over time: how often the suite catches a real bug before it reaches production (worth a rough log, even informally, of "caught here" versus "caught in production"), how long the full suite takes to run (a suite that's crept past ten or fifteen minutes tends to get skipped locally, pushing all verification onto CI and slowing feedback), and the flake rate of the E2E layer specifically, since a rising flake rate is the earliest warning sign that a team is about to start ignoring the suite altogether.

Where visual and accessibility testing fits

Two categories worth naming separately because they don't fit neatly into the pyramid: visual regression testing (screenshot comparison tools that catch unintended layout or style changes) and accessibility testing (automated checks for missing alt text, insufficient color contrast, and keyboard navigation gaps, layered on top of manual review — see our companion piece on WCAG 2.2 compliance for what automated tools do and don't catch). Both are worth running in CI on a schedule or on every pull request, but neither replaces the functional layers above — a page can be visually pixel-perfect and fully accessible while still submitting the wrong data to the server.

Building a strategy that fits a real team, not a textbook

A five-person startup shipping fast and a fifty-person team maintaining a mature product need genuinely different test investment, and treating them the same wastes effort either way. For an early-stage product, we typically prioritize integration tests around core business logic and a small handful of E2E tests on the two or three flows that generate revenue, deliberately skipping exhaustive unit test coverage until the product's shape has stabilized enough that the underlying functions aren't being rewritten weekly. For a mature product with a stable core, investment shifts toward broader unit coverage of edge cases, since the cost of a regression in a long-running system is higher and the codebase is stable enough for that investment to keep paying off rather than being rewritten away.

The practical failure mode to watch for either way is a test suite that exists but that the team has learned to distrust — either because it's too slow to run before every commit, or too flaky to trust when it fails, or too shallow to actually catch the bugs that make it to production. A smaller suite that the team actually runs and trusts protects a product more than a large one that gets skipped under deadline pressure. That trust is the actual deliverable of a testing strategy — the specific tool and pyramid ratio are just the means to it.

Want results like this?

Keep reading