Unit, Integration & E2E Tests
Not all tests serve the same purpose. Unit tests verify a single function in isolation; integration tests verify that several pieces work together (a route + its database); end-to-end (E2E) tests verify the whole system from the user's perspective. Each has different speed, scope, and confidence trade-offs. The test pyramid is the guideline for how many of each to write — and getting the balance wrong leads to suites that are either slow and brittle or fast but missing real bugs.
The test pyramid
/\
/E2E\ few — slow, high-confidence, brittle
/------\ (full system: browser → API → DB)
/ Integ. \ some — medium speed, test real interactions
/----------\ (route + DB, service + cache)
/ Unit \ many — fast, isolated, pinpoint failures
/--------------\ (pure functions, single modules)Unit tests — isolated and fast
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { calculateDiscount } from './pricing.js'
// Pure function, no I/O — the ideal unit test:
test('calculateDiscount applies 10% for orders over $100', () => {
assert.equal(calculateDiscount(150), 15)
})
test('calculateDiscount applies nothing under $100', () => {
assert.equal(calculateDiscount(50), 0)
})
test('calculateDiscount throws on negative input', () => {
assert.throws(() => calculateDiscount(-10), /must be positive/)
})Integration tests — real interactions
import { test, before, after } from 'node:test'
import assert from 'node:assert/strict'
import request from 'supertest'
import { app } from './app.js'
import { db } from './db.js'
// Tests the route + validation + database together, against a REAL test DB:
test('POST /users creates a user and returns 201', async () => {
const res = await request(app)
.post('/users')
.send({ email: 'ada@x.com', name: 'Ada' })
.expect(201)
assert.equal(res.body.email, 'ada@x.com')
// Verify it actually persisted:
const stored = await db.users.findByEmail('ada@x.com')
assert.ok(stored)
})E2E tests — the whole system
With Playwright (browser-driving E2E)
import { test, expect } from '@playwright/test'
test('user can sign up and log in', async ({ page }) => {
await page.goto('https://staging.yourapp.com/signup')
await page.fill('[name=email]', 'newuser@x.com')
await page.fill('[name=password]', 'securepass123')
await page.click('button[type=submit]')
await expect(page).toHaveURL(/dashboard/)
await expect(page.locator('h1')).toContainText('Welcome')
})Choosing the right level for a test
You want to verify… | Test type |
|---|---|
A calculation, parser, validator, pure function | Unit |
A route returns the right status and persists data | Integration |
An error is mapped to the right HTTP code | Integration |
A service correctly calls its repository | Unit (mock the repo) |
The signup → email → login flow works | E2E |
Edge cases and error branches of a module | Unit |