Resilient Front-End Tests using the React Testing Library

by Enrique Cerda

React has become the community’s favorite JavaScript web framework and has gained popularity in the past years as seen on NPM download trends where a wider gap keeps growing between its competitors, such as Angular and Vue. We at Dynamic.Tech are keen to keep up with the industry’s best practices and latest trends, so we’re glad to see how the React ecosystem keeps improving.

Not so long ago, Imperative testing was common and even promoted by React’s top libraries. Nowadays, the trend is shifting to a more Declarative style for a few reasons, being flexibility and maintainability two of the most important benefits. Testing is one of our core practices, as such, I would like to demonstrate the relevance of writing resilient and declarative tests in React with a walkthrough-exercise.

Group Programming

Declarative Testing in React

Recently, I started a project which required some testing in React. I visited the React webpage and discovered they were no longer recommending Enzyme, a testing utility capable of mimicking React’s lifecycle as well as its API for DOM manipulation. I had spent some time learning this utility, so I felt a little disappointed. I kept reading and found out that React Testing Library (RTL) was now being suggested. So what did this mean? I went on and visit React Testing Library and read its first paragraph:

The problem

You want to write maintainable tests that give you high confidence that your components are working for your users. As a part of this goal, you want your tests to avoid including implementation details so refactors of your components (changes to implementation but not functionality) don’t break your tests and slow you and your team down.

Unlike Enzyme, this tool’s focus is to avoid implementation details and treat the test as a user would view it on the screen. So instead of doing this:

describe("SlideViewer", () => {
  it("increments 'slideIndex' when Next is clicked", () => {
    const slides = [slide1, slide2, slide3];
    const wrapper = shallow(<SlideViewer slides={slides} />);
    wrapper.setState({ slideIndex: 0 });

    wrapper.find("button").simulate("click");

    expect(wrapper.state("slideIndex")).toBe(1);
  });
});
Browser View

RTL suggests this:

describe("SlideViewer", () => {
  test("it shows next slide when button is clicked", () => {
    const slides = [slide1, slide2, slide3];
    render(<SlideViewer slides={slides} />);

    clickOnNext();

    expect(screen.findByText("elephant")).toBeTruthy();
  });
});

The big difference here is the style used to write tests, Imperative (common in Enzyme) vs Declarative (RTL). With Imperative style, you focus on internals and details that would never be important for a user that may not even know they exist, such as state. With Declarative, as I found out, you put yourself on the user’s shoes and test what’s in front of you, like the UI (screen). On the second snippet, you render a component, like a screen would whenever you visit a site. Afterwards, you’d click on a button, in this case a “Next” button. And you would expect for the screen to show an increment on the slide index.

Walkthrough example

Download the example code or follow along.

The first thing I will do is my initial setup where I will configure a simple server for it to be able to display my component visually… although tests should be enough to prove it works.

Install Webpack & Html plugin

$ npm install --save-dev webpack webpack-cli html-webpack-plugin webpack-dev-server
  • webpack Bundles files
  • webpack-dev-server Provides a server and live reloading
  • webpack-cli Needed by webpack-dev-server
  • html-webpack-plugin Displays on a browser

Webpack packages your React code (written among others, with JSX), into simple JS language that a browser is able to read. webpack-dev-server is a module bundler and helps during development for a quicker feedback, providing fast and automatic reloading.

webpack.config.js

const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
  mode: "development",
  entry: { example: "./src/index.js" },
  plugins: [
    new HtmlWebpackPlugin({
      title: "Example",
      chunks: ["example"],
    }),
  ],
};

I have provided webpack its own configuration file using the HtmlWebpackPlugin which will help us on the visual part by displaying our component on a browser.

Install React

Babel

Babel is a kind of translator that converts ECMAScript 2015+ into JavaScript. We will use a Babel preset, @babel/preset-react, specially used to convert React’s JSX into JavaScript.

$ npm install --save-dev @babel/core @babel/preset-env @babel/preset-react babel-loader

babel-loader is a module used by webpack and it is used by webpack to transpile files using Babel.

You’ll need to specify this loader in Babel configuration in your root:

babel.config.json

{
  "presets": [
    "@babel/preset-react",
    [
      "@babel/preset-env",
      {
        "targets": {
          "node": "current"
        }
      }
    ]
  ]
}

And add into your webpack configuration:

webpack.config.js

module: {
  rules: [
    {
      exclude: /node_modules/,
      loader: "babel-loader",
    },
  ],
},

These configurations say that everything that ends with “.js” will be loaded by “babel-loader” and we are also specifying the React presets in the Babel config file.

Finally, to test your webpack server and render a React component:

$ npm install --save-dev react-dom

index.js

import React from "react";
import ReactDOM from "react-dom";

const container = document.createElement("div");
document.body.appendChild(container);
ReactDOM.render(<h1>Hello, world!</h1>, container);

Running npm run start:dev should render Hello, World! on your browser.

And now for the testing part…

Install Jest and RTL

$ npm install --save-dev jest @testing-library/react

Jest is the testing framework that will allow us to make assertions.

And begin testing… Note I will do test-driven development and start with a failing test:

SlideViewer.test.js

import React from "react";
import { render, screen } from "@testing-library/react";

describe("SlideViewer", () => {
  it("renders msg when empty slides", () => {
    render(<SlideViewer />);
    expect(screen.getByText("No Slides")).toBeTruthy();
  });
});

I have rendered a SlideViewer, and I expect the screen to show a text saying “Slides”. Obviously this test will fail since there is no SlideViewer, but running npm run test outputs the following:

ReferenceError: SlideViewer is not defined

      4 | describe("SlideViewer", () => {
      5 |   it("renders a SlideViewer", () => {
    > 6 |     render(<SlideViewer />);

After implementing SlideViewer

SlideViewer.js

const SlideViewer = () => {
  return "No Slides:";
};

export default SlideViewer;

…and running npm run test, the output is finally green:

 PASS  spec/SliderViewer.test.js
  SlideViewer
    ✓ renders a SlideViewer (26 ms)

And now, a test to display the first slide passed on:

SlideViewer.test.js

it("displays first slide", () => {
  const slides = [
    { name: "cow", filename: "cow.jpg" },
    { name: "elephant", filename: "elephant.jpg" },
    { name: "lion", filename: "lion.jpg" },
    { name: "table", filename: "table.jpg" },
  ];

  render(<SlideViewer slides={slides} />);

  const slideImg = document.querySelector("img");
  expect(slideImg.src.endsWith(slides[0].filename)).toBe(true);
  expect(screen.getByText(slides[0].name)).toBeTruthy();
});

And to make both tests pass:

SlideViewer.js

const SlideViewer = (props) => {
  return (
    <div>
      {!props.slides && "No Slides"}
      {props.slides && (
        <div>
          <div>Slides:</div>
          <figure>
            <img src={`../img/${props.slides[0].filename}`} />
            <figcaption>{props.slides[0].name}</figcaption>
          </figure>
        </div>
      )}
    </div>
  );
};

Display the component on your browser, obviously you still would need to add some styling, but that’s not this blog’s objective, we’ll leave that for another time.

index.js

const slides = [
  { name: "cow", filename: "cow.jpg" },
  { name: "elephant", filename: "elephant.jpg" },
  { name: "lion", filename: "lion.jpg" },
  { name: "table", filename: "table.jpg" },
];

const container = document.createElement("div");
document.body.appendChild(container);
ReactDOM.render(<SlideViewer slides={slides} />, container);

Next we are going to simulate what a user would do to display the next slide. He would have to click on a button, this button would normally be labeled “Next”.

SlideViewer.test.js

it("displays next slide when 'Next' clicked", () => {
  render(<SlideViewer slides={slides} />);

  clickOn("Next");

  expect(screen.getByAltText(slides[1].name)).toBeTruthy();
  expect(screen.getByText(slides[1].name)).toBeTruthy();
});

const clickOn = (btnName) =>
  userEvent.click(screen.getByRole("button", { name: btnName }));

I have emulated the clickOn action. userEvent is part of @testing-library/user-event which I had installed. This library has plenty of user events that will help us to emulate user actions.

And finally, to make this test pass:

SlideViewer.js

const Button = (props) => {
  return (
    <button type="button" onClick={props.onClick}>
      {props.label}
    </button>
  );
};

const SlideViewer = ({ slides }) => {
  const [index, setIndex] = useState(0);

  if (slides === null || slides.length === 0) {
    return "No slides";
  }

  const slide = slides[index];

  return (
    <div>
      <div>
        <div>Slides:</div>
        <figure>
          <img src={`../img/${slide.filename}`} width="500" height="600" />
          <figcaption>{slide.name}</figcaption>
        </figure>
      </div>
      <Button name="Next" onClick={() => setIndex(index + 1)} />
    </div>
  );
};

You are starting to get the idea, the Declarative testing style tries to emulate what the user actually sees and tests on it. It does not care about internals, there is no state testing nor any other internal function. Instead, it tests over the final result that the user sees on the screen as the example clearly shows.

I will continue this exercise on my own and cover the whole functionality. Please take a look at the example code in github to see newer tests and how their implementation works.

Dynamic.Tech - Software Development Training
Subscribe

Sign up to receive our monthly tech recap

Conversation