Darek Kay's picture Darek Kay

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:

  1. If the list of fruits is empty, render "No results".
  2. Otherwise, render the list of fruits.

Visualization of the requirements: 1. List of fruits 2. No results

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.

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.


Want to leave a comment?

Join the discussion at Twitter, Mastodon or Hacker News. Feel free to drop me an email. 💌

Countercheck unit tests