Kinds of tests and what each one catches
- Define unit, integration, component, contract, end-to-end and smoke tests by what runs for real and what is replaced
- Pick the cheapest kind of test that would catch a given bug, and say why the cheaper kinds pass right over it
- Explain where each kind runs — Node, a simulated DOM, a real browser, a deployed URL — and what a simulated DOM can't see
- Explain how a contract test lets two services check they still agree without deploying both, and why a stubbed client can't
Read
A kind of test is defined by what's real
FlagTest names get used loosely — one team's "integration test" is another team's "unit test". Cut through it with one question: what runs for real, and what is replaced?
Take one feature: a user uploads an avatar. The browser sends the file, an API handler checks its size and type, the storage module saves it, and the profile page shows the new picture.
| Kind | What runs for real | What's replaced | Example |
|---|---|---|---|
| Unit | one function or class | everything it calls, or nothing to replace | isAllowedImage("cat.gif", 6_000_000) returns false (over 5 MB) |
| Integration | several of your modules together, often with a real test database | services you don't own (the storage provider) | the handler + validator + repository save a row for a valid upload |
| Component | one UI component, rendered into a DOM and driven like a user | the network, and usually the browser itself (a simulated DOM) | render the upload form, pick a 6 MB file, see "File too large" |
| Contract | one side of an API boundary, checked against a recorded agreement | the other side | the web app's expected POST /avatar response is replayed against the real API |
| End-to-end | the whole app — browser, frontend, backend, database | (ideally) nothing you own | Playwright signs in, uploads a file, sees the new picture |
| Smoke | the deployed app | nothing | right after a deploy: the home page loads and sign-in works |
Read the table top to bottom and two things grow: how much is real and how much it costs. A unit test runs in milliseconds and names the broken function. An end-to-end test takes seconds, needs everything running, and when it fails you still have to find out where.
Read it bottom to top and a third thing grows: how many cases you can afford. You can try forty
file sizes against isAllowedImage in a few milliseconds. You won't try forty uploads in a browser.
So each kind answers a different question, and none of them answers all of them. The next examples show what each one misses.
The bug each kind passes right over
FlagFive real bugs from the same avatar-upload feature. For each, the cheapest test that would have caught it — and why the cheaper ones stayed green.
1. isAllowedImage accepts .gif but rejects .GIF. A unit test with both spellings catches
it in milliseconds. Every higher level could catch it too, but only if someone happened to upload
an uppercase file in that test.
2. The handler passes the size in kilobytes; the validator expects bytes. Each function passes
its own unit tests — isAllowedImage(…, 6_000_000) is right, and the handler's unit test, with the
validator replaced by a double, checks that it "calls the validator". The bug lives in the wiring.
An integration test with the real handler and the real validator uploads a 6 MB file and
expects a rejection.
3. The API team renames avatarUrl to avatar_url. The web app's tests replace the HTTP client
with a double that returns { avatarUrl } — the shape as it was when the test was written. They
stay green; production shows a broken image. A double freezes the other side's API at the day you
wrote it. A contract test checks the web app's expectations against the API's real behaviour on
every change to either side.
4. On phones, the "Save" button sits under the sticky footer. The component test renders the
form in happy-dom, clicks "Save" and passes: the simulated DOM has no layout, so nothing is ever
"under" anything. An end-to-end test at a phone viewport fails: Playwright waits for the button
to receive the click, then times out with <footer> intercepts pointer events in its log.
5. A deploy ships without the storage bucket's environment variable. Every test in CI passes, because CI has its own configuration. Uploads fail in production. A smoke test against the deployed URL, run right after the deploy, catches it in a minute instead of when users complain.
The pattern
Each bug sits at a boundary that the cheaper tests replace:
- unit tests replace the neighbours, so they miss wiring;
- tests with a stubbed client replace the other team's API, so they miss its changes;
- a simulated DOM replaces the browser, so it misses layout;
- CI replaces production, so it misses deploy configuration.
That's why a healthy suite has several kinds: not for redundancy, but because each one is blind exactly where the one below it substitutes something.
Every test runs somewhere that isn't production
FlagThe deepest blind spot isn't a kind of test. It's that every test runs in an environment that differs from production, and each difference is a place a bug can hide:
- A simulated DOM has no layout, and its event and API coverage is an approximation — Vitest's docs warn that simulation "may result in some discrepancies" and "false positives or negatives".
- A dev server isn't the production build. Code is compiled on demand and unminified, so timing
differs, and anything you configure only for production (a Content-Security-Policy, say) simply
isn't there. A suite that only runs against
next devhas never seen the build you ship. - CI's configuration isn't production's — different environment variables, a seeded test database, no CDN in front.
Higher-level tests close more of these gaps; none closes all of them. The last line is a test that runs against the deployment itself.
Smoke tests
A smoke test is a handful of fast, shallow checks run against a deployed URL right after a deploy: does the home page render, can a test account sign in, does the health endpoint answer. It's not there to find subtle bugs — your other tests do that — but to catch "the deploy is broken" in the first minute.
In Playwright, the usual way is to tag a few end-to-end tests and run just those against the deployed URL:
test("signs in", { tag: "@smoke" }, async ({ page }) => {
await page.goto("/login");
// …
});
BASE_URL=https://preview-123.example.com npx playwright test --grep @smoke
(The tag name is your choice; Playwright's --grep filters on it. BASE_URL here is your own
variable, read into use.baseURL in the config.)
Quiz
A test renders <CheckoutForm> with Testing Library in happy-dom, types into the fields, clicks "Pay" and checks the error message. fetch is replaced with a double. What kind of test is it?
formatDuration(59_999) prints "60s" instead of "59s". What's the cheapest test that catches it?
The checkout handler passes the amount in dollars; the payments module expects cents. Both have passing unit tests, and the handler's unit test replaces the payments module with a double. Which test catches the mismatch?
FlagThe orders API renames total_cents to totalCents. The web app's tests stub the API client with the old shape and stay green; production shows $NaN. Which kind of test would have failed before the deploy?
On a 375px-wide screen, the "Save" button sits under a sticky footer. The component test in happy-dom clicks it and passes. Why?
FlagA deploy ships without the storage provider's environment variable. Every CI check passed. Which test catches it soonest?
FlagWhy do most teams keep end-to-end tests few, compared with unit and integration tests?
FlagFill in the blank
Name each test by what runs for real.
Flag// Real: the pricing module and the cart module, a test Postgres. Replaced: the payment API. // → a(n) test // Real: a browser, the frontend, the API and its database, all running together. // → a(n) test
Fill in the two roles in consumer-driven contract testing.
Flag// web app (the ): its test records "GET /orders/7 → 200 { id, totalCents }" into a pact file // orders API (the ): its CI replays the pact against its real handlers
Tag a sign-in test as a smoke test, then run only the smoke tests against the deployed app.
Flagtest("signs in", { tag: "" }, async ({ page }) => { /* … */ }); // after the deploy: // npx playwright test -- @smoke
Predict the output
// @vitest-environment happy-dom
document.body.innerHTML = `<button style="width:200px">Pay</button>`;
const button = document.querySelector("button")!;
console.log(button.getBoundingClientRect().width);// summary.ts
export async function orderSummary(id: number) {
const order = await getOrder(id);
return `Order ${order.id}: $${(order.total_cents / 100).toFixed(2)}`;
}
// summary.test.ts — written last month
vi.mocked(getOrder).mockResolvedValue({ id: 7, total_cents: 1999 });
expect(await orderSummary(7)).toBe("Order 7: $19.99");
// Today the real API returns { id: 7, totalCents: 1999 }.