Testing React Applications: A Practical Guide
Tests are a safety net: they make refactoring less frightening and releases less dependent on memory. The goal is not the largest possible test count. It is confidence in the paths that matter.
If a suite feels slow or fragile, the problem is often not the testing framework. The tests may be coupled to implementation details instead of observable behavior.
A balanced stack
For many React projects, Vitest, React Testing Library, and Playwright cover distinct layers:
- Vitest runs fast checks for logic and component behavior.
- React Testing Library encourages interaction through accessible elements.
- Playwright verifies a small number of critical flows in a real browser.
An existing Jest suite does not need a rewrite simply because another runner is newer. Stable tests with familiar tooling are valuable. Improve the testing boundaries first.
Decide what belongs at each level
Unit tests
Use these for pure functions, formatters, validators, reducers, and state transitions. They should be fast, deterministic, and easy to diagnose.
export function formatPrice(cents: number, currency = "USD") { if (cents < 0) { throw new Error("Price cannot be negative"); } return new Intl.NumberFormat("en-US", { style: "currency", currency, }).format(cents / 100); }
import { describe, expect, it } from "vitest"; import { formatPrice } from "./formatPrice"; describe("formatPrice", () => { it("formats cents as dollars", () => { expect(formatPrice(1999)).toBe("$19.99"); }); it("rejects negative amounts", () => { expect(() => formatPrice(-100)).toThrow("Price cannot be negative"); }); });
Integration tests
This is often the highest-value layer for product interfaces. Render a meaningful component boundary, perform the actions a user performs, and assert the result they can observe.
import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { LoginForm } from "./LoginForm"; describe("LoginForm", () => { it("explains that a password is required", async () => { const user = userEvent.setup(); render(<LoginForm onSubmit={vi.fn()} />); await user.type( screen.getByRole("textbox", { name: /email/i }), "test@example.com", ); await user.click(screen.getByRole("button", { name: /sign in/i })); expect(screen.getByText(/password is required/i)).toBeVisible(); }); });
Queries by role and accessible name resemble real interaction. If the test cannot find the control, that can reveal an accessibility problem in the interface.
End-to-end tests
Use browser tests for the few journeys that must work across routing, authentication, data, and rendering. Login, checkout, account recovery, and a product’s central action are common examples.
import { expect, test } from "@playwright/test"; test("a user can complete checkout", async ({ page }) => { await page.goto("/products"); await page.getByRole("button", { name: "Add to cart" }).first().click(); await page.getByRole("link", { name: "Cart" }).click(); await page.getByRole("button", { name: "Checkout" }).click(); await page.getByLabel("Email").fill("test@example.com"); await page.getByRole("button", { name: "Place order" }).click(); await expect(page.getByText("Order confirmed")).toBeVisible(); });
Keep the count deliberate. Browser tests cover a large surface, but they are slower and more expensive to debug than focused tests.
Mock at system boundaries
Mock the network, clock, storage, or a third-party service when control is necessary. Avoid mocking every internal helper. A test that duplicates the component’s implementation will fail during harmless refactors and stay green during the wrong product behavior.
For API-dependent components, a request interception layer can return realistic responses while preserving the application’s normal request path. Include success, empty, loading, and failure states.
High-signal habits
- Assert what a user sees or can do.
- Prefer roles, labels, and visible text over test IDs.
- Give each test one clear behavioral reason to fail.
- Use deterministic fixtures with names that explain the scenario.
- Reset shared state between tests.
- Keep error output readable enough to diagnose in CI.
Test IDs are reasonable when no semantic selector exists, but they should not be the default escape from inaccessible markup.
What to avoid
Internal state assertions
Whether a value lives in state, a reducer, or a derived expression is usually not the contract. Assert the rendered behavior instead.
Giant snapshots
Snapshots of whole pages often turn meaningful review into “update snapshot.” Small snapshots can help with serialized formats, generated schemas, or a deliberately stable output.
Testing every permutation in the browser
Push validation edge cases and pure logic into faster tests. Let the browser suite verify that the layers connect.
Chasing coverage as the goal
Coverage can identify untouched code, but a high percentage does not prove useful assertions. A checkout flow with five meaningful scenarios may protect the product better than hundreds of shallow line-execution tests.
Organizing the suite
Keep focused tests near the code they describe. Place cross-route browser flows in a dedicated directory with explicit fixtures and setup. Name tests after behavior:
shows an actionable error when the upload exceeds the limit
is more useful than:
upload test 2
In continuous integration, run fast unit and integration tests early. Run browser tests against a production-like build when possible, and preserve traces or screenshots on failure.
Build a confidence map
List the product’s critical flows and the failure modes that would hurt users most. Then connect each one to the cheapest test layer that can catch it.
| Risk | Best starting layer |
|---|---|
| Price rounding | Unit |
| Form validation and submission | Integration |
| Authentication redirect across routes | End-to-end |
| Third-party outage response | Integration or contract |
| Visual layout regression | Screenshot review for selected states |
This keeps the suite tied to product risk instead of accumulating tests by habit.
Wrap-up
Start with one integration test for the most important interaction, add unit tests for tricky logic, and protect only the critical full journeys with browser tests. A useful suite becomes living documentation: it describes what the product promises and gives the team room to improve how that promise is implemented.
