Quiz

Explain the difference between unit testing, integration testing, and end-to-end testing

Topics
JavaScriptTesting

TL;DR

  • Unit test: Exercises a small behavior with controlled dependencies. Fast and precise, but cannot prove that modules are wired correctly.
  • Integration test: Exercises collaborating modules or a real boundary such as an HTTP handler plus database adapter. Catches contract and wiring defects at moderate cost.
  • End-to-end (E2E) test: Drives a deployed or production-like system through its public UI or API. Gives confidence in critical user journeys, but is slower and more expensive to diagnose and maintain.

Use a mix based on risk. A checkout calculation deserves many unit cases, the order endpoint deserves integration coverage, and a few Playwright flows should prove that a user can complete checkout in a real browser.


Test scope and boundaries

The layers differ primarily in how much real system behavior they include and which boundaries they replace.

Unit, integration, and end-to-end test scope

A healthy suite combines the layers rather than treating one as a universal replacement for the others.

Compare the layers through one feature

LayerCheckout exampleCommon toolsMain tradeoff
UnitCalculate tax, discounts, and validation for controlled inputsVitest, Jest, Mocha, Node.js test runnerFast feedback, limited boundary confidence
IntegrationSubmit an order through the HTTP handler and verify stored recordsTest runner plus Supertest, a test database, or MSWMore realistic setup and cleanup
E2EAdd an item, enter delivery details, pay, and see confirmationPlaywright, Cypress, SeleniumHighest system confidence, slower diagnosis

The labels describe scope, not a specific tool. A Vitest test can be an integration test; a Playwright test can exercise one page or a whole multi-service journey.

Unit test

test('applies free shipping at the exact threshold', () => {
expect(calculateShipping({ subtotal: 50, isMember: false })).toBe(0);
});

This is ideal for many edge cases because it has no browser, network, or database. It cannot detect that the API passes a subtotal as the wrong field or that the UI displays the wrong result.

Integration test

test('POST /orders stores the calculated total', async () => {
const response = await request(app)
.post('/orders')
.send({ productId: 'shoe-1', quantity: 2 });
expect(response.status).toBe(201);
await expect(
testDatabase.orders.find(response.body.id),
).resolves.toMatchObject({
quantity: 2,
});
});

This catches routing, serialization, service, and persistence integration errors. Keep the database isolated per test and reset state so parallel runs do not interfere. If the real database behavior matters, an in-memory substitute may hide dialect, transaction, or constraint differences.

End-to-end test

import { expect, test } from '@playwright/test';
test('customer can complete checkout', async ({ page }) => {
await page.goto('/products/shoe-1');
await page.getByRole('button', { name: 'Add to cart' }).click();
await page.getByRole('link', { name: 'Checkout' }).click();
await page.getByLabel('Email').fill('buyer@example.com');
await page.getByRole('button', { name: 'Place order' }).click();
await expect(
page.getByRole('heading', { name: 'Order confirmed' }),
).toBeVisible();
});

Use resilient, user-facing locators and wait for observable outcomes rather than fixed sleeps. Run against controlled accounts and payment sandboxes; never let an automated test charge real customers or mutate production data.

Choosing the mix

Put most combinations and error cases at the lowest layer that can prove them. Add integration tests where the risk lies between modules, and reserve E2E tests for high-value journeys and browser-specific behavior. Duplicating every case at every layer increases cost without proportional confidence.

Further reading

Exercises

Check your understanding
Beta
Check your understanding Exercise
Check your understanding Exercise

Which test portfolio best matches the risks of an online checkout?