9 React Testing Best Practices for Better Design and Quality of Your Tests
A Senior Engineer’s Guide to Effective React Testing (includes Cheat Sheet) (5 min)
Many developers struggle to make their tests both effective and efficient.
Solid testing is a must-have if you care about your application, customers, and business.
As a Senior Software Engineer with experience in testing and software design, I’ve read, reviewed, and written many tests.
Over the years, I’ve distilled a set of best practices that significantly improved the quality and maintainability of my tests.
In this blog post, I will share 9 tips to help you write and design better tests in your React applications.
1. Favor Arrange-Act-Assert (AAA) pattern
The Arrange-Act-Assert (AAA) pattern brings clarity and structure to your tests.
By dividing your test into three distinct parts, you make it easier to read, follow, understand, and maintain.
This pattern helps prevent tests from becoming complex and 🍝.
This ensures that each test focuses on a specific behavior of the app.
In summary, the Arrange-Act-Assert (AAA) pattern helps with readability and consistency, allowing you to grasp what the test is verifying for others and future you.
it('should toggle create payment profile dialog', async () => {
// Arrange
render(<PaymentProfiles />);
// Act
fireEvent.click(await screen.findByTestId(testIds.addButton));
// Assert
const dialog = await screen.findByRole('dialog');
expect(dialog).toBeInTheDocument();
});
Sometimes we might not need the Act and that’s fine.
it('should display server error', async () => {
// Arrange
server.use(
graphql.query('GetCardPaymentProfiles', (_, __, ctx) =>
resDelay(ctx.status(500)),
),
);
render(<PaymentProfiles />);
// Assert
expect(await screen.findByTestId(testIds.error)).toBeInTheDocument();
});
2. Avoid testing too many things at once
Testing multiple functionalities in a single test can make bugs and issues hard to find.
It’s better to write smaller, focused tests that cover only one aspect of the component’s behavior and functionality.
This simplifies debugging and ensures each test has a clear purpose.
It also reduces the cognitive load when maintaining the tests since you’re focused only on one scenario.
⛔ Avoid testing too many things at once.
it('should increment and decrement the counter', () => {
render(<Counter initialCount={0} />);
fireEvent.click(screen.getByText('Increment'));
expect(screen.getByTestId('count')).toHaveTextContent('1');
fireEvent.click(screen.getByText('Decrement'));
expect(screen.getByTestId('count')).toHaveTextContent('0');
});
✅ Prefer testing only one aspect of the component’s behavior and functionality.
it('should increment the counter', () => {
render(<Counter initialCount={0} />);
fireEvent.click(screen.getByText('Increment'));
expect(screen.getByTestId('count')).toHaveTextContent('1');
});
it('should decrement the counter', () => {
render(<Counter initialCount={1} />);
fireEvent.click(screen.getByText('Decrement'));
expect(screen.getByTestId('count')).toHaveTextContent('0');
});
3. Be careful with snapshot tests
Snapshot tests can be helpful but they can also become a maintenance headache.
They should be treated carefully.
Over-reliance on snapshots can lead to neglecting tests that don’t effectively test components’s scenarios and catch regressions.
If you have snapshots that are too broad, they will always fail due to insignificant changes.
As a rule of thumb, I prefer to add snapshot tests for “dummy” or stateless UI components and not for stateful ones.
This way if a style is not applied or changed due to a bug, the snapshot test will fail and someone will have to look into it.
Another place where snapshot tests can be useful is for critical components with stable structures.
Keep snapshot tests small and focused.
it('should load and display invoices', async () => {
renderComponent();
expect(screen.getByTestId(testIds.loading)).toBeInTheDocument();
await waitForElementToBeRemoved(() =>
screen.getByTestId(testIds.loading),
);
expect(await screen.findByTestId(testIds.invoices)).toBeInTheDocument();
expect(screen.getByTestId(testIds.invoices)).toMatchSnapshot();
});
4. Test the Happy Path first
Start by testing the most common and expected use cases of your components.
Ensure that the core functionality and business logic works as expected before diving into edge cases.
This way you verify that the component behaves correctly in the main case with normal conditions, providing a solid foundation for further testing.
If the core business case doesn’t work, what’s the chance that other edge cases will work as expected?
describe('Invoices', () => {
//
// Happy Path
//
it('should load and display invoices', async () => {
renderComponent();
expect(screen.getByTestId(testIds.loading)).toBeInTheDocument();
await waitForElementToBeRemoved(() =>
screen.getByTestId(testIds.loading),
);
expect(await screen.findByTestId(testIds.invoices)).toBeInTheDocument();
expect(screen.getByTestId(testIds.invoices)).toMatchSnapshot();
});
});
5. Test Edge Cases and Errors
After you verified that the happy path works as expected, continue with testing how your component handles edge cases and errors like invalid inputs, delayed requests, etc.
This ensures correctness and robustness by verifying that the component can handle real-world scenarios gracefully.
describe('Invoices', () => {
//
// Edge Cases
//
it('should load and display empty message', async () => {
server.use(
graphql.query('GetInvoices', (_, __, ctx) =>
resDelay(
ctx.status(200),
ctx.data({
viewer: { account: { invoices: { nodes: [] } } },
}),
),
),
);
renderComponent();
expect(await screen.findByText(/No Invoices/)).toBeInTheDocument();
});
it('should not display empty message if refetching data', async () => {
queryClient.setDefaultOptions({
queries: {
refetchOnMount: 'always',
initialData: [],
},
});
renderComponent();
expect(screen.getByTestId(testIds.loading)).toBeInTheDocument();
expect(screen.queryByText(/No Invoices/)).toBeNull();
await waitForElementToBeRemoved(() =>
screen.getByTestId(testIds.loading),
);
expect(await screen.findByTestId(testIds.invoices)).toBeInTheDocument();
expect(screen.queryAllByText(/PDF/)[0]).toBeInTheDocument();
});
//
// Errors
//
it('should display server error', async () => {
server.use(
graphql.query('GetInvoices', (_, __, ctx) =>
resDelay(ctx.status(500)),
),
);
renderComponent();
expect(await screen.findByTestId(testIds.error)).toBeInTheDocument();
});
});
6. Focus on Integration tests
Integration tests verify that different parts of your application work together as expected.
These type of tests have a higher chance to catch issues that unit tests might miss.
Integration tests provide confidence that the system works as a whole, not just isolated units.
The ROI (Return on Investment) of the integration tests is much higher compared to unit tests and E2E tests.
This doesn’t mean you don’t need them but for sure you should have more integration tests.
The more your tests resembles the way your software is used, the more confidence they can give you.
it('should log in and see the dashboard', async () => {
render(<App />);
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'testuser' } });
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password' } });
fireEvent.click(screen.getByText('Log In'));
expect(await screen.findByText('Welcome to your dashboard')).toBeInTheDocument();
});
7. Don’t test third-party libraries
Your tests should focus on your code and application, not the internal functionality of external libraries.
Trust that well-maintained libraries have their own tests.
Testing third-party modules can lead to fragile tests which can break when the library updates, no matter if you haven’t changed their usage.
⛔ Avoid testing the internals of third-party modules.
✅ Prefer testing how your component works with the third-party library.
it('should not display empty message if refetching data', async () => {
queryClient.setDefaultOptions({
queries: {
refetchOnMount: 'always',
initialData: [],
},
});
renderComponent();
expect(screen.getByTestId(testIds.loading)).toBeInTheDocument();
expect(screen.queryByText(/No Invoices/)).toBeNull();
await waitForElementToBeRemoved(() =>
screen.getByTestId(testIds.loading),
);
expect(await screen.findByTestId(testIds.invoices)).toBeInTheDocument();
expect(screen.queryAllByText(/PDF/)[0]).toBeInTheDocument();
});
8. Don’t focus on test coverage percentage
If you have 100% test coverage, this doesn’t mean high-quality tests or no bugs at all.
It’s better to focus on meaningful tests, instead of adding tests chasing coverage metrics.
⛔ Avoid writing tests that only serve to increase test coverage.
//
// Meaningless test only to satisfy test coverage metrics
//
it('should log in and see the dashboard', async () => {
render(<App />);
// No assertions
});
✅ Prefer adding valuable tests that verify the component’s behavior and functionality.
it('should log in and see the dashboard', async () => {
// Arrange
render(<App />);
// Act
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'testuser' } });
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password' } });
fireEvent.click(screen.getByText('Log In'));
// Assert
expect(await screen.findByText('Welcome to your dashboard')).toBeInTheDocument();
});
9. Remove unnecessary tests
As your application and codebase evolve, some tests might become redundant or irrelevant.
Regularly review and clean up your tests.
Tests are part of the codebase, so they should be treated as such - regularly reviewed and updated.
This reduces maintenance overhead and keeps your tests lean and efficient.
So when a feature is deprecated or a component is removed, delete the related tests.
🎁 Cheat Sheet: 9 React Testing Best Practices
Paid subscribers, you can get it here: 🎁 Products for Paid Subscribers.
⭐ TL;DR
Favor Arrange-Act-Assert (AAA) pattern
Avoid testing too many things at once
Be careful with snapshot tests
Test the Happy Path first
Test Edge Cases and Errors
Focus on Integration tests
Don’t test third-party libraries
Don’t focus on test coverage percentage
Remove unnecessary tests
👋 Let’s connect
You can find me on LinkedIn or Twitter.
I share daily practical tips to level up your skills and become a better engineer.
Thank you for being a great supporter, reader, and for your help in growing to 11.9K+ subscribers this week 🙏
This newsletter is funded by paid subscriptions from readers like yourself.
If you aren’t already, consider becoming a paid subscriber to receive the full experience!
Think of it as buying me a coffee twice a month, with the bonus that you also get all my templates and products for FREE.
You can also hit the like ❤️ button at the bottom to help support me or share this with a friend to get referral rewards. It helps me a lot! 🙏
👏 Weekly Shoutouts
7 Must-Know Lessons To Be A Better Engineer From Top Industry Leaders by
Amazon Frugal Architecture Explained by
Master The 5 Types of Mocks by
You don’t need to be a manager to have a successful career in the engineering industry by
System design isn't a Cut & Paste job by
SQL vs NoSQL - 7 Key Differences You Must Know by
Totally agree on these tips, Petar. I didn't know about the A-A-A pattern name either, but fun that I ended up doing that naturally, now I have a name for it.
Thanks as well for the article shout-out 🙏
100% on "Focus on Integration tests".
The more your tests mimic real user behavior, the more confident you can be that your software will work as expected.
Thanks for the mention, Petar!