# Unit, Integration & E2E Tests, Explained

> What the three levels of testing actually are, what each one catches and costs, and how to get the mix right so your suite stays fast and your failures point somewhere useful.


---

# Unit, Integration & E2E Tests, Explained

You've heard the words thrown around in standups and PR reviews - "this needs a unit test," "the integration tests are flaky again," "don't bother with E2E for that." And maybe you've nodded along while quietly wondering: what actually *makes* a test one kind versus another? Is it the folder it lives in? The framework? Something deeper?

Here's the part nobody sat you down to explain: the three levels aren't about tools. They're about **how much of the system each test runs at once** - and that single choice decides how fast the test is, how often it fails for dumb reasons, and how precisely a failure tells you where the bug lives. Once you see that, "what level should this be?" stops being a guess.

This guide gives you the mental model (the pyramid), walks the three levels one at a time with real examples, and then shows you how to mix them so your suite is fast, trustworthy, and actually catches the bugs that matter.

## How to read this
- **Want it to finally make sense?** Read in order - each phase builds on the last. The pyramid in Phase 1 is the lens everything else uses.
- **Already know the levels and just want the strategy?** Skip to [Phase 3: Getting the Mix Right](03-getting-the-mix-right.md) - but the decision rules there lean on the cost/coverage trade-offs explained in [Phase 2](02-the-three-levels.md).

## The phases
1. **[The Testing Pyramid](01-the-testing-pyramid.md)** - the mental model: many small fast tests at the bottom, fewer big slow ones at the top, and *why* that shape is the one that holds up.
2. **[The Three Levels](02-the-three-levels.md)** - unit, integration, and end-to-end, one at a time: what each catches, what each costs in speed and flakiness, and a concrete example of each.
3. **[Getting the Mix Right](03-getting-the-mix-right.md)** - lots of unit, some integration, a few E2E; how to decide what level a given risk belongs at; and the "ice-cream cone" anti-pattern that quietly wrecks teams.

> This guide is about the *levels* and the *mix*. Writing your first actual unit test is covered in [Your First Unit Test](/guides/your-first-unit-test); replacing slow dependencies so unit tests stay fast is covered in [Mocking & Test Doubles](/guides/mocking-and-test-doubles).


---

# The Testing Pyramid

Before we name the three levels, let's install the one picture that makes all of them make sense. If you only remember the definitions - "unit tests one thing, E2E tests everything" - you'll still be stuck guessing when you're staring at a new feature and wondering where its tests should go.

The picture is a pyramid, and the reason it's a *pyramid* and not, say, a stack of equal boxes, is the whole point. Once you see why the shape is what it is, the rest of this guide is mostly footnotes.

## The one idea underneath everything: how much do you run at once?

**What it actually is.** Every automated test makes one core decision before it does anything else: *how much of the system do I start up and run?* That's it. That's the axis the three levels live on.

- Run one small piece, by itself, with everything around it faked or absent → that's a **unit** test.
- Run a few real pieces together - say your code plus a real database → that's an **integration** test.
- Run the whole system the way a user would - through the UI or the public API, with everything real → that's an **end-to-end** (E2E) test.

📝 **Terminology - "the system under test."** Testers call the thing a given test is actually exercising the *system under test* (SUT). For a unit test the SUT is one function or class. For an E2E test the SUT is your entire running application. Same phrase, wildly different size - and that size is the thing that changes everything else.

**Why this matters.** "How much you run" isn't a trivia distinction. It directly drives two things you care about every single day: **how fast the test is**, and **how precisely it tells you where the bug is** when it fails. Hold onto those two - speed and pointing-power - because the pyramid shape falls right out of them.

## The shape

Here's the picture. Wider means *more tests of that kind*; taller-up means *more of the system per test*.

```mermaid
flowchart TD
  E2E[E2E - a handful: whole system, through the UI/API]
  Integ[Integration - some: a few real pieces, e.g. code + DB]
  Unit[Unit - lots: one piece in isolation, no DB, no network]
  E2E -->|slower · flakier · fewer| Integ
  Integ -->|faster · steadier · many| Unit
```

*Reading the picture:* as you climb, each test runs more of the system, so each one gets **slower** and more **flaky** (more moving parts = more things that can wobble for reasons unrelated to your bug). So you write **fewer** of them. Down at the base, each test runs almost nothing, so it's **fast** and **steady** - cheap enough to write thousands.

## Why the shape is this shape (and not flipped)

The pyramid is a recommendation, and the recommendation has two plain reasons behind it - the reasons you'll repeat to a teammate someday.

### Reason 1 - Speed compounds, and you run tests constantly

A unit test that touches no database, no disk, and no network finishes in well under a millisecond. An E2E test has to launch the app, open a browser, click through pages, and wait on a real server and a real database - that's seconds per test, sometimes many.

That gap doesn't stay small. You run your suite on every save, every commit, every pull request. A base of thousands of unit tests can finish before you've taken your hand off the keyboard. A suite that's mostly E2E turns "let me just run the tests" into a coffee break - so people stop running it, and tests you don't run might as well not exist.

💡 **Key point.** The pyramid is wide at the bottom because **fast tests get run, and tests that get run actually protect you.** Speed isn't a nice-to-have; it's what keeps the safety net in use.

### Reason 2 - When a test fails, it should point at the bug

This is the reason people forget, and it's the better one.

When a *unit* test fails, you know almost exactly where the problem is: it's in the one small piece that test runs. The failure is a pin dropped on a map.

```text
unit test fails    →  bug is in THIS function           (pin on the map)
E2E test fails     →  bug is somewhere in the request    (a region on the map)
                      path: browser? frontend? API?
                      business logic? database? network?
```

When an *E2E* test fails, all you've learned is "something, somewhere in that whole chain, is wrong." Now you get to go spelunking. The test caught a real problem - good - but it handed you a region, not a pin.

⚠️ **Gotcha - a green E2E suite is not the same as a debuggable one.** Teams sometimes brag that "everything's covered by E2E tests." Coverage isn't the issue; *diagnosis* is. The day one of those tests goes red, the broad ones cost you hours of "where even is this" that a unit test would have answered in seconds. Coverage that you can't act on quickly is worth less than it looks.

## So why have the top of the pyramid at all?

Fair question - if units are fast and precise, why not write only units?

Because units have a blind spot, and it's a big one: **a unit test only ever sees the one piece it runs.** It can't tell you whether your pieces actually fit together. Your function can pass every unit test while expecting a date string when the database hands it back a timestamp, or while calling an API endpoint the other team renamed last week. Each piece is individually correct; the *seams between them* are broken. Only a test that runs more than one real piece at once catches that.

That's what integration and E2E tests are for. They're slower and blunter, so you write few of them - but they cover the exact thing units can't: the connections. The pyramid isn't "units good, E2E bad." It's **each level covers what the level below it is blind to, so you buy a little of the expensive coverage and a lot of the cheap coverage.**

## Recap

1. Every test makes one decision first: **how much of the system does it run?** That single choice drives everything else.
2. More-of-the-system means **slower** and **flakier**, so you write **fewer** of those - and **less**-of-the-system means **faster** and **steadier**, so you write **many**.
3. The pyramid is wide at the base for two reasons: **fast tests actually get run**, and **narrow tests point straight at the bug** when they fail.
4. You still need the top because **units are blind to the seams between pieces** - integration and E2E exist to test the connections units can't see.

With the shape in hand, let's look at each level up close - what it catches, what it costs, and what one actually looks like.


---

# The Three Levels

Now we walk the pyramid one floor at a time. For each level, three questions - the same three every time, because they're the ones that decide where a test belongs:

- **What does it catch** that the others don't?
- **What does it cost** - in speed, and in flakiness (failing for reasons that aren't your bug)?
- **What does one actually look like?**

We'll follow one feature through all three levels so the differences are concrete: a small shopping app where adding an item to your cart updates the cart total.

## Unit - one piece, in isolation

**What it actually is.** A unit test runs a single small piece of your code - one function, one class - completely by itself. Anything that piece would normally reach out to (a database, the network, the clock, the file system) is either not involved or replaced with a stand-in.

📝 **Terminology - "in isolation" / test double.** *In isolation* means the piece runs without its real collaborators. The stand-ins you swap in for those collaborators are called **test doubles** (fakes, stubs, mocks). They exist so the unit test can stay fast and focused on the one piece - building them well is its own skill, covered in [Mocking & Test Doubles](/guides/mocking-and-test-doubles).

**What it catches.** Logic bugs inside that one piece: the off-by-one, the wrong rounding, the forgotten edge case. If a function should sum prices and apply a discount, a unit test pins down whether *the math* is right.

**What it costs.** Almost nothing. No database to start, no server to boot - these run in well under a millisecond each, which is why you can have thousands and still finish in seconds. Flakiness is near zero too: with no network or shared state, there's nothing to wobble. The cost is the blind spot from Phase 1 - it cannot tell you the piece works *with* the real things around it.

**A real example.** Testing the cart-total calculation by itself:

```console
$ npm test cart-total

  cartTotal()
    ✓ sums the prices of all line items (1 ms)
    ✓ applies a percentage discount to the subtotal (1 ms)
    ✓ returns 0 for an empty cart

  3 passing (12 ms)
```

*What just happened:* The test called the `cartTotal()` function directly with a few hand-made line items - no cart was saved to a database, no page was rendered. It checked the returned number matched what the math should produce: three cases, twelve milliseconds total. If the discount logic were wrong, exactly this test would go red, and you'd know the bug lives in `cartTotal()` and nowhere else.

## Integration - a few real pieces, working together

**What it actually is.** An integration test runs **two or more of your real pieces together**, with at least one real collaborator that a unit test would have faked - most often a real database, sometimes a real message queue or a second internal service.

**What it catches.** The seams - things invisible when each piece is tested alone: the SQL query that's subtly wrong, the column that's actually nullable, data that comes back from the database in a different shape than your code assumed. Your `saveCart()` and `loadCart()` might each have passing unit tests and still disagree about how a cart is stored; an integration test that saves a cart and reads it back is what catches that.

**What it costs.** More than a unit test, less than E2E. There's a real database to talk to, so each test is more like tens of milliseconds and up, and you need that database set up and cleaned between tests. Flakiness creeps in here: shared state between tests, leftover rows, ordering assumptions. Still very manageable - far steadier than E2E - but no longer "free."

**A real example.** Saving a cart and reading it back through a real test database:

```console
$ npm run test:integration -- cart-repository

  CartRepository (against test database)
    ✓ saves a cart and loads it back with the same items (38 ms)
    ✓ updates the total when an item is added (29 ms)

  2 passing (1.2 s)
```

*What just happened:* These tests ran your real repository code against a real (test) database - saving a cart, then loading it to confirm the items and total survived the round-trip intact. Notice the timing: tens of milliseconds per test instead of one, and over a second of total wall-clock once you count starting and resetting the database. That extra cost buys proof that *your code and your database actually agree.*

⚠️ **Gotcha - leftover state is the #1 source of flaky integration tests.** If one test saves a cart and the next doesn't start from a clean slate, the second can pass or fail depending on what ran before it. Fix: reset the data between tests (a transaction rolled back, or a truncate) so every test starts from the same known state. A test whose result depends on run order isn't testing your code - it's testing your luck.

## End-to-end - the whole system, as a user

**What it actually is.** An E2E test drives your **entire running system the way a real user would** - through the actual UI in a real browser, or through the public API - with everything real behind it: real frontend, real backend, real database, real network hops between them.

**What it catches.** That the whole journey works, all the way through - not "does the cart math work" or "does the database round-trip," but "**can a person actually add an item and see the total update on the page.**" It's the only level that exercises the real wiring between frontend and backend, the routing, the rendering - the parts no narrower test ever touches.

**What it costs.** The most, on both axes. Speed: it launches the app, drives a browser, and waits on real network and real rendering - seconds per test, not milliseconds. Flakiness: this is where flaky tests are *born.* A page that's a half-second slow to load, an animation that hasn't finished, a network blip - any of these can fail an E2E test while your code is perfectly fine. That's the tax for testing everything at once: everything that can wobble, does.

**A real example.** Driving the browser through the add-to-cart flow:

```console
$ npx playwright test add-to-cart

Running 1 test using 1 worker

  ✓  add-to-cart.spec.ts:4 › cart total updates when an item is added (4.7s)

  1 passed (6.1s)
```

*What just happened:* The test opened a real browser, loaded the running app, clicked "Add to cart" on a product, and asserted the total shown *on the page* changed to the expected amount. One test, nearly five seconds, versus twelve milliseconds for three unit tests - but it proved the thing none of the others could: a user clicking that button gets the right result on their screen, with every real piece in the chain doing its job.

## The three side by side

One plain table, since the whole point is the trade-off:

| | **Unit** | **Integration** | **End-to-end** |
|---|---|---|---|
| Runs | one piece, alone | a few real pieces (e.g. code + DB) | the whole system, via UI/API |
| Catches | logic bugs in that piece | broken seams between pieces | the user's journey actually works |
| Speed | sub-millisecond | tens of ms and up | seconds |
| Flakiness | near zero | low–moderate (state, ordering) | highest (timing, network, rendering) |
| When it fails | pin: this piece | a region: these pieces + their link | a wide region: anywhere in the chain |

## Recap

1. **Unit** runs one piece in isolation - catches logic bugs, costs almost nothing, but is blind to how pieces fit together.
2. **Integration** runs a few real pieces together (classically code + a real database) - catches broken seams, costs more and risks state-leak flakiness.
3. **End-to-end** runs the whole system as a user - catches "the journey actually works," costs the most in both speed and flakiness.
4. Each level catches exactly what the level below it can't see - which is why you want all three, in different amounts.

That "different amounts" is the last piece. Next: how to get the mix right, and the anti-pattern that gets it exactly backwards.


---

# Getting the Mix Right

You know the three levels now, and you know the pyramid says "many at the bottom, few at the top." This phase turns that shape into decisions you can make on a Tuesday afternoon: how much of each, how to decide where a specific test belongs, and how to recognize the anti-pattern that gets all of this exactly backwards.

## The decision cheat-card

> **Looking at a feature and not sure where its tests go? Start here, then read below.**

| The risk you're worried about | The level that fits |
|---|---|
| "Is this logic / math / edge case correct?" | **Unit** - fast, precise, write many |
| "Does my code agree with the database / another service?" | **Integration** - some, on the real seams |
| "Can a user actually complete this critical journey?" | **E2E** - a few, on the journeys that matter most |
| "I want to test five branches of one function" | **Unit** - five units, not five E2E tests |
| "I want to know checkout works before every release" | **One E2E** for the happy path; unit-test the pieces |

## The mix, stated plainly

The healthy shape, in rough proportions (these are a rule of thumb, not a measured law):

```text
   a few         E2E          critical journeys only: login, checkout
   some       Integration     the real seams: code↔database, code↔service
   lots          Unit         every branch, edge case, and bit of logic
```

The instinct to fight is "more E2E = more confidence." It feels true - E2E tests look the most like real usage, so surely they prove the most? But recall Phase 1: an E2E test is slow and, when it fails, points at a whole region instead of a bug. A suite built mostly of them is slow to run and slow to diagnose - the *most realistic* tests and the *least usable* suite. Confidence you can't run often, and can't act on quickly, isn't worth what it costs.

💡 **Key point.** Push every test **down** to the lowest level that can actually catch the bug you're worried about. If a unit test can catch it, that's where it goes. Reserve the slow, blunt, expensive E2E tests for the handful of journeys where "the whole thing works end to end" is the actual risk - and let units and integration carry everything below that.

## How to decide the level for a given risk

When you're staring at a specific thing to test, ask in this order - and stop at the first "yes":

1. **Is this a question about logic inside one piece?** (Does the discount round correctly? Does the validator reject a bad email?) → **Unit.** Don't drag a database or a browser into a question about one function's math.
2. **Is this a question about whether two real things agree?** (Does what I save load back correctly? Does the other service return the shape I expect?) → **Integration.** This is the seam units are blind to.
3. **Is this a question about a whole user journey working?** (Can someone sign up, add to cart, and check out?) → **E2E** - and only for the journeys important enough to justify the cost.

The trap is answering a level-1 question with a level-3 test. Checking five branches of a pricing function is five unit tests, fast, each pointing at its own branch. Doing it through the UI gives you five slow, flaky tests that all point at "somewhere in the request." Same coverage on paper; far worse suite in practice.

## ⚠️ The anti-pattern: the ice-cream cone

Here's the shape teams drift into when nobody's steering - the pyramid flipped on its head:

```mermaid
flowchart TD
  E2E[lots of E2E - slow, flaky, vague failures]
  Integ[some integration]
  Unit[a few unit - almost no fast tests at the bottom]
  E2E --> Integ --> Unit
```

It's called the **ice-cream cone** (a fat scoop of E2E on top, tapering to almost no units at the base), and it's the single most common testing-strategy failure. It usually grows by accident: testing through the UI feels the most "real," QA writes end-to-end checks, nobody pushes logic down into units, and one day your suite is a tower of slow E2E tests balanced on nothing.

**Why it hurts - concretely:**

- **The suite is slow, so people stop running it.** When the full run takes many minutes, developers skip it locally and lean on CI, and bugs slip further down the line before anyone notices.
- **Failures don't point anywhere.** Every red test means "something in the whole chain broke," so every failure is an investigation, not a fix - the Phase 1 pointing-power problem, multiplied across the whole suite.
- **Flakiness erodes trust.** E2E tests fail intermittently from timing and network wobble. A suite that's mostly E2E is mostly flaky, and the moment people start saying "oh, that test is always red, ignore it," it has stopped protecting you. A test you've trained yourself to ignore is worse than no test.
- **It's expensive to grow.** Each new E2E test adds real seconds to every run, forever. The base of the pyramid scales; the tip does not.

🪖 **War story.** Plenty of teams have lived the version where the only real safety net was a giant E2E suite. It was "thorough" on paper and miserable in practice: a single failure meant an afternoon of bisecting the request path to find which layer actually broke, and the flaky ones got muted one by one until the green checkmark meant nothing. The fix is never "write even more E2E tests" - it's pushing logic down into fast unit tests so failures point somewhere and the suite runs in seconds.

The cure isn't to delete your E2E tests. It's to **invert the cone**: for each broad E2E test, ask "what's the actual risk here, and could a unit or integration test catch most of it?" Move that coverage down. Keep a *few* E2E tests on the journeys that genuinely need full-system proof, and let the fast base do the heavy lifting.

## Where this meets the rest of your workflow

- **This is what makes CI bearable.** A pyramid-shaped suite is fast enough to run on every push and gives failures you can act on - exactly what a continuous-integration pipeline needs to stay useful instead of becoming the thing everyone waits on and resents. How tests get wired into that pipeline (and run in the right order - fast units first, slow E2E last) is covered in [Testing in CI](/guides/testing-in-ci).
- **The base of the pyramid depends on good test doubles.** "Lots of fast unit tests" is only possible because you can replace the slow real collaborators (database, network, clock) with stand-ins. Doing that cleanly - without faking so much that the test stops meaning anything - is its own skill: see [Mocking & Test Doubles](/guides/mocking-and-test-doubles).
- **And if you haven't written that first unit test yet,** start at the base: [Your First Unit Test](/guides/your-first-unit-test).

## Recap

1. **Push every test down** to the lowest level that can actually catch the bug - unit if it can, integration for the seams, E2E only for whole journeys that matter.
2. The healthy mix is **lots of unit, some integration, a few E2E** - and "more E2E" buys realism at the cost of a slow, hard-to-diagnose, flaky suite.
3. To place a specific test, ask: *one piece's logic?* → unit · *two things agreeing?* → integration · *a whole journey?* → E2E. Stop at the first yes.
4. The **ice-cream cone** (mostly E2E, almost no units) is the common failure: slow, vague, flaky, expensive - invert it by moving coverage down, not by adding more E2E.
5. A pyramid-shaped suite is what makes [CI](/guides/testing-in-ci) fast and trustworthy, and it rests on solid [test doubles](/guides/mocking-and-test-doubles) at the base.

You can now look at any feature and place its tests deliberately - fast where you can, broad only where you must - and recognize when a suite is drifting upside-down before it costs the team its afternoons.
