Unit vs integration vs end-to-end tests
Published
A test exists to fail before your users do. The question is never "should we test this", it is which kind of test catches this bug most cheaply. There are three kinds, and the difference between them is how much of the real system each one runs:
- A unit test runs one function or class on its own, with nothing real around it: no database, no network, no disk. It takes a millisecond and tells you exactly which function broke.
- An integration test runs your code together with something real it depends on: a real Postgres, a real queue, a real HTTP client against a sandbox. It takes tens or hundreds of milliseconds and catches the bugs that live in the seams.
- An end-to-end test (E2E) drives the whole product the way a user would, usually a real browser clicking through real pages against a deployed copy of the app. It takes seconds per test and catches what nothing else can, at the highest cost.
You are adding a 'pay with UPI' option to checkout. You have time for about ten tests. How many would you make unit, integration and end-to-end, and what would the end-to-end ones check?
Which test catches which bug#
The best way to feel the difference is to look at bugs that really happen, and ask which is the cheapest test that would have stopped each one.
formatPrice(199900), an amount in paise, shows "₹199,900" instead of "₹1,999.00".
"Orders this month" misses every order placed between midnight and 5:30am IST on the 1st, because the SQL compares dates in UTC.
On small phones the cookie banner covers the "Pay" button, so nobody can tap it.
Our payments client sends the amount in rupees; the gateway expects paise. Every charge is 100 times too small.
Two coupons stack when the rules say only one can apply.
After a config change, sign-ups succeed but the welcome email never sends, because the queue name no longer matches.
Notice the pattern. Logic bugs (formatting, rules, calculations) are cheapest to catch with unit tests. Seam bugs (SQL against a real database, time zones, config, the contract with a payment gateway) only show up when the real thing is there, so they need integration tests. Experience bugs (a button you can't reach, a page that never loads) need a browser.
How many of each#
Mike Cohn drew the test pyramid in 2009: lots of unit tests at the bottom, fewer integration tests in the middle, a handful of E2E tests at the top. The shape follows the cost. Unit tests are fast and precise, so have many. E2E tests are slow and break for reasons that have nothing to do with your change, so keep them for the paths that matter most.
Kent C. Dodds pushed back in 2018 with the testing trophy, which puts the most weight on integration tests. His point was that a pile of unit tests full of mocks can all pass while the app is broken, because the mocks agree with your code, not with reality. That is exactly what happened in two of the bugs above.
Both are right about different code. A practical rule that works for most backend services:
- Unit tests for logic with many cases: pricing, permissions, parsing, state machines.
- Integration tests for every query, every external call and every queue, against real dependencies running in containers.
- E2E tests for the five to ten user flows that make money or lose trust: sign up, log in, search, checkout, cancel. Not every page.
A test that mocks the database to check a SQL query is testing your mock. Mock things you do not own and cannot run (a third party's production API), and run the things you can (your database, your queue) for real. Fakes, like an in-memory version of a repository, sit in between: useful for speed in unit tests, but they will not catch a bug in the real SQL.
How teams write them today#
This is a typical TypeScript setup in 2026: Vitest for unit tests, Testcontainers to start a real Postgres in Docker for integration tests, and Playwright for the browser. The same ideas carry over to Python (pytest, Testcontainers for Python, Playwright for Python) and Java (JUnit, Testcontainers, Playwright).
import { describe, it, expect } from 'vitest'
import { applyCoupons } from './coupons'
// One table, many cases, under 10 ms. Rules like this are what unit tests are for.
describe('applyCoupons on a ₹1,000 cart', () => {
it.each([
[[], 1000],
[['SAVE10'], 900],
[['FLAT50'], 950],
[['SAVE10', 'FLAT50'], 900], // only one applies: the better one
])('%j gives %i', (codes, total) => {
expect(applyCoupons(1000, codes)).toBe(total)
})
})Three habits make these suites work on a real team:
- Find elements the way users do.
getByRole('button', { name: 'Pay' })survives a redesign; a CSS selector like.btn-primary > spanbreaks when someone renames a class. Testing Library made this style popular and Playwright built it in. - Each test sets up its own data. Tests that depend on each other, or on what an earlier test left in the database, pass alone and fail together.
- Treat flaky tests as bugs. A test that fails sometimes for no reason teaches everyone to press "re-run" and ignore red. Google reported in 2016 that about 1.5% of its test runs were flaky, and that almost 16% of its tests had failed that way at least once. Quarantine a flaky test the day you see it, and fix or delete it within the week.
- VitestUnit tests for Vite and TypeScript projects, Jest compatible API
- JestThe long time default for JavaScript, still everywhere
- Testing LibraryTest components the way users use them
- Mock Service WorkerMock HTTP at the network level, in tests and in the browser
- DockerTestcontainers starts real Postgres, Kafka or Redis for a test
- PlPlaywrightBrowser E2E from Microsoft: Chromium, Firefox, WebKit
- CypressBrowser E2E with a strong interactive runner
- pytestThe default for Python, with fixtures for setup
| Choice | What you gain | What you pay | Pick it when |
|---|---|---|---|
| Unit test | Runs in milliseconds and points at the exact function that broke. | Says nothing about the database, the network or config. Heavy mocking hides real bugs. | Logic with many cases: prices, permissions, parsing, state machines. |
| Integration test | Catches bugs in queries, contracts, config and time zones, where most production bugs live. | Slower, from tens of milliseconds to seconds, and needs Docker or a sandbox in CI. | Every query, every external call, every queue producer and consumer. |
| End-to-end test | The only test that sees what a user sees: layout, routing, the whole flow. | Seconds per test, the most likely to be flaky, and a failure says "something broke" rather than what. | The five to ten user flows that make money or lose trust. |
In the interview#
Checkpoint#
1. A SQL query returns the wrong rows for orders near midnight. Which test catches it most cheaply?
2. What is the main argument of the testing trophy against a unit-heavy pyramid?
3. An E2E test fails about one run in twenty with no code change. What do you do?
- Unit: one function, milliseconds. Integration: real dependency, tens of ms to seconds. E2E: real browser, seconds.
- Logic bugs: unit. Seam bugs: integration. Experience bugs: E2E.
- E2E only for the 5 to 10 user flows that make money or lose trust.
- Run real dependencies in containers. Mock only what you cannot run.
- Google, 2016: about 1.5% of test runs flaky. Quarantine flaky tests the day they appear.
A unit test runs one function alone in milliseconds, an integration test runs my code with a real dependency like Postgres or a queue, and an end-to-end test drives the whole app in a real browser. I pick the cheapest test that catches each kind of bug: logic with many cases gets unit tests, every query, external call and queue gets an integration test against real dependencies in containers, and only the five to ten user flows that make money or lose trust get end-to-end tests. The pyramid says most tests should be unit tests; the testing trophy points out that heavily mocked unit tests can pass while the app is broken, so I lean on integration tests for the seams. Today that is Vitest, Testcontainers and Playwright in TypeScript, or pytest in Python. Flaky tests are bugs: quarantined the day they appear and fixed within the week.