Chapter 15
Unit, Integration and End-to-End Testing
Vitest, Testing Library and Playwright with a testing strategy.
# 15. Testing
Testing is how a team preserves confidence while code changes. It is not an optional layer added after the work is done. In a production codebase, tests are part of product quality: they protect behavior, document assumptions, catch regressions, and make refactoring safer. The challenge is not whether to test; it is how to design a test suite that gives fast feedback without becoming brittle or expensive.
This chapter covers the test pyramid, Vitest, React Testing Library, accessibility-focused queries, MSW API mocking, Playwright end-to-end testing, and the anti-patterns that cause teams to lose trust in their tests.
Overview and Learning Objectives
By the end of this chapter, you will be able to:
- Explain the test pyramid and choose the right balance of unit, integration, and end-to-end tests.
- Use Vitest to write fast, maintainable unit tests.
- Write React Testing Library tests that assert user-visible behavior using accessible queries.
- Mock HTTP APIs with MSW while keeping tests realistic.
- Use Playwright to test real flows in a browser across environments.
- Avoid common testing anti-patterns such as testing implementation details or relying on brittle selectors.
The Test Pyramid
The test pyramid is a mental model for building a healthy suite. It suggests that most tests should be fast and low-level, while a smaller number of end-to-end tests validate critical user journeys.
A modern pyramid usually looks like this:
- many unit tests
- some integration tests
- few end-to-end tests
The reason is simple: broad, fast tests give immediate feedback, while slow UI tests are valuable but expensive to maintain. A team with only a handful of end-to-end tests can easily miss local logic issues, while a team with only unit tests may miss integration problems between modules or server responses.
Unit tests
Unit tests validate the behavior of a single function, small module, or isolated component in a controlled environment. They are fast, easy to run repeatedly, and useful for edge cases.
Examples:
- a pure utility that formats a date
- a reducer function that handles state transitions
- a validation function for email input
Unit tests should focus on behavior, not implementation. If a function accepts an input and returns a specific output, the test should assert that output. It should not assert internal variable names or how the code happens to compute the result.
Integration tests
Integration tests validate multiple components or units working together. They are especially important in React applications, where behavior often emerges from a combination of props, context, state, and network responses.
Examples:
- a form component that updates validation UI
- a list component rendered with user data from a context provider
- a page that fetches data and renders loading and error states
These tests validate the contract between pieces of the system without requiring the full app environment.
End-to-end tests
End-to-end tests run the application in a browser and verify real flows. They catch issues involving routing, timing, CSS rendering, browser APIs, and cross-layer integration that unit tests cannot detect.
Examples:
- sign in flow with real authentication state
- checkout process and payment confirmation
- search, filter, and item detail navigation
End-to-end tests are essential, but they should be limited to the most important journeys. A full application should not rely on dozens of slow browser tests for routine validation.
The practical rule
The test pyramid is not a rigid rule but a design principle: optimize for fast feedback loops and meaningful coverage. A team with 1,000 unit tests, 100 integration tests, and 10 end-to-end tests often gets better returns than a team with 20 end-to-end tests only.
Vitest: Fast JavaScript Testing
Vitest is the default JavaScript test runner in many Vite-based React applications. It is fast, has TypeScript support, and integrates well with modern front-end tooling. Vitest is also easy to use for unit and integration tests because it supports DOM testing and async flows without excessive configuration.
A simple test
import { describe, it, expect } from 'vitest';
function formatPrice(value: number, currency = 'USD') {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(value);
}
describe('formatPrice', () => {
it('formats a value as US dollars', () => {
expect(formatPrice(42)).toBe('$42.00');
});
it('supports a different currency', () => {
expect(formatPrice(42, 'EUR')).toBe('€42.00');
});
});This is the kind of unit test that should be fast and deterministic. It does not depend on a browser or a running app.
Arranging tests around behavior
Good tests usually follow a clear structure:
- arrange the input
- act on the behavior under test
- assert the outcome
For example:
import { describe, it, expect } from 'vitest';
function getStatusFromInventory(count: number) {
if (count === 0) return 'out-of-stock';
if (count < 10) return 'low-stock';
return 'in-stock';
}
describe('getStatusFromInventory', () => {
it('returns out-of-stock when the count is zero', () => {
expect(getStatusFromInventory(0)).toBe('out-of-stock');
});
it('returns low-stock when the count is below ten', () => {
expect(getStatusFromInventory(7)).toBe('low-stock');
});
it('returns in-stock otherwise', () => {
expect(getStatusFromInventory(12)).toBe('in-stock');
});
});This style is readable and durable because it tests the observable behavior, not internal code structure.
Mocking with Vitest
Vitest provides mocking utilities to isolate logic. For example, a module that calls fetch can be tested by stubbing the function or using a mocked module.
import { vi, describe, it, expect } from 'vitest';
const fetchProducts = vi.fn();
fetchProducts.mockResolvedValue([{ id: 1, name: 'Keyboard' }]);This is useful for unit tests, but mock too much and you can end up with tests that assert only the mock interactions, not the real system behavior. Good mocking is narrow and purposeful. It focuses on external boundaries such as network or browser APIs.
React Testing Library: Test the User Experience
React Testing Library encourages tests that interact with the rendered UI the way users do. Instead of reaching into component internals, you query accessible elements, click buttons, type into inputs, and assert that meaningful changes appear in the DOM.
Accessible queries
Preferred queries are those users can discover through assistive technology:
getByRolefindByRolegetByLabelTextgetByTextgetByPlaceholderText
These queries are better than querySelector('.className') because they focus on semantics and user-visible behavior.
Example:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
function LoginForm() {
return (
<form>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" />
<button type="submit">Sign in</button>
</form>
);
}
describe('LoginForm', () => {
it('renders the email field and submit button', () => {
render(<LoginForm />);
expect(screen.getByRole('textbox', { name: /email/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
});
});This creates a meaningful assertion: the form contains an email field and a submit button that a user can interact with. It does not test CSS classes or internal state.
Interactions and async behavior
Testing Library is designed around user events rather than direct state manipulation. Example:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
function SearchBox() {
const [query, setQuery] = React.useState('');
return (
<>
<label htmlFor="search">Search</label>
<input id="search" value={query} onChange={(e) => setQuery(e.target.value)} />
<p>{query ? `Results for ${query}` : 'No query'}</p>
</>
);
}
describe('SearchBox', () => {
it('shows the entered query', async () => {
const user = userEvent.setup();
render(<SearchBox />);
const input = screen.getByRole('textbox', { name: /search/i });
await user.type(input, 'keyboard');
expect(screen.getByText('Results for keyboard')).toBeInTheDocument();
});
});This tests a real user flow. It also makes the UI easier to understand because it verifies the user-visible output.
Accessibility-first assertions
React Testing Library encourages using accessible names and roles, but it is also worth checking actual ARIA states when a component exposes them.
<button aria-expanded="false" aria-controls="menu">More options</button>A test can confirm the state:
const toggle = screen.getByRole('button', { name: /more options/i });
expect(toggle).toHaveAttribute('aria-expanded', 'false');This is more robust than checking a CSS class because the state is part of the interaction contract.
MSW: Mocking APIs Realistically
Mock Service Worker (MSW) is a powerful library for intercepting HTTP requests in tests. Unlike ad-hoc mocking of fetch functions, MSW lets you simulate API behavior at the network boundary. This is especially valuable when a component or page depends on server data and error states.
Why MSW is useful
A test should model the real contract between the browser and the API. If a component does an async fetch call and shows a loading state followed by list data, MSW can simulate the actual HTTP outcome without reaching into component internals.
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
http.get('/api/products', () => {
return HttpResponse.json([
{ id: 1, name: 'Keyboard' },
{ id: 2, name: 'Mouse' },
]);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());This test setup lets the application perform a real network request with a controlled response. That is much stronger than simply mocking fetch and assuming the request logic is correct.
Error and loading states
A realistic suite should also test error and loading cases, because users encounter them often.
server.use(
http.get('/api/products', () => {
return HttpResponse.json({ message: 'Server error' }, { status: 500 });
})
);Then the test asserts that the UI shows an error message and that the page remains usable. Observing actual HTTP behavior helps catch subtle issues such as broken parsing, wrong status handling, or stale data rendering.
Best practice for MSW
Use MSW for the network edge, not for replacing the component’s own logic. Do not mock every function in the component tree. If the component performs a fetch to /api/products, let the fetch go through the mocked server and test the actual result. This keeps the tests closer to reality.
Playwright: Browser-Level Validation
Playwright is the standard for end-to-end browser testing. It runs real browsers, supports locators, waits, and assertions, and is excellent for testing actual flows across routes, loading states, and UI transitions.
Example test
import { test, expect } from '@playwright/test';
test('user can sign in', async ({ page }) => {
await page.goto('http://localhost:3000/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('secure-password');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});Playwright locators are resilient and user-focused. Instead of using CSS selectors like .login-button, it prefers semantic queries: labels, roles, accessible names, and user-visible content.
Assertions and auto-waiting
Playwright automatically waits for elements to become actionable. This reduces flaky tests caused by timing issues. The framework also supports assertions like:
toBeVisibletoContainTexttoHaveURLtoHaveTitletoHaveAttribute
These assertions are useful for validating not only that an element exists, but whether the application state is actually correct.
CI considerations
Playwright tests are usually run in CI with a browser installed and project-specific configuration. The CI environment should include:
- the browser dependencies required by Playwright
- test accounts or seeded data for realistic flows
- a deterministic environment for network and data setup
- screen recordings or traces on failures
One of the biggest production benefits of Playwright is not just that it catches UI regressions, but that it makes those regressions reproducible in a controlled browser environment. This is particularly valuable when a bug shows up only after a route change, a state management update, or a cross-browser interaction.
Anti-Patterns to Avoid
Testing can become expensive and misleading when teams build around the wrong habits. These are some of the most common anti-patterns.
1. Testing implementation details
A test should not care whether a component uses useState or a reducer internally. It should care whether the user sees the correct behavior.
Bad:
expect(component.state.count).toBe(1);Better:
expect(screen.getByText('Count: 1')).toBeInTheDocument();2. Over-mocking everything
If every function and data flow is mocked, the test may pass while the system fails in production. Use mocks at actual boundaries and keep the system behavior real where possible.
3. Brittle selector strategies
Avoid selecting by CSS classes or DOM nesting order when user-visible semantics are available. A test that depends on div:nth-child(3) will break when the layout changes even though the feature still works.
4. Asserting only on happy paths
A robust test suite includes edge cases: empty data, slow network responses, validation errors, duplicate submissions, and unauthorized access. Production bugs are often not in the ideal path.
5. Writing tests that are too slow to run
A huge Playwright suite can become a bottleneck. Keep end-to-end tests focused on critical flows and leave the bulk of coverage to unit and integration tests. This keeps the developer feedback loop healthy.
6. Ignoring accessibility in tests
Testing accessible behavior is not an extra luxury. It is part of the user experience and often helps reveal semantic problems before a release. For example, checking that a button has a valid accessible name or that a form field is labeled appropriately is a meaningful and robust test.
Debugging Test Failures
A failing test is a signal, not a personal failure. The best approach is to read the failure carefully and determine whether the test is invalid, the code regressed, or the environment is not set up correctly.
Common debugging steps:
- Read the exact assertion and the actual output.
- Check whether the element is present but hidden or disabled.
- Look for timing issues in async logic or network responses.
- Confirm whether the test is using a realistic environment or an unrealistic mock.
- Evaluate whether the test is asserting the user-visible behavior or the internal implementation.
If a UI test fails only under CI, consider environment drift: different browser versions, slower network conditions, or missing service workers. If a unit test fails after a refactor, ask whether the behavior changed intentionally or whether the test was overfitting to implementation details.
Best Practices for Production Test Suites
A mature engineering team usually follows these practices:
- keep the test suite fast enough for developers to run before pushing
- group tests by feature or concern, not purely by file type
- avoid overfitting tests to implementation details
- cover the high-risk user flows with a small but meaningful end-to-end suite
- keep fixtures realistic and maintainable
- run accessibility checks in CI when possible
- make failing tests actionable and readable
Testing is a long-term investment. A small set of clear, meaningful tests is better than a large suite of brittle assertions. The goal is not to maximize the number of tests; it is to maximize confidence with minimal maintenance cost.
Keeping feedback fast and trustworthy
Treat test runtime as part of the development experience. A useful suite can be divided into quick checks for every commit and broader checks for pull requests or scheduled runs. Unit tests should not need a browser unless browser behavior is what you are validating. Component tests should use a predictable DOM environment, reset mocks and handlers between cases, and avoid depending on test execution order. Playwright tests can run against a production-like build in CI, with a small set of representative browsers rather than an exhaustive matrix on every change.
Retries can help distinguish a genuinely intermittent infrastructure failure from a deterministic regression, but they should not hide flakiness. Record traces, screenshots, and network details for failed browser runs, then fix the race or unstable dependency instead of raising timeouts indefinitely. Keep test data isolated: each test should establish its own relevant state and clean it up, so parallel execution does not create cross-test interference.
Coverage reports are diagnostic tools, not quality scores. A line can execute without its important outcomes being asserted. Review uncovered branches to find missing edge cases, but prioritize behavior by user impact and failure risk. For example, a payment confirmation path deserves stronger coverage than a trivial formatting helper even if both add the same number of lines to a report.
Hands-On Exercises
Exercise 1: Write a failing form test in React Testing Library
Create a component that accepts an email and a password, validates that the email is required, and displays an error message when the form is submitted empty. Write a test that checks the error message and the accessible role of the validation message.
Solution
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
function LoginForm() {
const [error, setError] = React.useState('');
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
const formData = new FormData(event.currentTarget as HTMLFormElement);
const email = String(formData.get('email') ?? '');
if (!email) {
setError('Email is required.');
return;
}
setError('');
};
return (
<form onSubmit={handleSubmit} noValidate>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" />
<button type="submit">Sign in</button>
{error ? <p role="alert">{error}</p> : null}
</form>
);
}Test:
it('shows a validation error when the email is missing', async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(screen.getByRole('alert')).toHaveTextContent('Email is required.');
});This asserts the user-visible behavior and uses an accessible role to target the validation message.
Exercise 2: Create and run a Playwright flow test
Write a Playwright test for a simple search flow on a product page: visit /products, type a query into a search box, click search, and assert that the result table contains the matching product name.
Solution
import { test, expect } from '@playwright/test';
test('search shows the matching product', async ({ page }) => {
await page.goto('http://localhost:3000/products');
await page.getByLabel('Search products').fill('keyboard');
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByRole('table')).toContainText('Keyboard');
await expect(page.getByRole('cell', { name: 'Keyboard' })).toBeVisible();
});This uses the user-facing API rather than CSS selectors. It validates a realistic browser flow and checks that the interface reflects the search result. In CI, this is a good candidate for a critical-path smoke test while most lower-level behavior remains covered by Vitest and component tests.
Summary
The goal of testing is not to prove every line of code is executed. It is to build confidence that the product works as users expect it to. The test pyramid gives a sustainable structure: many fast unit tests, some integration tests, and a smaller set of end-to-end browser checks. Vitest and React Testing Library make it straightforward to test behavior in a way that matches user interactions and accessible semantics. MSW helps validate the network boundary without inventing unrealistic mock logic, and Playwright gives confidence that the app works in a real browser.
A healthy test suite is maintainable, high signal, and aligned with the user journey. The most dangerous testing anti-pattern is not a missing assertion; it is a suite that gives false confidence. A strong testing culture values clarity, realism, and the right level of automation for the risk at hand.