Writing Developer-Friendly React Unit Tests

moubi

Miroslav Nikolov

Posted on March 30, 2021

Writing Developer-Friendly React Unit Tests

You want to write React unit (component) tests in a human-readable manner. In conjunction with the best practices today it should make your life (and the life of your colleague developers) easier and reduce the number of production bugs.

it("should render a button with text", () => {
  expect(
    <Button>I am a button</Button>,
    "when mounted",
    "to have text",
    "I am a button"
  );
});
Enter fullscreen mode Exit fullscreen mode

This component test is for real. Back on it soon... [đź”–]


The Problem

Snapshots and direct DOM comparison are fragile while JQuery like chaining syntax reads bad and makes tests wordy. How to address the readability issue in that case? How to keep up testing components' data flow in isolation by hiding their implementation details?

Below is my approach to unit testing in React. It aims to follow the consensus with a pinch of clarity on top.

 

The Component (A Button)

A trivial Material UI-like button will be used for this demonstration. It is simple enough to unfold the concept with the help of several test examples.

Material UI-like buttons showcase

// Button.js

export default function Button({
  children,
  disabled = false,
  color,
  size,
  onClick,
}) {
  const handleClick = () => {
    if (!disabled) {
      if (typeof onClick === "function") {
        onClick();
      }
    }
  };

  return (
    <button
      className={classNames("Button", {
        [color]: color,
        [size]: size,
      })}
      disabled={disabled}
      onClick={handleClick}
    >
      {children}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

 

Testing Library

Getting back to the test case on top [🔖]. It uses UnexpectedJS—an assertion library compatible with all test frameworks—supplemented by a few plugins to help you working with React components and the DOM.

Jest is the test runner and behind the scenes, it has react-dom and react-dom/test-utils as dependencies.

 

Testing Setup

There is an example GitHub repo you can use as a ref. Head over there for the full picture.

Otherwise here are some of the more interesting moments:

 

Project Structure

-- src
    |-- components
    |   |-- Button
    |   |   |-- Button.js
    |   |   |-- Button.scss
    |   |   |-- Button.test.js
    |-- test-utils
    |   |-- unexpected-react.js

Enter fullscreen mode Exit fullscreen mode

 

Test Plugins

package.json

"devDependencies": {
  ...
+ "sinon": "9.2.4",
+ "unexpected": "12.0.0",
+ "unexpected-dom": "5.0.0",
+ "unexpected-reaction": "3.0.0",
+ "unexpected-sinon": "11.0.1"
}
Enter fullscreen mode Exit fullscreen mode

Sinon is used for spying on functions—callback component props executed as a result of specific user interactions with the UI.

 

Test Helper

A test helper named unexpected-react.js has the following structure:

// unexpected-react.js

import unexpected from "unexpected";
import unexpectedDom from "unexpected-dom";
import unexpectedReaction from "unexpected-reaction";
import unexpectedSinon from "unexpected-sinon";

const expect = unexpected
  .clone()
  .use(unexpectedDom)
  .use(unexpectedReaction)
  .use(unexpectedSinon);

export { simulate, mount } from "react-dom-testing";

export default expect;
Enter fullscreen mode Exit fullscreen mode

It simply exports all necessary functions to put together the Button's tests.

 

Button Component Tests

// Button.test.js

import expect, { mount, simulate } from "../../test-utils/unexpected-react";
import React from "react";
import sinon from "sinon";

import Button from "./Button";

describe("Button", () => {
  // Test cases
});
Enter fullscreen mode Exit fullscreen mode

Individual unit/component tests are placed within a describe() block. See below.

 

1. Render with text.

A button with text

it("should render with text", () => {
  expect(
    <Button>I am a button</Button>,
    "when mounted",
    "to have text",
    "I am a button"
  );
});
Enter fullscreen mode Exit fullscreen mode

Checking if a button renders with the specified text.

 

2. Render with custom markup.

A button with additional markup

it("should render with markup", () => {
  expect(
    <Button>
      <span>Download</span>
      <span>⬇️</span>
    </Button>,
    "when mounted",
    "to satisfy",
    <button>
      <span>Download</span>
      <span>⬇️</span>
    </button>
  );
});
Enter fullscreen mode Exit fullscreen mode

If you want to compare the DOM structure—which in this case may make sense—this is the way to go.

You can also use data-test-id with its relevant assertion. Fx.

it("should render with markup", () => {
  expect(
    <Button>
      <span>
        <i />
        <span data-test-id="button-text">
          Download
        </span>
      </span>
    </Button>,
    "when mounted",
    "queried for test id"
    "to have text",
    "Download"
  );
});
Enter fullscreen mode Exit fullscreen mode

 

3. Render a primary button.

Primary button

it("should render as primary", () => {
  expect(
    <Button color="primary">Primary</Button>,
    "when mounted",
    "to have class",
    "primary"
  );
});
Enter fullscreen mode Exit fullscreen mode

There are two supported color prop values: primary and secondary. These are then set as CSS classes.

 

4. Render a small button.

Small button

it("should render as small", () => {
  expect(
    <Button size="small">Small</Button>,
    "when mounted",
    "to have class",
    "small"
  );
});
Enter fullscreen mode Exit fullscreen mode

Similar to color there are two values for the size prop: small and large.

 

5. Render as disabled.

Disabled button

it("should render as disabled", () => {
  expect(
    <Button disabled>Disabled</Button>,
    "when mounted",
    "to have attributes",
    {
      disabled: true,
    }
  );
});
Enter fullscreen mode Exit fullscreen mode

Checking for the disabled attribute. That's all.

 

6. Don't trigger click handlers.

it("should NOT trigger click if disabled", () => {
  const handleClick = sinon.stub();

  const component = mount(
    <Button onClick={handleClick} disabled>Press</Button>
  );

  simulate(component, { type: "click" });

  expect(handleClick, "was not called");
});
Enter fullscreen mode Exit fullscreen mode

The onClick callback should not be executed on disabled-buttons.

 

7. Handle a click.

it("should trigger click", () => {
  const handleClick = sinon.stub();

  const component = mount(
    <Button onClick={handleClick}>Click here</Button>
  );

  simulate(component, { type: "click" });

  expect(handleClick, "was called");
});
Enter fullscreen mode Exit fullscreen mode

The was called assertion here has a better alternative if you need to test for arguments passed to the handler. Fx.

// Passing a checkbox state (checked) to the callback
expect(handleClick, "to have a call satisfying", [true]);
Enter fullscreen mode Exit fullscreen mode

 

8. Tests output.

Jest terminal output

This is what you will see in the terminal if all unit tests are passing.

 

Final Words

There is a trend for testing in React (and testing in general) which has been started by React Testing Library and seems the majority of our community is going after it. Its guiding principle is:

...you want your tests to avoid including implementation details of your components and rather focus on making your tests give you the confidence for which they are intended.

This statement translates to something like "you should not test against components' DOM structure but rather focus on data flow." Changes in your component DOM should not break your tests.

UnexpectedJS comply with this principle by allowing you to easily test for data-test-id (or aria-* attributes) while at the same time encourages writing human-readable unit tests by its custom assertions.

PS: Would such an approach be of a value to you?


Join My Programming Newsletter

I send it out once a month. It contains my latest write-up plus useful links and thoughts on topics I can't easily find answers by just Googling.

If it sounds interesting head over and add your email.
Spam-free.

đź’– đź’Ş đź™… đźš©
moubi
Miroslav Nikolov

Posted on March 30, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related