HomeProjectsBlogContact

Unit vs Integration vs End-to-End Tests: What to Write and Why

Not all tests are equally useful. What each type of test catches, how much of each to write, and a pragmatic testing strategy for web apps that gives confidence without slowing you down.

Unit vs Integration vs End-to-End Tests: What to Write and Why

"We should write more tests" is something every team says. The harder question is which tests. Write the wrong kind and you get a slow, brittle test suite that breaks on every refactor while real bugs slip through. Write the right mix and you can change code with confidence.

The three main types

Unit tests

Test a single function or module in isolation.

import { calculateTax } from './tax'

test('applies 18% tax to the subtotal', () => {
  expect(calculateTax(100, 0.18)).toBe(18)
})

test('rounds to two decimal places', () => {
  expect(calculateTax(9.99, 0.18)).toBe(1.8)
})
  • Fast: thousands run in seconds.
  • Precise: when one fails, you know exactly where.
  • Limited: they can't tell you whether the pieces work together.

Best for: pure logic like calculations, parsing, validation, formatting and business rules.

Integration tests

Test several parts working together: your API route with a real database, a service with its repository, a component with its data layer.

test('POST /api/orders creates an order for the logged-in user', async () => {
  const user = await createTestUser()
  const res = await request(app)
    .post('/api/orders')
    .set('Cookie', await loginAs(user))
    .send({ productId: 42, quantity: 2 })

  expect(res.status).toBe(201)
  const order = await db.orders.findFirst({ where: { userId: user.id } })
  expect(order.quantity).toBe(2)
})
  • Realistic: they catch problems at the boundaries, like queries, serialisation, auth middleware and validation.
  • Slower than unit tests, and they need setup such as a test database.

Best for: API endpoints, database queries, authorization rules, anything where wiring matters.

End-to-end (E2E) tests

Drive the real app through a browser, like a user would, using tools like Playwright or Cypress.

test('user can sign up and see the dashboard', async ({ page }) => {
  await page.goto('/signup')
  await page.getByLabel('Email').fill('new@example.com')
  await page.getByLabel('Password').fill('a-long-secure-password')
  await page.getByRole('button', { name: 'Create account' }).click()
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
})
  • Highest confidence: the whole system works, from the browser to the database.
  • Slowest and most fragile: more moving parts, more ways to fail for reasons unrelated to your change.

Best for: a handful of critical journeys like signup, login, checkout and the main thing your product does.

How many of each?

The classic testing pyramid says lots of unit tests, fewer integration tests, very few E2E tests. Many web teams now prefer a testing trophy shape that puts the most weight on integration tests, because in web apps most bugs live where pieces connect.

A pragmatic mix for a typical web app:

  • Unit tests for any non-trivial logic.
  • Integration tests for every API endpoint's main success and failure paths, especially permissions.
  • E2E tests for the five to ten journeys that would be a disaster if broken.

What makes a test valuable

  • It tests behaviour, not implementation. Assert on outputs and visible results, not on which internal function was called. Then refactors don't break tests unnecessarily.
  • It fails for real reasons. A flaky test that fails randomly teaches everyone to ignore failures. Fix or delete flaky tests quickly.
  • It's readable. A test is documentation of how the code should behave. Name tests as sentences: "rejects orders with quantity zero."
  • It's independent. Each test sets up its own data and doesn't depend on another test running first.

Mock carefully

Mocks replace real dependencies with fakes. They're useful for things you don't control (payment APIs, email services, the current time). But mocking your own code too heavily creates tests that pass while the real integration is broken. Prefer real databases in integration tests (a container or a separate test database) and mock at the edges of your system.

Test the things that hurt

Prioritise tests for:

  • Money: pricing, taxes, discounts, payments (payments guide)
  • Permissions: user A must not see user B's data (security checklist)
  • Data integrity: anything that writes or deletes
  • Bugs you've already fixed, so they never return

Chasing 100% coverage isn't the goal. Coverage shows what's untested; it doesn't prove what's tested well.

Run tests automatically

Tests only protect you if they run on every change. Wire them into CI so a failing test blocks the merge. (GitHub Actions starter.) If you want to go further and write tests first, see test-driven development.

FAQ

What is the difference between unit and integration tests?

Unit tests check a single function or module in isolation. Integration tests check that several parts, such as an API route and a real database, work correctly together.

How many end-to-end tests should I write?

Relatively few. Cover the critical user journeys like signup, login and checkout, and rely on faster unit and integration tests for the details.

Is 100% test coverage necessary?

No. High coverage of critical logic is valuable, but coverage alone doesn't measure test quality. Focus tests on high-risk areas and real behaviour.

Keep reading

More from the workbench

All articles →