Ch. 22 · Software Testing

Unit vs Integration vs End-to-End Tests: What Each Should Cover

Unit, integration and end-to-end tests differ in what runs for real; four green pricing tests missed a coupon bug that one API-level test caught.

~7 min readbeginnerupdated Oct 11, 2026

“Unit test”, “integration test” and “end-to-end test” are some of the most used and least agreed-upon terms in software. Two engineers can argue for an hour about whether a test that touches an in-memory database is a unit test. The argument rarely matters. What matters is being able to say, for any given test, what is inside its boundary (running for real) and what is outside (replaced by a double or not involved at all), because that decides which bugs the test can possibly see.

Interviewers ask about test levels to find out whether you understand that trade-off, not whether you’ve memorised a definition. The clearest way to show it is with a concrete feature tested at each level, and a bug that only one level catches.

Draw the boundary, then name it

Roughly, the levels sit like this:

  • Unit test: one function, class or small module, in memory. No network, no disk, no real database. It runs in microseconds to milliseconds and fails with a message that points at one piece of logic.
  • Integration test: two or more real parts working together across a seam that can go wrong. Your HTTP handler plus your router plus JSON parsing plus a real database. It runs in milliseconds to seconds and catches wiring, mapping and configuration bugs.
  • End-to-end test: the whole system from the user’s side, usually a real browser driving the deployed frontend against a running backend. It runs in seconds per test and is the only level that sees what the user sees.

The distinction between solitary unit tests (every collaborator replaced) and sociable ones (real collaborators where they’re fast and deterministic), terms Martin Fowler picked up from Jay Fields, helps with the in-between cases. A test of an OrderService that uses the real PriceCalculator but a fake repository is still a unit test to most people. The label is less important than knowing what’s real.

One feature, three layers

The feature: customers place an order with optional coupon SAVE10, which takes 10% off orders of 20.00 or more. The pricing rules live in a pure function:

const COUPONS = { SAVE10: { percentOff: 10, minSubtotalCents: 2000 } };

export function priceOrder(lines, couponCode) {
  const subtotalCents = lines.reduce((sum, l) => sum + l.unitCents * l.qty, 0);
  const coupon = couponCode ? COUPONS[couponCode.toUpperCase()] : undefined;
  const discountCents = coupon && subtotalCents >= coupon.minSubtotalCents
    ? Math.round((subtotalCents * coupon.percentOff) / 100)
    : 0;
  return { subtotalCents, discountCents, totalCents: subtotalCents - discountCents };
}
JavaScript

An HTTP handler (on node:http, with SQLite via node:sqlite) accepts POST /orders, looks up unit prices, calls priceOrder, stores the order and returns it. The documented request body is { "items": [{ "sku": "MUG", "qty": 2 }], "coupon": "SAVE10" }. The relevant line in the handler:

const price = priceOrder(lines, body.couponCode);
JavaScript

You may have spotted it: the API contract says coupon, the handler reads couponCode. Now the tests.

Unit level. Four tests on priceOrder cover the rules: subtotal arithmetic, the 10% discount, the 20.00 minimum, and case-insensitive codes.

test('SAVE10 takes 10% off orders of 20.00 or more', () => {
  assert.equal(priceOrder([{ ...mug, qty: 2 }], 'SAVE10').discountCents, 240);
});

test('SAVE10 does nothing below 20.00', () => {
  assert.equal(priceOrder([{ ...mug, qty: 1 }], 'SAVE10').discountCents, 0);
});
JavaScript

Integration level. One test starts the real server on a random port, sends the documented JSON body with fetch, then reads the order back to check it was stored:

test('POST /orders prices the order, applies the coupon and stores it', async () => {
  const res = await fetch(`${base}/orders`, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ items: [{ sku: 'MUG', qty: 2 }], coupon: 'SAVE10' }),
  });
  assert.equal(res.status, 201);
  const created = await res.json();
  assert.deepEqual(created, { id: 1, subtotalCents: 2400, discountCents: 240, totalCents: 2160 });

  const stored = await (await fetch(`${base}/orders/${created.id}`)).json();
  assert.deepEqual(stored, { id: 1, totalCents: 2160, discountCents: 240 });
});
JavaScript

The bug only the middle layer could see

✖ POST /orders prices the order, applies the coupon and stores it (25.505292ms)
✔ subtotal is unit price times quantity (1.05275ms)
✔ SAVE10 takes 10% off orders of 20.00 or more (0.086917ms)
✔ SAVE10 does nothing below 20.00 (0.060208ms)
✔ coupon codes ignore case (0.059583ms)

  AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
  + actual - expected

    {
  +   discountCents: 0,
  -   discountCents: 240,
      id: 1,
      subtotalCents: 2400,
  +   totalCents: 2400
  -   totalCents: 2160
    }
Text

All four unit tests pass, correctly: the pricing rules are right. The bug isn’t in any rule. It’s in the seam between the HTTP layer and the pricing function, where a field name was mistranslated. No unit test of priceOrder can see it, because those tests call the function directly with the right argument. A unit test of the handler with priceOrder mocked wouldn’t see it either, unless the mock checked the argument, in which case the test author would have needed to already know the field name was wrong.

The integration test sees it because it sends what a real client sends and checks what a real client gets. Change body.couponCode to body.coupon and all five tests pass. The bug is also exactly the kind that does real damage quietly: no error, no crash, customers just don’t get the discount they were promised.

An end-to-end test would also have caught it, as would a manual check. But it would have taken seconds instead of 25 milliseconds, needed a browser and a deployed frontend, and its failure (“total shows 24.00, expected 21.60”) would have pointed at the whole stack instead of at the API.

What each layer costs and buys

The timings from that run make the trade-off concrete: about 0.06 ms per unit test after the first, about 17 to 25 ms for the API test including server startup and two HTTP round trips. A browser-based end-to-end test typically takes a few seconds once you count page loads and rendering.

Speed isn’t the only cost. Higher-level tests need more setup (servers, databases, browsers, seed data), they have more moving parts that can flake, and their failures are harder to localise. In exchange, each level up checks more of what users depend on: the unit tests prove the rules, the API test proves the rules are reachable through the real interface, and an end-to-end test proves a person can actually click through the flow.

That’s the reasoning behind the test pyramid: many fast unit tests at the bottom, fewer integration tests, a handful of end-to-end tests. The common mistake is repeating every case at every level. The coupon minimum, case insensitivity and rounding belong in unit tests, where twenty variations cost a millisecond. The API test needs one coupon case to prove the wiring works, plus the cases only the API can have: invalid JSON, unknown SKUs, missing fields, authentication. The end-to-end suite needs one happy-path checkout and perhaps the most important failure, not a rerun of the pricing table through a browser.

Picking the level for a new test

When you’re about to write a test, ask what bug it’s meant to catch, then pick the lowest level that can see that bug:

  • A calculation, rule or edge case: unit.
  • A mapping between layers, a query, a serialization format, a configuration value, a status code: integration.
  • Something that depends on the browser, the frontend build, or several services cooperating in the deployed environment: end-to-end.

If a bug escaped to production, ask the same question afterwards: which level could have caught it, and why didn’t it? The answer is often a missing integration test at a seam everyone assumed was trivial.

Interview practice

A team’s frontend sends dates as "2026-10-11" strings, the backend stores them in a DATE column, and a nightly report groups orders by day. After a release, orders placed late in the evening in New York started appearing under the next day. Unit tests for the report’s grouping function all pass. At what level would you add a test, and what would it do?

Answer

The grouping logic is probably fine, which is why its unit tests pass. The bug is almost certainly in a conversion between layers: somewhere a date-only string is being parsed as a timestamp in UTC (new Date("2026-10-11") in JavaScript does exactly that), or a timestamp is being truncated to a date in the server’s time zone rather than the store’s. That’s a seam problem, so I’d add an integration test that crosses the real seams: send an order through the API with a timestamp of 11:30 p.m. New York time, let it go through the real serialization and the real database column, run the report query, and assert it appears under the correct local date.

I’d also add a few unit tests around whichever conversion function turns out to be wrong, covering times near midnight and around daylight-saving changes, since those are cheap once the culprit is known. I wouldn’t start with an end-to-end browser test. It could reproduce the problem, but it would be slow, and when it failed it wouldn’t tell me which of the frontend, API, database or report layer did the wrong conversion.

More in Software Testing

read ✓Software Testing · mid

Unit and Integration Test Balance

Watch fast unit tests pass while a real SQLite test catches a swapped column and an untranslated unique-constraint error, then split the work.

~7 min readread →
read ✓Software Testing · easy

Arrange, Act and Assert for Clear Tests

Split each test into arrange, act and assert so a failure names one broken behavior, shown on an order-cancellation service with node:test.

~7 min readread →
esc