Unit vs Integration vs End-to-End Tests
Unit tests check one function in isolation, integration tests check pieces working together, and end-to-end tests check the whole system end to end.
A unit test verifies one function or component in isolation, an integration test verifies that several pieces work correctly together, and an end-to-end (E2E) test drives the whole system the way a real user would. The three sit at different levels of a test suite, and a healthy project needs all of them — just not in equal numbers.
Unit tests: fast, narrow, plentiful
A unit test exercises a single function, class, or component with everything around it replaced by a mock, stub, or fake. If you’re testing a function that formats a currency value, you call it with a handful of inputs and assert on the output — no database, no network, no filesystem.
The appeal is speed and precision. A unit test suite of a few thousand tests can run in seconds, and when one fails it points at almost exactly the broken line. That precision comes from isolation: the test doesn’t know or care how the rest of the application behaves, which also means it can pass while the system as a whole is broken (say, if the pieces don’t fit together the way the test assumed).
Because they’re cheap to write and run, unit tests should be the largest layer of a test suite by count. See what memoization is or how closures work for the kind of small, pure logic that unit tests are best suited to cover.
Integration tests: checking the seams
An integration test checks that two or more real pieces cooperate correctly — a service talking to an actual (or realistic) database, an API route exercising real middleware, a frontend component fetching from a real backend. The goal is to catch bugs that live in the seams: a schema mismatch, a serialization bug, an off-by-one in how two modules pass data.
These tests are slower than unit tests because they touch real infrastructure — often a test database, a container, or an in-memory service standing in for a production dependency. They’re also more brittle: a schema change or a dependency upgrade can break several integration tests at once, even when the underlying logic is fine. That’s the tradeoff for realism.
A common middle ground is running integration tests against a local instance of a real dependency (a Postgres container, for example) rather than mocking it entirely. This catches the class of bug — like a subtly wrong SQL query — that a mocked-out database test would never see. If your integration layer talks to Postgres, database migrations and connection pooling are common sources of the bugs this layer exists to catch.
End-to-end tests: the user’s point of view
An E2E test drives the application through its real interface — a browser clicking through a signup flow, an API client hitting live endpoints in sequence — with no internal component replaced. It’s the closest a test gets to answering “does this actually work for a user,” and it’s the only layer that catches problems in how the pieces are wired together at the top: routing, deployment configuration, environment variables, and the interactions between systems that unit and integration tests can’t see because they never run the whole thing at once.
E2E tests are the slowest and most expensive layer, often taking minutes rather than milliseconds, and they’re the most sensitive to flakiness — a slow network call, an animation that hasn’t finished, or a race condition in the test itself can cause an intermittent failure that has nothing to do with a real bug. For this reason, most teams keep the E2E suite small and focused on critical paths: login, checkout, the core workflow that defines the product. Tools like Vitest and Jest handle the unit and integration layers; browser-automation frameworks (Playwright, Cypress) typically own the E2E layer.
The testing pyramid
| Unit | Integration | End-to-end | |
|---|---|---|---|
| Scope | One function/component | Multiple real components | Whole system, real interface |
| Speed | Milliseconds | Seconds | Seconds to minutes |
| Isolation | Dependencies mocked | Some real dependencies | Nothing mocked |
| Failure signal | Very precise | Points at a seam | Points at a broken user flow |
| Flakiness risk | Very low | Low to moderate | Highest |
| Typical count | Thousands | Hundreds | Dozens |
The “pyramid” shape — many unit tests, fewer integration tests, and a small number of E2E tests at the top — reflects that tradeoff between speed and realism. Inverting it (a handful of unit tests propped up by a large, slow E2E suite) is a common anti-pattern: the suite takes forever to run, and a single unrelated bug can fail dozens of E2E tests at once, making it hard to tell what’s actually broken.
Where each layer belongs in CI
Most teams run unit tests on every commit or every save locally, because they’re fast enough to give instant feedback. Integration tests typically run in CI/CD on every pull request, since they need real infrastructure that’s awkward to spin up on a laptop for every keystroke. E2E tests often run less frequently — on merge to the main branch, or on a schedule — precisely because of the time and flakiness cost.
When a test fails intermittently rather than consistently, resist the urge to just retry it and move on. A flaky E2E test is sometimes a real race condition in the application, not an artifact of the test harness. Treating every flaky failure as noise is how a real bug hides in plain sight for months. If a fix genuinely isn’t available yet, track it — don’t just add a retry loop and forget about it, the same discipline behind writing a good blameless postmortem rather than papering over root causes.
The takeaway
Unit, integration, and end-to-end tests answer different questions at different costs: unit tests confirm a piece of logic is correct in isolation, integration tests confirm the pieces fit together, and end-to-end tests confirm the whole system works from the outside. Build the suite as a pyramid — heavy on cheap, precise unit tests, moderate on integration tests around real seams, and light on slow, comprehensive E2E tests reserved for the paths that matter most — rather than picking one layer and skipping the rest.
Keep reading
Takina · · 4 min read Playwright vs Cypress: Comparing End-to-End Testing Tools
Playwright drives multiple browsers out-of-process via a devtools protocol; Cypress runs its test code inside the browser. How they differ in practice.
The Lycoris Team · · 4 min read Vitest vs Jest: Choosing a JavaScript Test Runner
Vitest and Jest share a similar API, but Vitest's Vite-based architecture makes it faster with native ESM. How the two test runners compare.
Takina · · 4 min read What Is a Feature Flag? Rollouts, Toggles, Kill Switches
A feature flag is a runtime switch that turns functionality on or off without a deploy, used for gradual rollouts, A/B tests, and instant kill switches.