Screenshot Testing with React

15. October, 2022 8 min read Develop

Using Jest, Puppeteer, Storybook and Docker

Testing your React app using Puppeteer, Jest, and Storybook enhances existing unit tests and saves you headaches in the future, while Docker makes everything consistent.

In an ideal world, we write code without any issues at all. Sadly reality is often disappointing, and we need to write tests. In this article, I’ll focus on screenshot testing. The tutorial was tested using Node 16 and 18.

The basics

The goal is to take snapshots and compare them to a reference screenshot stored alongside the test or in a centralised folder. The test will fail if the two screenshots do not match. Either the change is unexpected, or the reference screenshot needs to be updated to the new version.

We will start with a generic React boilerplate through create-react-app using Typescript. Open a terminal, navigate to your projects folder and run the following command to get started:

npx create-react-app react-screenshot \
  --template typescript && cd react-screenshot

The command will set up our React application together with some sample files. The following sections will guide us to:

  • create a simple screenshot-capturing test using jest-image-snapshot
  • integrate Storybook to isolate testing from a development server
  • run everything through Docker to get a consistent result

Screenshots using Jest and Puppeteer

Let’s install some dependencies into our project before we get started. Inside the terminal, run the following commands:

npm install jest-image-snapshot \
  puppeteer \
  puppeteer-core \
  @types/jest-image-snapshot \
  @types/puppeteer --save

Check out the documentation for jest-image-snapshot and puppeteer to learn more about their configuration options. I will keep this tutorial as simple as possible, with only minor tweaks.

We’re ready to create our first screenshot test with our dependencies installed. Open src/App.test.tsx and replace the content of the file with:

import React from 'react';
import puppeteer from 'puppeteer-core';
import { render, screen } from '@testing-library/react';
import { toMatchImageSnapshot } from 'jest-image-snapshot';
import App from './App';

expect.extend({ toMatchImageSnapshot });

describe('React App', () => {
  let browser: puppeteer.Browser;
  let page: puppeteer.Page;

  beforeAll(async () => {
    browser = await puppeteer.launch();
    page = await browser.newPage();
  });

  afterAll(async () => {
    await browser.close();
  });

  it('renders learn react link', () => {
    render(<App />);
    const linkElement = screen.getByText(/learn react/i);
    expect(linkElement).toBeInTheDocument();
  });

  it('renders correctly', async () => {
    await page.goto('http://localhost:3000');

    const image = await page.screenshot();
    expect(image).toMatchImageSnapshot();
  });
});

To run the test, you will have to open two terminal sessions:

# the first one will run the React development server
npm run start

# and the second one our screenshot test
# which will run against the React development server
npm run test

Jest image snapshot takes screenshots against our running development server at http://localhost:3000. The snapshots themselves are stored within src/__image_snapshots__/. An additional folder is shown when the test fails. That folder will contain a visual representation of the diff between the reference and the new screenshot.

The reference screenshots can be regenerated by removing the image snapshots folder. However, as this tests against our development server, you can encounter issues while capturing the reference screenshots due to timeouts or incorrect rendering. So the whole process is somewhat flaky. We will solve this later on.

You can also emulate other devices by extending the page. Underneath a short example emulating an iPhone:

it('renders correctly on iPhone 13', async () => {
  await page.emulate(puppeteer.devices['iPhone 13']);
  await page.goto('http://localhost:3000');

  const image = await page.screenshot();
  expect(image).toMatchImageSnapshot();
});

A full list of supported devices can be found on puppeteers documentation.

Handling animations

You might have noticed that the React welcome screen has a rotating logo. This animation can be an issue when taking screenshots, as a couple of milliseconds of delays can move the logo by a pixel or two, creating inconsistent results. To avoid this, we’ll use a little trick. Add the following code snippet at the end of the src/App.css file:

.no-animations * {
  /* disable all animations */
  animation: none !important;
  /* hide the cursor caret when in inputs */
  caret-color: transparent !important;
}

Next, edit src/App.test.tsx and replace the beforeAll part with the following code:

beforeAll(async () => {
  browser = await puppeteer.launch();
  page = await browser.newPage();
  // make sure to disable animations
  await page.evaluate(() => {
    document.body.classList.add('no-animations');
  });
});

This change will add the no-animations class to the document’s body, which prevents all animations and hides any cursor that might be placed within an input element.

However, a development server is not ideal for taking screenshots due to moving parts. On top of that, it is harder to achieve certain states before taking screenshots. For that reason, let’s introduce Storybook!

Screenshots using Storybook

Storybook is a fantastic tool for documenting and testing our components and views. To install it, run the following commands:

npx storybook init
npm run storybook

The latter will open up Storybook with some story examples. To add our React view, create src/App.stories.tsx and place the following code inside:

import React from 'react';
import App from './App';

import './index.css';

export default {
  title: 'Views/App',
  component: App,
};

export const Overview = <App />;

Storybook will pick it up automatically and display the story under the “Views” category. You can find additional stories from Storybook inside the src/stories/ directory.

Next, we want to take snapshots from all of the components and views inside Storybook. A couple of preparations are required to achieve this. At this point, we do not need the Storybook development server. Shut it down and run the following commands:

# remove puppeteer due to peer conflicts
npm remove puppeteer puppeteer-core --save

# install the storybook addon
npm install @storybook/addon-storyshots-puppeteer --save

# clear our testing cache
npx jest --clearCache

The addon-storyshots-puppeteer package will take care of the correct puppeteer version. Otherwise, we get peer dependency conflicts.

We have learned that testing against a development server can raise issues. Luckily, Storybook offers a static build of itself without running a server at all. To get this working, we run npm run build-storybook before executing our tests and then chain it with npm run test. Our testing setup will leverage the local file system through file://, which doesn’t require any server to run.

To get started, replace the content of our src/App.test.tsx with the following code:

import * as path from 'path';
import initStoryshots from '@storybook/addon-storyshots';
import { imageSnapshot } from '@storybook/addon-storyshots-puppeteer';

initStoryshots({
  suite: 'storyshots',
  test: imageSnapshot({
    storybookUrl: `file://${path.resolve(__dirname, '../storybook-static')}`,
  }),
});

You could also create a separate file like src/screenshots.test.tsx for this process. You may want to delete src/__image_snapshots__/ before moving onward.

To execute the tests, run the following commands:

npm run build-storybook
npm run test

This procedure will add the generated screenshots again into src/__image_snapshots__/ with the same behaviour as before. Instead of just one screenshot, we end up with additional ones from all the components inside Storybook. It is beneficial when testing variants of the same view.

Glueing it together with Docker

The above examples already work when you’re the only person contributing to the project and only run the tests on your system. This setup might break as soon as you need to run screenshot tests on different operating systems due to how fonts are rendered. One solution to this problem is to use Docker to run our tests so that the testing environment is always consistent regardless of where you run them, locally on your Mac, Linux, or in the cloud.

To get started, create a Dockerfile in the root of the project and populate it with following content:

FROM node:16.17-alpine

WORKDIR /app
ENV PATH /app/node_modules/.bin:$PATH

COPY package*.json ./
RUN npm install --silent

COPY . .

Next, create a docker-compose.yml file in the root directory containing:

version: '3.9'

services:
  screenshots:
    build: .
    command: npm run test
    user: ''
    depends_on:
      - chrome
    volumes:
      - '.:/app:rw'
      - 'storybook:/app/storybook-static'

  chrome:
    image: 'browserless/chrome:1.53-chrome-stable'
    environment:
      CONNECTION_TIMEOUT: 3600000
    expose:
      - '3000'
    volumes:
      - '.:/app:rw'
      - 'storybook:/app/storybook-static'

volumes:
  storybook:

Docker will create two containers, a chrome container our tests can connect to and a screenshots container based on our Dockerfile that runs the tests encapsulated from our system.

Next, open up your test file and replace it with the following code:

import * as path from 'path';
import puppeteer from 'puppeteer';
import initStoryshots from '@storybook/addon-storyshots';
import { imageSnapshot } from '@storybook/addon-storyshots-puppeteer';

let browser: puppeteer.Browser;

const getCustomBrowser = async (): Promise<puppeteer.Browser> => {
  browser = await puppeteer.connect({
    browserURL: 'http://chrome:3000/',
  });

  return browser;
};

const beforeScreenshot = async (page: puppeteer.Page) => {
  await page.evaluate(() => {
    document.body.classList.add('no-animations');
  });
};

initStoryshots({
  suite: 'storyshots',
  test: imageSnapshot({
    getCustomBrowser,
    beforeScreenshot,
    storybookUrl: `file://${path.resolve(__dirname, '../storybook-static')}`,
  }),
});

afterAll(async () => {
  await browser.close();
});

To finally have consistent and reliable tests, delete the src/__image_snapshots__/ folder and run the following sequence:

# we still need to build storybook first
npm run build-storybook
# build the containers
docker-compose build
# and then run the tests in docker with watching
docker-compose run --rm screenshots

Another great advantage is that such a setup can easily be integrated into a CI such as GitHub Actions or Gitlab Pipelines.

Summary

Unit testing is a very efficient and relatively cheap process to perform. Integration tests, such as automated screenshot testing, can be expensive and time-consuming.

At Divio, we have hundreds of screenshot tests with various components and views in both light and dark modes. This tutorial is just a mere start to screenshot testing, and the hole goes deep. Have fun exploring 😊.

‘Till next time!