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 () => {
// <strong>Arrange</strong>
render(<PaymentProfiles />);
// <strong>Act</strong>
fireEvent.click(await screen.findByTestId(testIds.addButton));
// <strong>Assert</strong>
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 () => {
// <strong>Arrange</strong>
server.use(
graphql.query('GetCardPaymentProfiles', (_, __, ctx) =>
resDelay(ctx.status(500)),
),
);
render(<PaymentProfiles />);
// <strong>Assert</strong>
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('<strong>should increment and decrement the counter</strong>', () => {
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');
});
✅ Prefertesting only one aspect of the component’s behavior and functionality.
it('<strong>should increment the counter</strong>', () => {
render(<Counter initialCount={0} />);
fireEvent.click(screen.getByText('Increment'));
expect(screen.getByTestId('count')).toHaveTextContent('1');
});
it('<strong>should decrement the counter</strong>', () => {
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('<strong>should load and display invoices</strong>', async () => {
renderComponent();
expect(screen.getByTestId(testIds.loading)).toBeInTheDocument();
await waitForElementToBeRemoved(() =>
screen.getByTestId(testIds.loading),
);
expect(await screen.findByTestId(testIds.invoices)).toBeInTheDocument();
<strong>expect(screen.getByTestId(testIds.invoices)).toMatchSnapshot();</strong>
});
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('<strong>Invoices</strong>', () => {
//
// <strong>Happy Path</strong>
//
it('<strong>should load and display invoices</strong>', 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('<strong>Invoices</strong>', () => {
//
// <strong>Edge Cases</strong>
//
it('<strong>should load and display empty message</strong>', 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('<strong>should not display empty message if refetching data</strong>', 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();
});
//
// <strong>Errors</strong>
//
it('<strong>should display server error</strong>', 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('<strong>should log in and see the dashboard</strong>', 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.
//
// <strong>Meaningless test only to satisfy test coverage metrics</strong>
//
it('<strong>should log in and see the dashboard</strong>', async () => {
render(<App />);
// <strong>No assertions</strong>
});
✅ Prefer adding valuable tests that verify the component’s behavior and functionality.
it('<strong>should log in and see the dashboard</strong>', async () => {
// <strong>Arrange</strong>
render(<App />);
// <strong>Act</strong>
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'testuser' } });
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password' } });
fireEvent.click(screen.getByText('Log In'));
// <strong>Assert</strong>
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.
⭐ 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
