NodeJSUnit, Integration & E2E Tests

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

Text
         /\
        /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)
Many fast unit tests, some integration tests, few E2E tests
The pyramid shape reflects a cost/value trade-off. Unit tests are cheap to write, run in milliseconds, and pinpoint exactly what broke — so you write many. E2E tests exercise the real system end-to-end and catch integration bugs unit tests miss, but they're slow, flaky, and a failure tells you "something" broke without saying where — so you write few, covering only critical user journeys. Integration tests sit in between. Inverting the pyramid (mostly E2E) gives you a slow, flaky suite; skipping the top entirely means you never test that the pieces actually fit together.
Unit tests — isolated and fast

JS
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/)
})
Unit tests target pure logic — mock anything external (DB, network, time)
A unit test exercises one module with all its dependencies replaced by [mocks/stubs](/nodejs/mocking). It should have no real database, no network calls, no filesystem — those make it slow and non-deterministic. The easiest code to unit test is **pure functions** (same input → same output, no side effects), which is one reason separating business logic from I/O is good design. If a function is hard to unit test, it often signals it's doing too much.
Integration tests — real interactions

JS
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)
})
Integration tests use a real (test) database — verify the wiring, not just the logic
Integration tests catch the bugs that unit tests can't: a mismatched column name, a validation rule that doesn't fire, a transaction that doesn't commit. They run against a **real test database** (often a disposable Docker container or an in-memory DB), seeded and cleaned between tests. They're slower than unit tests but far more realistic. [Supertest](/nodejs/supertest) is the standard tool for driving Express apps in integration tests without binding to a real network port.
E2E tests — the whole system

With Playwright (browser-driving E2E)

JS
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')
})
E2E tests are powerful but slow and flaky — reserve them for critical user journeys
End-to-end tests drive the real, deployed system (often via a real browser with Playwright or Cypress). They give the highest confidence — if an E2E test passes, the feature genuinely works for users. But they're slow (seconds to minutes each), flaky (network hiccups, timing, async UI), and expensive to maintain. Write them only for the handful of journeys that *must* work: signup, login, checkout, the core value flow. Don't try to cover edge cases at the E2E level — that's what unit and integration tests are for.
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

Push tests as far down the pyramid as they can meaningfully go
For any given behaviour, prefer the lowest (fastest, most isolated) test level that can verify it. Testing input validation? A unit test on the validator beats an E2E test that submits a form. Testing that a 404 is returned for a missing resource? An integration test on the route beats driving a browser. Reserve the higher levels for behaviours that genuinely require the integrated system. This keeps the suite fast and failures easy to diagnose.
Next
Start writing tests with zero dependencies: [Built-in Test Runner (node:test)](/nodejs/node-test-runner).