Unveiling 6 Anti-Patterns in React Test Code: Pitfalls to Avoid

Juntao Qiu
ITNEXT
Published in
7 min readMay 29, 2023

--

Defects are inevitable in software development, stemming from unclear requirements, logic errors, or overlooked edge cases. Detecting and fixing these defects can be a time-consuming and costly process. Writing effective tests is the most cost-effective way to prevent or minimize severe defects. Tests play a crucial role in any real-world project, serving as a tool to identify potential issues and safeguard developers. However, for tests to fulfil their purpose, they must be correct and easy to understand.

Photo by Nicolas Thomas on Unsplash

In this article, we will explore common anti-patterns frequently found in test code. We aim to help you create more resilient and maintainable test code by addressing these issues. With improved test code, you can confidently make changes to the product code, knowing that your tests provide a safety net to catch any potential defects.

Treat the test code differently.

The most common issue I’ve seen is developers treat test code as a second citizen. They use test code as a temporary snippet collection. You may find helper functions, commented-out code and unused code in a test file.

import { render, screen } from "@testing-library/react";
import { Tree } from "./Tree";
import userEvent from "@testing-library/user-event";

function insertNode(tree: Tree, node: TreeNode): Tree {
if (tree.name === node.parent) {
return {
name: tree.name,
items: [{ name: node.name, items: [] }, ...tree.items],
};
}
return {
...tree,
items: tree.items.map((item) => insertNode(item, node)),
};
}

describe("tree component", () => {
// it('renders a single node', () => {
// render(<Tree data={{ name: 'root', items: []}}/>)
// expect(screen.getByText('root')).toBeInTheDocument();
// })

it("renders multiple nodes", () => {
const data = {
name: "root",
items: [{ name: "child1", items: [] }],
};
render(<Tree data={data} />);
expect(screen.getByText("root")).toBeInTheDocument();
expect(screen.queryByText("child1")).not.toBeInTheDocument();
});

// ...
});

In product code, there are code standards like how to name a function, how to arrange the import order and the overall length of a function. But they don’t apply the same rule to test code because they are JUST some tests.

Test code should be treated equally to product code. If you have linting rules or any clean code standards, apply them to the test as well. Remove all the dead code, name the function with meaningful names and make it as clean.

Put Complicated Logic in Tests.

Recently, a reader shared some test code snippets with me for review. While the tests were well-structured, I noticed a common issue that I’ve encountered in previous projects: complicated logic within the tests themselves. This issue can potentially cause problems and hinder test maintenance and understanding. This article will delve into the pitfalls of complex test logic and explore strategies for keeping tests simple, focused, and effective. By addressing this issue, we can ensure that our test code remains maintainable and provides reliable feedback on the behaviour of our software.

import { compose, flatten, length, pluck } from 'ramda';
import reducer from './redux/slices/someslice'
import data from './data';

it('should do some work', () => {
const action = {type: 'SOME_ACTION', payload: {} }
const expectation = compose(length, flatten, pluck('OS'))(data);
const reulst = reducer([], action);
expect(result).toEqual(expectation);
});

In the world of testing, simplicity is key. A good test typically consists of three essential steps: Arrange, Action, and Assertion. During the Arrange phase, we set up the necessary data for the function being tested. The Action stage involves invoking the function, and finally, in the Assertion phase, we verify if the result aligns with our expectations. If you find yourself doing extensive work in any of these steps, it’s likely an indication that you’re overcomplicating things. Remember, keeping each step focused and concise is crucial for effective testing.

The issue in the code above is the usage of compose. It’s ok to use ramda or whatever library fits your need, but as there are more moving parts joined in your test, it’s hard to tell which one went wrong. For example, if the test failed, it could be one of the helper functions from ramda, or the data is incorrectly prepared, or the implementation isn’t correct.

So replace the calculation in the test above with the actual value you expect would be much cleaner. For example, expect(result).toEqual(10) .

Overuse Mocking

I’ve discussed this issue in my article, but the problem is using too many mocks. Although mocks are inevitable in cases, you should be aware of the dark side of mocking. It could cause a false negative case, and the defect can only be found when it’s too late.

For example, Contact will fetch data from an API endpoint /api/contacts/<id> and render the returned data. The test would pass as long as we’re using axios.get to fetch the data. But what if the returned structure changed, e.g., name to fullName? Or what happens if an error occurs, e.g. missed API-Key? The test will still pass, but the function is broken already.

import axios from "axios";
import Contact from "./Contact";

jest.mock("axios");

it("renders contact data correctly", () => {
const mockContact = { name: "John Doe", email: "john@example.com" };
axios.get.mockResolvedValue({ data: mockContact });

render(<Contact />);

expect(screen.getByText(mockContact.name)).toBeInTheDocument();
expect(screen.getByText(mockContact.email)).toBeInTheDocument();
});

It’s vital to notice that you need other types of tests (integration tests or UI tests) to cover different paths.

Separate Fixture Files

Sometimes you will need a large fixture file that contains either the backend response or some result from a complicated calculation. For example, a JSON file or XML response from another server.

We’ll then need to import and use the file in the test. That’s fine to do this in an integration test. Combined with a mock can probably simulate how different components can handle the “real” response.

import axios from "axios";
import Contact from "./Contact";

import contacts from './mock/contacts';

jest.mock("axios");

it("renders contact data correctly", () => {
axios.get.mockResolvedValue({ data: contacts[0] });

render(<Contact />);

expect(screen.getByText(mockContact.name)).toBeInTheDocument();
expect(screen.getByText(mockContact.email)).toBeInTheDocument();
});

But it’s now a bit hard to tell what the data look like in the test code. If the data isn’t too large, especially when I need only a small fraction of the data. I would define an in-file variable to demonstrate what we expect, and then use it in the test.

import axios from "axios";
import Contact from "./Contact";

jest.mock("axios");

const contact = { name: "John Doe", email: "john@example.com" }

it("renders contact data correctly", () => {
axios.get.mockResolvedValue({ data: contact });

render(<Contact />);

expect(screen.getByText(mockContact.name)).toBeInTheDocument();
expect(screen.getByText(mockContact.email)).toBeInTheDocument();
});

A better approach is to split the component into a container and a presentational component: the container sends a request to get data, while the presentational only renders whatever is passed in through prop. That way, we can quickly test the presentational component in pure unit tests without any mocks (remember that mock can be evil) and a single integration test for the container.

Lack of Unit Tests

A healthy software project should structure the test suite in a particular way. It should follow the test pyramid in some way. The benefit of having the structure is that it’s easier to diagnose the problem when something goes wrong. Also, the test suite runs relatively fast during the development process.

I found it’s pretty easy to slip into the integration tests when a few unit tests could be enough and more appropriate.

Take the Weather Application as an example. We could have three different types of tests.

The test structure of our Weather Application

Note that the number of unit tests is much more than the higher-level tests. They provide faster feedback and can help us to debug and identify defects much quicker.

Unorganised Test Files.

In all the test frameworks, some APIs can help us to organise the test reasonably. For example, if we’re testing a calculator class, it’s good to have a structure like this:

describe("calculator", () => {
it("should perform addition", () => {});
it("should perform subtraction", () => {});
it("should perform multiplication", () => {});
it("should perform division", () => {});
});

If the addition is more complicated than the other three, we can group them even further with describe function:

describe("calculator", () => {
describe("should perform addition", () => {
it("adds two positive numbers", () => {});
it("adds two negative numbers", () => {});
it("adds one positive and one negative numbers", () => {});
});
});

This is similar to the first anti-pattern, and you should treat the test code as you would organise your product code. Separate Concerns, Applying Single Responsibility, and so on to make the tests easy to understand.

Conclusion

By recognising and avoiding these six common anti-patterns in test code, you can significantly improve the quality and maintainability of your tests. Treating test code with the same care as production code, keeping tests simple and focused, using mocking judiciously, organising fixture files effectively, ensuring comprehensive unit test coverage, and adopting a structured approach to test files are essential to more reliable and efficient testing.

By following best practices and addressing these anti-patterns, you can create a robust test suite that provides accurate feedback and helps you deliver high-quality software. Embrace these insights, refine your testing approach, and say farewell to these pitfalls that can hinder your testing efforts. With an improved understanding of test code best practices, you’ll be well-equipped to achieve greater testing success and ensure the reliability of your applications.

If you like the reading, please Sign up for my mailing list. I share Clean Code and Refactoring techniques weekly via blogs, books and videos.

I hope you enjoyed reading this. If you’d like to support me as a writer, consider signing up to become a Medium member. It’s just $5 a month, and you get unlimited access to all my articles on Medium — as well as all the articles of the mentioned writers and everybody else!

--

--

Writer for

I help developers write better code. Developer | Author | Creator. https://juntao.substack.com/ @JuntaoQiu