Testing JavaScript Apps: Unit, Integration, and E2E

I walk through the testing pyramid, explain what unit, integration, and E2E tests each verify, and share the tradeoff that decides which layer a test belongs in.

Testing JavaScript Apps: Unit, Integration, and E2E

When you add a feature and something breaks three weeks later, the thing you wish you had is a clear error pointing to exactly what changed. That’s what a good test suite gives you — not coverage numbers, but signal.

The question most developers get wrong isn’t whether to test. It’s where in the stack to put each test. A test at the wrong layer runs slowly, breaks for unrelated reasons, and tells you something vague like “the checkout flow failed” when what you needed to know was “the price formatting function returned an unexpected value.”

The testing pyramid answers this. It gives you a mental model for distributing tests across three layers — unit, integration, and end-to-end — based on what each layer costs and what it confirms. Once you understand those tradeoffs, the question of where a test belongs usually answers itself.

Series: Part 1 of 4 in the JavaScript Testing series.

On this page

The pyramid gives you a budget, not a rule

Mike Cohn introduced the test pyramid in his book Succeeding with Agile (2009). Ham Vocke’s Practical Test Pyramid is the most referenced modern treatment. The core idea: write tests at different granularity levels, and keep fewer tests at each higher layer because higher-level tests are slower, harder to maintain, and more brittle.

Each layer trades cost for confidence differently. Unit tests run in milliseconds, are cheap to write, and fail with precision. End-to-end tests can take minutes per run, require infrastructure to stay running, and fail for reasons like network timeouts or changed CSS selectors. Putting most of your tests at the wrong end of that spectrum produces a CI pipeline that takes an hour and gives you little useful information when it’s red.

The pyramid shape reflects this: many at the base, few at the top.

Unit tests: fast feedback on isolated logic

A unit test exercises the smallest piece of testable code — a function, a module, or a class — completely cut off from everything external. It doesn’t touch the database, doesn’t make network calls, and replaces anything outside the unit itself with a controlled substitute. No I/O, no network overhead. A suite with hundreds of unit tests typically runs in a few seconds.

Here’s a straightforward example:

function applyDiscount(price, discountPercent) {
  if (discountPercent < 0 || discountPercent > 100) {
    throw new Error("Discount must be between 0 and 100");
  }
  return price * (1 - discountPercent / 100);
}

describe("applyDiscount", () => {
  it("applies a percentage discount to the price", () => {
    expect(applyDiscount(100, 10)).toBe(90);
  });

  it("throws when the discount is out of range", () => {
    expect(() => applyDiscount(100, -5)).toThrow("Discount must be between 0 and 100");
  });
});

The function takes inputs, returns a value, and has no dependencies. You call it, assert the result, done. This is the cleanest form of a unit test: pure input-output, no setup, no teardown.

Real code is rarely this clean. Functions call other functions, read config, or write to state. When a unit under test depends on something external — an HTTP client, a database connection, a third-party SDK — you replace that dependency with a test double. I’ll cover the mechanics of those in a moment.

The key limitation worth stating: unit tests verify that each piece works in isolation. They don’t verify that pieces work correctly together. A test for a function that writes a user record can’t tell you whether the database schema actually accepts it. That’s integration testing’s job.

Check this before moving on

  • You can name one function in your current project that would be easy to unit test right now
  • You understand why unit tests don’t touch the database or the network

Integration tests: catching what unit tests miss

Integration tests verify that two or more components work correctly together — your application code and something it depends on externally: a real database, a filesystem, a third-party API, a message queue.

The bugs integration tests catch are different from the bugs unit tests catch. A unit test confirms that a function parses a date string correctly in isolation. An integration test confirms that the parsed date, written through your ORM and read back from a real database, survives the round trip without corruption or timezone offset.

Ham Vocke’s Practical Test Pyramid recommends writing integration tests at each boundary where your code crosses into external infrastructure: database reads and writes, HTTP calls to external services, and file system serialization.

If you’re working with an Express REST API, integration tests cover whether your routes parse request bodies correctly and whether the database stores and returns what you intended. The production project structure post covers where integration test files typically live.

Integration tests are slower than unit tests because they involve real infrastructure — a test database, an actual HTTP server, real file paths. That’s manageable, but it’s why you want fewer integration tests than unit tests, not more. One integration test per integration boundary is a reasonable starting point.

End-to-end tests: the most expensive confidence

End-to-end tests drive your entire application from the outside — typically by controlling a real browser through real user actions, against a fully deployed or locally running stack.

The confidence they provide is the highest of any test type. An E2E test that passes means a complete user path actually worked: login, navigation, form submission, server response, database write, and UI update — all in sequence. That’s the test closest to what a real user experiences.

The cost is significant:

Test property Unit Integration E2E
Typical speed Milliseconds Seconds 10–30+ seconds each
Primary failure cause Logic bug Integration bug Any layer, plus flakiness
Maintenance cost Low Medium High
What it confirms One unit in isolation Two components connecting Full user path end-to-end

E2E tests fail for reasons unrelated to your code: network timeouts, changed selectors, browser version differences. That brittleness is inherent to the layer, not a sign of poorly written tests.

The antipattern is the ice cream cone: almost no unit tests and hundreds of E2E tests covering every edge case. That suite is slow, brittle, and expensive to fix — the kind you run reluctantly and ignore when it fails.

The right size for an E2E suite is small. Cover the user paths that represent the core value of your application: the login flow, the primary purchase path, the critical form submission. Everything else is better handled at a lower layer where the test runs faster and fails more precisely.

Test doubles: stubs, mocks, and spies

When a unit test isolates code from its dependencies, it needs controlled substitutes. The umbrella term for these is test double, coined by Gerard Meszaros and described on Martin Fowler’s site. Four come up most often in JavaScript:

Stub: Returns a pre-programmed response when called. The test controls what the stub returns; it doesn’t verify how the stub was called. Use stubs to control what the code under test receives from a dependency.

// Stub replaces a real user service with a controlled response
const userServiceStub = {
  findById: async () => ({ id: "u1", name: "Alex", age: 28 }),
};

it("returns the user's name", async () => {
  const name = await getUserName("u1", userServiceStub);
  expect(name).toBe("Alex");
});

Mock: Pre-programmed with expectations. A mock fails the test if expected calls don’t happen. Use mocks when you need to verify that your code called a dependency with specific arguments.

Spy: Wraps the real implementation and records how it was called. Unlike a mock, a spy lets the original code run. Use spies to observe behavior without replacing it.

Fake: A working implementation with shortcuts — an in-memory database, a local mail server. Fakes behave like the real thing but aren’t suitable for production.

The distinction matters. Use a stub to control what a dependency returns. Use a mock to verify what your code calls. Reaching for a mock when you need a stub produces tests that break whenever the implementation changes — even when the behavior you care about is still correct. The centralized error handling post shows code where stubs work well: the AppError class logic and the handler’s decision tree need no running HTTP server to test.

Test isolation and why it matters

Tests that share state are fragile. One test writes data to a shared table, the next test reads stale data, and the failure looks like a bug in the second test when it’s actually a leftover artifact of the first.

Test isolation means each test runs against a clean slate. In practice:

  • Unit tests: no shared mutable state between tests; each test creates its own inputs, asserts, and is done.
  • Integration tests: reset the database between runs — use a transaction that rolls back, a separate test schema, or a fresh container per run. Don’t rely on test ordering to produce the right state.
  • E2E tests: start from a known application state before each scenario; don’t depend on the outcome of a previous test completing successfully.

Isolation also means tests pass in any order. A test suite that only passes when run in a specific sequence has hidden coupling between tests — and that coupling will surface as intermittent failures at the worst possible moment.

Fixing isolation issues is unglamorous work, but it’s what makes a test suite actually trustworthy.

Write tests at the lowest layer that answers the question

The practical rule the pyramid gives you is this: push each test as far down the pyramid as it can go while still testing the thing you care about.

If the question is “does this date formatting function handle edge cases correctly,” a unit test answers it faster and more precisely than an integration test would. If the question is “does the data survive the round trip through the database,” only an integration test can answer it — a unit test with a mocked database skips the part that matters.

E2E tests should cover critical user paths that require the whole system to cooperate. They shouldn’t cover validation logic, edge cases in helper functions, or error states that can be triggered at the unit or integration level. When an E2E test fails, it should be because a user-visible path is broken — not because your date formatting function has a bug that a unit test would have caught in milliseconds.

Tests are a design signal. A function that’s hard to test in isolation is usually doing too much. The pyramid doesn’t just tell you where to put your tests — it nudges you toward code with clear, testable responsibilities.

If your project uses TypeScript, TypeScript for JavaScript Developers covers the setup that lets static types catch a class of bugs before any test runs.

Sources