Writing Foolproof React Tests
Intro
Throughout my career, I have always felt fortunate when I had the chance to work with people who truly enjoy writing good unit tests. I could be certain of one thing: they cared about high code quality - which in turn, let me always rely on their work.
Pairing with people like these has always been a great experience. Step-by-step I quickly realized this is the correct way to develop software and I started encouraging both myself and other developers to write more unit tests.
Having spent quite some time with UI libraries like Vue.js and React, often building custom components from scratch, my go-to library for frontend testing quickly became the popular Testing Library.
Over the time I've collected tips that I found most useful for writing frontend tests. While the examples are primarily written using React (as it's the library I use most often these days) the good thing about testing library is that it’s framework-agnostic, which means these tips can be applied to other frameworks as well!
All the examples in the article will be demonstrated testing ArticleCard
and UserProfile
components from a simple blog app, which can be previewed here:
Let’s dive in.
Avoid excessive mocking
The theory you can read around unit testing often suggests that all external calls from a function should be mocked so the unit test only tests the isolated function itself.
This sounds good at first sight, but what usually happens is that people end up mocking too much of the behaviour. This way, even if you reach 90-100% coverage, your tests often miss capturing how users actually interact with the system. Instead, you are testing your own mocked behavior and focusing too much on implementation details.
The philosophy often described in RTL (React Testing Library) docs states you should test your application as it is used by users, without any mocking if possible. Tests should focus on behavior (instead of implementation details). You perform actions (mouse clicks or keyboard presses) on the rendered DOM nodes and expect visual outcomes of the UI, usually only mocking the outside API calls or state in the global store. This way you are able to test your application as intended for real use.
Avoid too much nesting
This is a habit often seen especially with tools like Jest or Vitest, and is not only related to frontend testing alone. For some reason people like to group test cases into deeply nested structure of describe → it blocks without a strong reason.
But what exactly is “too much”? Let’s have a look:
// ❌ Too much nesting combined with beforeEach,
// difficult to follow due to indentation and line length limits.
describe('UserProfile', () => {
let user: User;
let userEvt: UserEvent;
beforeEach(() => {
vi.clearAllMocks();
});
beforeEach(() => {
user = {
id: '1',
displayName: 'Bill Gates',
email: 'bill.g@example.com',
avatar: 'https://example.com/avatar.jpg',
};
});
describe('when editing profile', () => {
beforeEach(() => {
vi.spyOn(api, 'getCurrentUser').mockResolvedValue(user);
userEvt = userEvent.setup();
renderWrapped(<UserProfile />);
});
it('renders user profile form', async () => {
expect(await screen.findByText('Profile Settings')).toBeVisible();
expect(screen.getByLabelText('Display Name')).toBeVisible();
expect(screen.getByLabelText('Email')).toBeVisible();
expect(
screen.getByRole('button', { name: 'Save' }),
).toBeVisible();
});
describe('and form is submitted', () => {
beforeEach(() => {
vi.spyOn(api, 'updateUser').mockResolvedValue(user);
});
it('shows success message after successful save', async () => {
const nameInput = await screen.findByDisplayValue('Bill Gates');
await userEvt.type(nameInput, 'John Doe');
await userEvt.click(
screen.getByRole('button', { name: 'Save' }),
);
expect(
await screen.findByText('Profile updated'),
).toBeVisible();
});
});
});
});
Sure it will look a bit more structured in the test results output:
and you are reducing some repetition, but on the other hand sacrificing readability, reducing available line length for every test case you write.
It’s usually not worth it and 2 levels are more than enough:
// ✅ Only 2 levels of nesting,
// more readable, focused, easier to follow.
describe('UserProfile', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders user profile form', async () => {
const user = {
id: '1',
displayName: 'Bill Gates',
email: 'bill.g@example.com',
avatar: 'https://example.com/avatar.jpg',
};
vi.spyOn(api, 'getCurrentUser').mockResolvedValue(user);
renderWrapped(<UserProfile />);
expect(await screen.findByText('Profile Settings')).toBeVisible();
expect(screen.getByLabelText('Display Name')).toBeVisible();
expect(screen.getByLabelText('Email')).toBeVisible();
expect(screen.getByRole('button', { name: 'Save' })).toBeVisible();
});
it('shows success message after successful save', async () => {
const user = {
id: '1',
displayName: 'Bill Gates',
email: 'bill.g@example.com',
avatar: 'https://example.com/avatar.jpg',
};
const userEvt = userEvent.setup();
vi.spyOn(api, 'getCurrentUser').mockResolvedValue(user);
vi.spyOn(api, 'updateUser').mockResolvedValue(user);
renderWrapped(<UserProfile />);
const nameInput = await screen.findByDisplayValue('Bill Gates');
await userEvt.type(nameInput, 'New Name');
await userEvt.click(screen.getByRole('button', { name: 'Save' }));
expect(await screen.findByText('Profile updated')).toBeVisible();
});
});
and the test results are still easy to understand:
Use factory methods to arrange data
Factory methods are a nice way of encapsulating data needed for the arrange phase of each test case. By using them exclusively (instead of assigning plain objects to a variable) you ensure consistency.
Let’s have a look at disadvantages of plain objects:
// ❌ Plain object - fixed values, global,
// changes to object will leak between test cases.
export const article: Article = {
id: '123',
image: 'https://example.com/plant.jpg',
title: 'Top 50 underrated plants',
category: 'indoor-plants',
author: {
name: 'Elsa Gardenowl',
avatar: 'https://example.com/avatar.jpg',
},
postedAt: '2024-01-01T00:00:00.000Z',
likes: 733,
};
Now with this approach you can either export the object defined globally (not good because one test case can influence results of another one if you are not careful) or you can redefine it for each test case which will add a lot of unnecessary noise to the file.
Another thing to mention here - it’s important to always keep relevant data close to your test case. Therefore, you should avoid doing this:
// ❌ Context is hard to find, spread through the whole file.
// Resetting global variable in beforeEach, rendering outside.
import { render, screen } from '@testing-library/react';
describe('ArticleCard', () => {
let article: Article;
beforeEach(() => {
article = {
id: '123',
image: 'https://example.com/plant.jpg',
title: 'Top 50 underrated plants',
category: 'indoor-plants',
author: {
name: 'Elsa Gardenowl',
avatar: 'https://example.com/avatar.jpg',
},
postedAt: '2024-01-01T00:00:00.000Z',
likes: 733,
};
render(<ArticleCard article={article} />);
});
it('renders with name of the author', () => {
// ...
});
it('renders title and image', () => {
// ...
});
it('does something else', () => {
// ...
});
it('shows number of likes', () => {
// ❓ How is it possible that component is rendered already?
// ❓ Where is 733 coming from exactly?
screen.getByText('733 people liked this');
// 🕵🏼♀️ Oh I need to look elsewhere.
});
});
Instead it’s better to define a factory method (there is a great library for generating test data called faker.js) somewhere in a shared file (e.g. factories.ts
):
// ✅ Defining factory method in common place,
// allows for overrides, ensures consistency and isolation.
import { Article } from '../api/types';
import { faker } from '@faker-js/faker';
export function createArticle(
overrides: Partial<Article> = {},
): Article {
return {
id: faker.string.uuid(),
image: faker.image.urlPicsumPhotos(),
title: faker.lorem.words({ min: 3, max: 7 }),
category: faker.helpers.arrayElement([
'indoor-plants',
'care',
'gardening',
]),
author: {
id: faker.string.uuid(),
name: faker.person.fullName(),
avatar: faker.image.avatar(),
},
postedAt: faker.date.recent(),
likes: faker.number.int({ min: 0, max: 1000 }),
...overrides,
};
}
And then use this method in the beginning of the each test case:
// ✅ With factory method tests are more readable,
// context is concentrated where it's needed the most.
import { createArticle } from '../tests/factories';
import { render, screen } from '@testing-library/react';
it('renders number of likes', () => {
const article = createArticle({ likes: 733 });
// ...
render(<ArticleCard article={article} />);
expect(screen.getByText('733 people liked this')).toBeVisible();
});
Here the method reduces noise because you can leave a lot of values up to function defaults and only focus on attribute relevant for the test case - number of likes.
Don’t worry about repetition
Developers are usually taught that repetition is very problematic and that we, as programmers, should avoid it at all costs. This is true in general, but more often, a wrong abstraction is worse than repetition.
In tests, you can worry about repetition less. Usually, when a test fails, you want to resolve the issue quickly, and you don’t want to sift through layers of clever abstractions defined somewhere completely outside of your test case, or even worse, outside of your test file.
I’d sum it up like this - the best test case is the one your less senior co-worker (or your future self) can understand the fastest without too much looking around.
Use screen queries
React Testing Library provides two ways of calling queries (getBy*
or similar). One way is to destructure the queries from render results:
// ❌ Using destructuring from render.
import { ArticleCard } from './ArticleCard';
import { createArticle } from '../tests/factories';
import { renderWrapped } from '../tests/wrappers';
describe('ArticleCard', () => {
it('renders article information', () => {
const article = createArticle({
title: 'Top 50 indoor plants',
likes: 733,
});
const { getByText, getByAltText } = renderWrapped(
<ArticleCard article={article} />,
);
expect(getByText('Top 50 indoor plants')).toBeVisible();
expect(getByText('733 people liked this')).toBeVisible();
expect(getByAltText('Top 50 indoor plants')).toBeVisible();
});
});
This is usually cumbersome because queries inside a test case change pretty often as the UI parts evolve.
Instead, it’s more practical to just import screen object from the testing library and call the queries directly on that object:
// ✅ Using imported screen object
import { screen } from '@testing-library/react';
import { ArticleCard } from './ArticleCard';
import { createArticle } from '../tests/factories';
import { renderWrapped } from '../tests/wrappers';
describe('ArticleCard', () => {
it('renders article information', () => {
const article = createArticle({
title: 'Top 50 indoor plants',
likes: 733,
});
renderWrapped(<ArticleCard article={article} />);
expect(screen.getByText('Top 50 indoor plants')).toBeVisible();
expect(screen.getByText('733 people liked this')).toBeVisible();
expect(screen.getByAltText('Top 50 indoor plants')).toBeVisible();
});
});
Always prefer to query by accessible role
This one is crucial in RTL. When you start writing your first tests in this library, you intuitively reach for the getByText
query most of the time. While this query works and your tests quickly become green, it should not be your first choice. Very often, you can query elements with getByRole
instead, which is more specific. Let’s again demonstrate it on our ArticleCard
:
// ❌ Less reliable approach using getByText
// will pass even if the like button is changed to differen element.
describe('ArticleCard', () => {
it('renders like button', () => {
const article = createArticle({ likes: 733 });
renderWrapped(<ArticleCard article={article} />);
expect(screen.getByText('Like')).toBeVisible();
});
});
Now if you change the rendered content and instead of a button (let’s say by mistake) you change it to something else - for example a <p>Like</p>
element (which makes no sense) your test won’t prevent you from doing so and still hapilly reports green success. However with getByRole
you can easily prevent such mistake from happening:
// ✅ With getByRole you cane be sure
// the element is actually a clickable button.
describe('ArticleCard', () => {
it('renders like button', () => {
const article = createArticle();
renderWrapped(<ArticleCard article={article} />);
expect(screen.getByRole('button', { name: 'Like' })).toBeVisible();
});
});
Conclusion
That’s it! If all of these tips were already familiar to you that’s great because it means you write some easy to understand and highly maintainable unit tests. If not then it’s even better because now you know how to improve your frontend test suite.
Subscribe to my newsletter
Read articles from Peter Babinec directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Peter Babinec
Peter Babinec
Fullstack dev, crafting web apps for 8+ years. Vue, React, Python, Node.js are my tools. I strive for clean, tested code. Always improving. Topics: web dev, best practices, agile, testing. Enjoy!