Countercheck unit tests
Test-driven development (TDD) is a good technique for making sure that our code matches the requirements. With frontend unit tests, it is often necessary to countercheck our requirements. In this post I will use React and testing-library, but the underlying problem can be applied to any language and framework.
Let's write a Fruits
component that expects an items
prop. Here are our requirements:
- If the list of fruits is empty, render "No results".
- Otherwise, render the list of fruits.
Let's start with some unit tests to cover both use cases:
test("displays a message when the fruit list is empty", () => {
render(<Fruits items={[]} />);
expect(screen.getByText("No results")).toBeInTheDocument();
});
test("displays a list of fruits", () => {
render(<Fruits items={["Apple", "Kiwi"]} />);
expect(screen.getByText("Apple")).toBeInTheDocument();
});
Do you see any issue with those unit tests? We've covered both requirements, so everything seems fine. Let's continue with the implementation:
const Fruits = ({ items }) => (
<>
<div>No results</div>
{items.map((fruit) => (
<div key={fruit}>{fruit}</div>
))}
</>
);
Do you see the issue now? This component always renders a "No results" message and all provided fruits. The implementation is incorrect, yet our unit tests didn't catch it. Worse yet, our tests gave us a false sense of security with a 100% test coverage.
The issue is that we have only covered the explicit part of our requirements:
If the list of fruits is empty, render "No results".
However, this statement includes an implicit requirement that we didn't verify:
If the list of fruits is not empty, don't render "No results".
I call this an inverse or countercheck test. Here's how such a unit test can look like:
test("doesn't display a message when the fruit list is non-empty", () => {
render(<Fruits items={["Apple", "Kiwi"]} />);
expect(screen.queryByText("No results")).not.toBeInTheDocument();
});
When dealing with binary cases, I like to include countercheck assertions within regular tests to prevent setup duplication, e.g.:
test("displays a list of fruits", () => {
render(<Fruits items={["Apple", "Kiwi"]} />);
expect(screen.queryByText("No results")).not.toBeInTheDocument();
expect(screen.getByText("Apple")).toBeInTheDocument();
});
Finally, here's a proper implementation of the Fruits
component that will pass our regular and countercheck tests:
const Fruits = ({ items }) =>
items.length === 0 ? (
<div>Loading</div>
) : (
items.map((fruit) => <div key={fruit}>{fruit}</div>)
);
This issue happens whenever we assert a result partially. It's not unique to frontend unit tests, but it's common with the testing-library
approach of checking if a page contains a certain element (queries). Keep counterchecks in mind when writing unit tests.