Darek Kay's picture
Darek Kay
Solving web mysteries

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. No results 2. 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();
});
Be extra-careful when testing for the absence of elements. A typo in the tested string won't make the test fail, yet the counter-check will become useless. One solution is to extract the string into a constant that is used by multiple tests.

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.

Countercheck unit tests