Ir al contenido

Frontend testing: good practices and patterns

Esta página aún no está disponible en tu idioma.

IMPORTANT: Even if the examples in this guide are based in React, this guide is applicable to every framework or technology based in Javascript and Typescript. This guide has been created to be agnostic in framework.

Good Practices and Patterns in Frontend Testing

Section titled “Good Practices and Patterns in Frontend Testing”

Building a test suite that gives confidence without slowing the team down

Section titled “Building a test suite that gives confidence without slowing the team down”

Frontend testing is often approached from two extremes. In some projects, tests are treated as the solution to every quality problem, while in others they’re something developers write at the end of a task just to satisfy the pipeline. Neither approach works particularly well in practice.

The goal of a test suite isn’t to maximize the number of tests or hit an arbitrary coverage percentage. It’s to give the team enough confidence to change the application without constantly worrying about breaking important behavior. I’ve worked on projects with high coverage where nobody actually trusted the tests, because they failed randomly, mocked most of the application, or were so coupled to implementation details that even a small refactor meant rewriting dozens of files.

I’ve also seen how a better testing strategy, combined with static analysis, clearer responsibilities and a controlled release process, can significantly reduce frontend defects. Tests are rarely the only reason quality improves, but in most teams I’ve worked with, they’re one of the foundations.

This article is framework agnostic. The examples use React, Jest and Testing Library, but the same principles apply to Angular, Vue, Svelte or pretty much any modern frontend stack.


A frontend application contains several kinds of behavior: business rules, data transformations, user interactions, conditional rendering, API communication, loading and error states, navigation and permissions, and critical user flows. Not all of them should be tested the same way.

A calculation function can be tested with inputs and outputs. A form should normally be tested through user interactions. A complete payment or registration flow may require a real browser. A reasonable distribution looks something like this:

Test typeMain purposeCost
UnitIsolated logic and transformationsLow
Component / IntegrationUser behavior and collaboration between frontend piecesMedium
End-to-endComplete critical flowsHigh

The objective isn’t to build a mathematically perfect testing pyramid, but to use the cheapest test that provides enough confidence for the behavior you want to protect.


This is probably the most important principle in frontend testing.

For a pure function, the consumer cares about the returned value. For a component, the consumer is usually the user, and they care about what appears on screen, what they can click, what data gets submitted and what feedback they receive. They don’t care about internal state variable names, whether you use useState or useReducer, private functions, the exact component tree, CSS classes, or how many times a hook gets executed.

Consider this component:

type ReleaseQualityIndicatorProps = {
initialOpenBugs: number;
};
export const ReleaseQualityIndicator = ({
initialOpenBugs,
}: ReleaseQualityIndicatorProps) => {
const [openBugs, setOpenBugs] =
React.useState(initialOpenBugs);
return (
<section>
<p>Open frontend bugs: {openBugs}</p>
<button
type="button"
onClick={() =>
setOpenBugs((currentBugs) =>
Math.max(0, currentBugs - 1),
)
}
>
Mark one as resolved
</button>
</section>
);
};

A useful test verifies the visible behavior:

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
describe("ReleaseQualityIndicator", () => {
it("reduces the number of open bugs when one is resolved", async () => {
const user = userEvent.setup();
render(
<ReleaseQualityIndicator
initialOpenBugs={18}
/>,
);
await user.click(
screen.getByRole("button", {
name: "Mark one as resolved",
}),
);
expect(
screen.getByText("Open frontend bugs: 17"),
).toBeInTheDocument();
});
});

This test keeps working even if you extract the state into a hook, replace useState with useReducer, or reorganize the internal markup, because the behavior stays the same. If a test breaks after a legitimate refactor, and the application still works correctly, that’s usually a sign it was protecting the implementation instead of the behavior.


A simple pattern that works well is Arrange, Act, Assert: arrange the initial data, execute the behavior, and assert the result.

describe("calculateFinalPrice", () => {
it("applies the percentage discount to the original price", () => {
const price = 100;
const discount = 20;
const result = calculateFinalPrice(price, discount);
expect(result).toBe(80);
});
});

The comments aren’t always necessary, what matters is that the test tells a clear story, and test names are part of that story. Avoid names like these:

it("works correctly", () => {});
it("test button", () => {});
it("calls callback", () => {});

And prefer names that describe the expected behavior instead:

it("shows an error message when the request fails", () => {});
it("disables the submit button while the form is being submitted", () => {});
it("redirects unauthenticated users to the login page", () => {});

When a test fails in CI, its name is the first piece of information you get. A clear name can save several minutes of debugging before anyone even opens the file.


Unit tests are especially useful for pure business logic.

type OrderItem = {
price: number;
quantity: number;
};
export const calculateOrderTotal = (
items: OrderItem[],
): number =>
items.reduce(
(total, item) => total + item.price * item.quantity,
0,
);

Testing it is straightforward:

describe("calculateOrderTotal", () => {
it("returns zero for an empty order", () => {
expect(calculateOrderTotal([])).toBe(0);
});
it("adds each item according to its quantity", () => {
const items = [
{
price: 10,
quantity: 2,
},
{
price: 5,
quantity: 3,
},
];
expect(calculateOrderTotal(items)).toBe(35);
});
});

This kind of test also tends to expose architectural problems. If testing a pricing rule requires rendering several providers, mocking the router, initializing a global store and configuring an API client, that rule probably lives in the wrong place.

Logic that often deserves to be extracted includes price calculations, permissions, validation, sorting and filtering, data mapping, state transitions and feature availability. That doesn’t mean turning every line into a utility function, the goal is simply to separate logic that has meaning by itself from the UI that coordinates and displays it.


Component tests should verify the behavior your application adds on top of the framework.

Imagine a form used to filter the frontend issues of a release:

type IssueFilterFormProps = {
onSubmit: (filters: {
team: string;
severity: string;
}) => void;
};
export const IssueFilterForm = ({
onSubmit,
}: IssueFilterFormProps) => {
const [team, setTeam] = React.useState("");
const [severity, setSeverity] =
React.useState("");
const handleSubmit = (
event: React.FormEvent,
) => {
event.preventDefault();
onSubmit({
team,
severity,
});
};
return (
<form onSubmit={handleSubmit}>
<label>
Team
<input
value={team}
onChange={(event) =>
setTeam(event.target.value)
}
/>
</label>
<label>
Severity
<select
value={severity}
onChange={(event) =>
setSeverity(event.target.value)
}
>
<option value="">All</option>
<option value="critical">Critical</option>
<option value="major">Major</option>
<option value="minor">Minor</option>
</select>
</label>
<button type="submit">
Apply filters
</button>
</form>
);
};

The test should interact with it the same way a user would:

describe("IssueFilterForm", () => {
it("submits the filters selected by the user", async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(
<IssueFilterForm
onSubmit={handleSubmit}
/>,
);
await user.type(
screen.getByRole("textbox", {
name: "Team",
}),
"Payments",
);
await user.selectOptions(
screen.getByRole("combobox", {
name: "Severity",
}),
"critical",
);
await user.click(
screen.getByRole("button", {
name: "Apply filters",
}),
);
expect(handleSubmit).toHaveBeenCalledWith({
team: "Payments",
severity: "critical",
});
});
});

Notice we’re not calling private functions or modifying state manually, we’re just typing into the input, selecting a severity and submitting the form. This single test ends up verifying several things at once: fields are associated with labels, state changes when the user interacts with the form, submission works, the correct filters reach the callback, and the button is accessible through its role and name. That usually gives more confidence than testing every internal function separately.


It’s very common to add data-testid attributes everywhere because they’re easy to query.

<button data-testid="save-button">
Save changes
</button>
screen.getByTestId("save-button");

This works, but the user never sees that identifier, it only exists for the test. The button already has a semantic role and a visible name:

screen.getByRole("button", {
name: "Save changes",
});

A practical priority order is:

  1. getByRole
  2. getByLabelText
  3. getByPlaceholderText
  4. getByText
  5. getByTestId

This isn’t a strict rule, though. Test IDs are valid when the element has no useful semantic representation, when content changes with translations, or when you’re testing charts, canvases and other complex visual elements. What matters is not using them by default.

Semantic queries have an added benefit: they expose accessibility issues. If you can’t find an input by its label, chances are the input doesn’t have a proper label either. Tests don’t replace accessibility audits, but they do tend to encourage better markup along the way.


Mock Boundaries, Not the Entire Application

Section titled “Mock Boundaries, Not the Entire Application”

Mocks are necessary, but they can easily create false confidence.

With Jest, you can replace functions or complete modules:

const handleSubmit = jest.fn();
const getUser = jest.fn().mockResolvedValue({
id: "user-1",
name: "Jane",
});

The problem starts when you mock every dependency the component uses. If you mock the API client, store, router, hooks, child components and utilities, you may end up testing that your mocks return exactly what you configured them to return, which isn’t testing much at all.

A better rule is to mock external boundaries: network requests, browser APIs, time, random values, analytics, external SDKs and payment providers. Inside your own application, it’s usually better to keep the real pieces working together.

For API communication, intercepting the request is often better than mocking the complete data hook:

it("shows the books returned by the API", async () => {
server.use(
http.get("/api/books", () =>
HttpResponse.json([
{
id: "book-1",
title: "Dune",
},
]),
),
);
render(<BookList />);
expect(
await screen.findByRole("heading", {
name: "Dune",
}),
).toBeInTheDocument();
});

This single test covers the request, the state update, the loading transition and the final rendering, which ends up being much closer to how the real application behaves.


Domain objects tend to grow over time. What starts like this:

const user = {
id: "1",
name: "John",
};

may eventually require roles, status, permissions, preferences and timestamps. Duplicating complete objects in every test adds noise, and global fixtures aren’t always better either, since a small change can end up affecting many unrelated tests.

A builder provides valid defaults and lets each test override only what matters:

type User = {
id: string;
name: string;
role: "admin" | "member";
status: "active" | "disabled";
};
export const buildUser = (
overrides: Partial<User> = {},
): User => ({
id: "user-1",
name: "Jane Doe",
role: "member",
status: "active",
...overrides,
});

Now the test can focus on the relevant condition:

it("allows administrators to manage users", () => {
const user = buildUser({
role: "admin",
});
expect(canManageUsers(user)).toBe(true);
});

This keeps tests readable and reduces maintenance work whenever the model changes.


Every test should be able to run alone, in any order and, when possible, in parallel. A test shouldn’t depend on data created by a previous test.

let createdUserId: string;
it("creates a user", async () => {
createdUserId = await createUser();
});
it("loads the created user", async () => {
const user = await getUser(createdUserId);
expect(user).toBeDefined();
});

The second test here only works if the first one runs before it, which is fragile. A better version arranges its own state:

it("loads an existing user", async () => {
const createdUser = await createUser();
const user = await getUser(createdUser.id);
expect(user).toEqual(createdUser);
});

Tests should also reset anything that can leak between runs, such as mocks, fake timers, local storage, network handlers, global variables or modified browser APIs. For example:

afterEach(() => {
jest.restoreAllMocks();
});

In my experience, shared and uncontrolled state is one of the most common reasons a suite becomes unreliable over time.


Frontend applications contain asynchronous behavior almost everywhere: requests, state updates, debounced inputs, lazy loading, background validation, timers. If content appears asynchronously, don’t assert immediately:

render(<UserProfile />);
expect(
screen.getByText("Jane Doe"),
).toBeInTheDocument();

Wait for the expected condition instead:

render(<UserProfile />);
expect(
await screen.findByText("Jane Doe"),
).toBeInTheDocument();

A practical rule I tend to follow: use getBy when the element should already exist, queryBy when checking that something does not exist, findBy when something appears asynchronously, and waitFor when you’re waiting for an assertion or state transition.

Try to avoid arbitrary delays like this one:

await new Promise((resolve) => {
setTimeout(resolve, 1000);
});

This makes tests slower and still doesn’t guarantee the application is actually ready. It’s better to wait for the condition that matters instead of a random amount of time.


Coverage tells you which lines and branches were executed. It doesn’t tell you whether the assertions are useful, whether important scenarios are protected, whether tests are coupled to implementation, whether the suite is reliable, or whether users can actually complete critical flows.

This test increases coverage but protects nothing at all:

it("executes the function", () => {
calculatePrice(100, 20);
});

Coverage is still useful for finding completely untested modules, missing branches, new code without tests, and critical areas with low protection. What I’d avoid is forcing the same percentage on every file, a payment rule, a formatting utility and a decorative icon simply don’t carry the same risk.

A more mature approach combines coverage thresholds with team judgment and higher expectations for business-critical code. Adding meaningless tests just to satisfy the metric improves the dashboard, not the application.


When a bug reaches production, it means we found a scenario the existing checks didn’t protect. The fix should usually follow this process: reproduce the bug with a test, confirm the test fails, implement the fix, confirm the test passes, and keep the test.

For example, if a discount is applied twice after repeated clicks:

it("applies the discount only once", async () => {
const user = userEvent.setup();
render(<DiscountForm />);
await user.type(
screen.getByRole("textbox", {
name: "Discount code",
}),
"SAVE20",
);
const button = screen.getByRole("button", {
name: "Apply discount",
});
await user.click(button);
await user.click(button);
expect(
screen.getByText("Final price: 80"),
).toBeInTheDocument();
});

Regression tests are especially valuable because they protect against failures that have already happened in the real system, and production bugs are good evidence of where the application tends to be fragile. It’s worth using that information.


A flaky test sometimes passes and sometimes fails without a relevant code change. The usual response is to just run the pipeline again, assume it only fails in CI, decide it’s always been unstable, and move on. Once developers stop trusting failures, though, the test suite loses most of its value.

Common causes include shared state, uncontrolled timers, real network requests, arbitrary waits, random data, unstable selectors, missing cleanup and dependencies between tests.

Retries can be useful for diagnosing infrastructure issues, especially in end-to-end testing, but they shouldn’t become the permanent solution. A flaky test should be treated as a defect: reproduce it, identify the uncontrolled dependency, and make it deterministic. A smaller trusted suite is far more useful than a large one everybody quietly ignores.


Protect Critical Flows with End-to-End Tests

Section titled “Protect Critical Flows with End-to-End Tests”

End-to-end tests provide strong confidence because they run the application in a real browser, but they’re also slower and more expensive to maintain. The mistake is trying to test every validation rule and component state at this level.

End-to-end tests should focus on workflows where the integration itself is the risk: authentication, registration, checkout, payment, password recovery, role-based access, and creating or editing important entities.

For example:

test("an authenticated user can create a project", async ({
page,
}) => {
await page.goto("/projects");
await page.getByRole("button", {
name: "Create project",
}).click();
await page.getByRole("textbox", {
name: "Project name",
}).fill("Frontend testing");
await page.getByRole("button", {
name: "Save project",
}).click();
await expect(
page.getByRole("heading", {
name: "Frontend testing",
}),
).toBeVisible();
});

The same principles still apply here: semantic queries, controlled data, isolation and no arbitrary waits. End-to-end testing isn’t really a different philosophy, it’s the same one applied to a larger part of the system.


For a new frontend project, I’d usually start with something like this.

For pure business logic, use Jest without rendering UI, focusing on important branches, boundary values, invalid inputs and previous regressions. For components and features, test through the DOM, focusing on user interactions, visible states, accessibility and collaboration between your own components. For external communication, mock the external boundary and focus on successful responses, errors, empty results and delayed responses. For end-to-end, protect a small number of high-value workflows, focusing on authentication, permissions and critical business processes.

And finally, keep improving continuously: use real failures to evolve the suite, add regression tests for production bugs, remove flaky tests, and review tests that constantly break during valid refactors. A testing strategy shouldn’t be configured once and forgotten, it needs to evolve with the application and with the problems the team actually runs into.


Good frontend testing isn’t about writing the maximum number of tests. It’s about placing the right tests around the behaviors that matter. Unit tests protect isolated logic, component and integration tests protect user interactions and collaboration between frontend pieces, and end-to-end tests protect the most important complete workflows.

The quality of a suite becomes obvious the moment the code changes: whether you can refactor without rewriting every test, whether a failure actually explains what behavior broke, whether tests can run in any order, whether developers trust the results, whether production bugs get converted into regression tests, and ultimately whether the suite helps the team release or has just become another obstacle in the pipeline.

A project can have thousands of tests and still be risky to change. Another can have a much smaller suite that protects its critical behavior extremely well. Test from the user’s perspective whenever possible, keep business logic easy to isolate, mock external boundaries instead of your own application, and treat flaky tests as real defects.

Tests are production code too, and they need clear names, controlled dependencies and continuous maintenance just like everything else.