Unit vs Integration vs End-to-End Tests: What Each One Catches
Three kinds of automated tests that check your app at different sizes. Unit tests check one function, integration tests check pieces working together, end-to-end tests click through the real app. What each catches, what each costs, and which to write first.
"Write tests" is good advice that doesn't tell you which tests. There are three main kinds, and they check your app at different zoom levels.
Unit tests: one piece, alone
A unit test checks one small piece of code — usually a single function — in isolation.
// price.ts
export function applyDiscount(price: number, percent: number) {
return Math.round(price * (1 - percent / 100) * 100) / 100
}
// price.test.ts
import { test, expect } from 'vitest'
import { applyDiscount } from './price'
test('applies a 20% discount', () => {
expect(applyDiscount(50, 20)).toBe(40)
})
test('handles 0%', () => {
expect(applyDiscount(19.99, 0)).toBe(19.99)
})
Fast (thousands per second), precise (a failure points to one function), cheap to write. Best for logic: calculations, validation rules, formatting, parsing.
Misses: whether the pieces work together. Every function can pass its unit tests while the app is broken.
Common tools: Vitest, Jest (JavaScript); pytest (Python).
Integration tests: pieces together
An integration test checks that several parts work together — typically your code plus a real database, or an API route from request to response.
test('POST /api/orders creates an order', async () => {
const res = await request(app)
.post('/api/orders')
.send({ productId: 1, quantity: 2 })
expect(res.status).toBe(201)
const order = await db.order.findFirst({ where: { productId: 1 } })
expect(order?.quantity).toBe(2)
})
Catches: wrong SQL, missing migrations, broken validation, permission checks that don't work, mismatched data shapes between layers.
Slower than unit tests (database setup, real I/O) and need a test environment — often a throwaway database in Docker. (Docker Compose for local development, database seeding)
End-to-end (E2E) tests: the whole app, like a user
An E2E test runs your real app in a real browser and does what a user does:
test('user can sign up and see dashboard', async ({ page }) => {
await page.goto('/signup')
await page.getByLabel('Email').fill('test@example.com')
await page.getByLabel('Password').fill('a-strong-password')
await page.getByRole('button', { name: 'Create account' }).click()
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
})
Catches: anything a user would hit — broken buttons, frontend and backend disagreeing, redirects, JavaScript errors.
Slowest, and can be flaky (failing randomly because of timing). When they fail, the cause could be anywhere. Tools: Playwright, Cypress. (Playwright tutorial)
Side by side
| Unit | Integration | E2E | |
|---|---|---|---|
| Checks | One function | Parts together (API + DB) | Whole app in a browser |
| Speed | Milliseconds | Seconds | Seconds to minutes |
| Failure points to | Exact function | A feature | "Something in this flow" |
| Flakiness | Very low | Low | Higher |
| Confidence the app works | Low | Medium–high | Highest |
The testing pyramid (and trophy)
The classic advice is a pyramid: many unit tests, fewer integration tests, a handful of E2E tests. A popular alternative, the testing trophy, puts most weight on integration tests, because they give the most confidence per test for typical web apps.
For a small app, a practical mix:
- A few E2E tests for the flows that would be disastrous if broken: sign up, log in, pay, the core action.
- Integration tests for each important API route, including permissions ("user A can't read user B's data"). (IDOR explained)
- Unit tests for tricky logic: prices, dates, permissions rules.
Tests and AI coding tools
Tests are what make AI coding agents trustworthy. An agent that can run tests can check its own work, and you can let it make bigger changes safely. Ask for tests before or alongside features, and make "all tests pass" part of your definition of done in CLAUDE.md. Watch for AI-written tests that only check the code does what it does, rather than what it should. (Getting AI to write tests that catch bugs)
The summary
- Unit: one function, fast, precise, narrow.
- Integration: parts together, catches real wiring bugs.
- E2E: the whole app in a browser, highest confidence, slowest.
- Start with E2E for critical flows, integration for APIs, unit for tricky logic.
EasySpawn gives Claude Code a persistent server with your app and Postgres running, so it can run unit, integration and browser tests against the real thing on every change. See how it works or join the waitlist.
Related: How to Test Your App Before Launch · Getting AI to Write Tests That Catch Bugs · Playwright Tutorial · What Is CI/CD?
Keep reading
What Is WSL? Running Linux on Windows, Explained
WSL lets you run a real Linux system inside Windows without dual-booting or a separate virtual machine to manage. What it is, why web developers use it, how to install it, where your files live, and the performance and networking gotchas.
What Is an IDE? Code Editors vs IDEs Explained
An IDE (integrated development environment) puts everything you need to write software in one app: editor, file browser, terminal, debugger, git and more. How it differs from a plain code editor, the popular options, cloud IDEs, and where AI fits in.