Testing & Quality
Testing & Quality

Kinds of tests and what each one catches

Lesson · ~25 min
After this unit you can
  • 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

Simple
1 / 2

A kind of test is defined by what's real

Flag

Test 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.

KindWhat runs for realWhat's replacedExample
Unitone function or classeverything it calls, or nothing to replaceisAllowedImage("cat.gif", 6_000_000) returns false (over 5 MB)
Integrationseveral of your modules together, often with a real test databaseservices you don't own (the storage provider)the handler + validator + repository save a row for a valid upload
Componentone UI component, rendered into a DOM and driven like a userthe network, and usually the browser itself (a simulated DOM)render the upload form, pick a 6 MB file, see "File too large"
Contractone side of an API boundary, checked against a recorded agreementthe other sidethe web app's expected POST /avatar response is replayed against the real API
End-to-endthe whole app — browser, frontend, backend, database(ideally) nothing you ownPlaywright signs in, uploads a file, sees the new picture
Smokethe deployed appnothingright 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.

Medium
1 / 2

The bug each kind passes right over

Flag

Five 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.

Complex

Every test runs somewhere that isn't production

Flag

The 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 dev has 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?

Flag

formatDuration(59_999) prints "60s" instead of "59s". What's the cheapest test that catches it?

Flag

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?

Flag

The 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?

Flag

On a 375px-wide screen, the "Save" button sits under a sticky footer. The component test in happy-dom clicks it and passes. Why?

Flag

A deploy ships without the storage provider's environment variable. Every CI check passed. Which test catches it soonest?

Flag

Why do most teams keep end-to-end tests few, compared with unit and integration tests?

Flag

Fill 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.

Flag
test("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);
Flag
// 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 }.
Flag

Further reading