Yomi Eluwande JavaScript Developer. Wannabe Designer and Chief Procrastinator at Selar.co and Worklogs.co

End-to-end testing React apps with Puppeteer and Jest

10 min read 3053

In this tutorial, we’ll see how to write tests for a React app using Jest and Puppeteer. Testing is an important part of modern web application development, it helps to check if the code you wrote is acceptable and works as accepted. It’s a way of catching bugs in your code before you go “live”.

There are different approaches when it comes to testing;

Unit Testing — Unit testing helps to check that individual unit of code (mostly functions) work as expected.

Integration Tests — Integration tests are tests where individual units/features of the app are combined and tested as a group.

End-to-End Tests — This test helps to confirm that entire features work from the user’s perspective when using the actual application.

For this tutorial, we’ll be doing end-to-end tests and checking if certain features actually work as expected. To do this, we’ll use Jest and Puppeteer (you can read about Puppeteer here).

Build a React app

We’ll be writing tests for a functional React app and see what happens when the tests pass and fail. To get started, we’ll use the create-react-app package to quickly scaffold a React app.

npx create-react-app react-puppeteer

Once the project directory has been created and installed, navigate to the newly created directory and run the command below in your terminal.

npm i --save-dev jest jest-cli puppeteer faker

Jest — a testing tool created by Facebook to test React apps or basically any JavaScript app

jest-cli — a CLI runner for Jest

Puppeteer — a Node library which provides a high-level API to control headless Chrome or Chromium over the DevTools Protocol. We’ll use this to carry out tests from a user’s perspective.

faker — a tool that helps to generate massive amounts of fake data in the browser. We’ll use it to generate data for Puppeteer.

In your package.json file, add the following line of code in the scripts object.

"test": "jest"

With all the necessary packages installed, you can run the React app with the command npm start and just leave it running in the background.

Write the tests

To start off with the tests, we’ll first write a test to check if there’s a particular text on a page and also a test to see if a contact form submits successfully. Let’s start off with checking if there’s a particular text on a page.

The App.test.js file is where we’ll be writing the tests. Jest is automatically configured to run tests on files have the word test in them. Open up the App.test.js and edit with the following code.

const faker = require('faker');
const puppeteer = require('puppeteer');

const person = {
  name: faker.name.firstName() + ' ' + faker.name.lastName(),
  email: faker.internet.email(),
  phone: faker.phone.phoneNumber(),
  message: faker.random.words()
};

describe('H1 Text', () => {
  test('h1 loads correctly', async () => {
    let browser = await puppeteer.launch({
      headless: false
    });
    let page = await browser.newPage();

    page.emulate({
      viewport: {
        width: 500,
        height: 2400
      },
      userAgent: ''
    });

    await page.goto('http://localhost:3002/');
    await page.waitForSelector('.App-title');

    const html = await page.$eval('.App-title', e => e.innerHTML);
    expect(html).toBe('Welcome to React');

    browser.close();
  }, 16000);
});

In the first part of the code block above, faker and puppeteer are both imported and we generate a bunch of data from faker which will be used later.

The describe function acts as a container that’s used to create a block that groups related tests into one test suite. This can be helpful if you want your tests to be organized into groups. The test function is declared by Jest with the name of the test suite.

Inside the test function, a browser is launched with Puppeteer with the option of headless mode set to false. This means that we can see the browser while testing. browser.newPage() allows you to create a new page.

The .emulate() function gives you the ability to emulate certain device metrics and User-agent. In the code block above, we set it to have a viewport of 500x2400.

With the page open and it’s viewport defined, we then tell it to navigate to the app we’ll be testing with the .goto() function. The .waitForSelector() function tells Puppeteer to hold on until the particular selector has been loaded on the DOM. Once it’s loaded, the innerText of that selector is stored in a variable called html.

The next line is where the real testing happens.

expect(html).toBe('Welcome to React')

In the line of code above, the Jest expect function is set to check if the content of the variable html is the same as Welcome to React. As previously mentioned, we are testing if a particular text on the app is what it should be, a very straightforward test. At the end of the test, the browser is closed with the .close() function.

Let’s now run the test, in your terminal, run the command below.

npm run test

The test would pass, therefore your command output should be the same as above. You can actually change the content of the .App-title selector to see what would happen to failed tests.

Here, the output actually indicates both that the test failed and why it failed. In this case, the received value is not the same as the expected value.

More testing!

For the next test, we’ll simulate and test submitting a contact form on the React app.

In the React app code, open up the App.js file and edit with the code below.

import React, { Component } from 'react';
import logo from './logo.svg';
import './App.css';

class App extends Component {

  constructor(props) {
    super(props);
    this.state = {
      fullname: '',
      email: '',
      message: '',
      terms: false,
      test: ''

    }

    this.handleChange = this.handleChange.bind(this);
    this.handleSubmit = this.handleSubmit.bind(this);
  }

  handleChange(event) {
    const target = event.target;
    const value = target.type === "checkbox" ? target.checked : target.value;
    const name = target.name;

    this.setState({
      [name]: value
    });
  }

  handleSubmit(event) {
    event.preventDefault();
    console.log(this.state)
  }

  render() {
    return (
      <div className="App">
        <header className="App-header">
          <img src={logo} className="App-logo" alt="logo" />
          <h1 className="App-title">Welcome to a React app</h1>
        </header>
        <p className="App-intro">
          To get started, edit <code>src/App.js</code> and save to reload.
        </p>

        <div className="container contact-form m-t-20">

          <p>Contact Form</p>
          <form onSubmit={this.handleSubmit}>
            <div className="field">
              <div className="control">
                <label className="label">Full Name</label>
                <input name="fullname" type="text" placeholder="Full Name" className="input" value={this.state.fullname} onChange={this.handleChange}/>
              </div>
            </div>

            <div className="field">
              <div className="control">
                <label className="label">Email Address</label>
                <input name="email" type="email" placeholder="Email Address" className="input" value={this.state.email} onChange={this.handleChange}/>
              </div>
            </div>

            <div className="field">
              <div className="control">
                <label className="label">Message</label>
                <textarea className="textarea" placeholder="Message here" name="message" value={this.state.message} onChange={this.handleChange}></textarea>
              </div>
            </div>

            <div className="field">
              <div className="control">
                <label className="checkbox">
                  <input
                    name="terms"
                    type="checkbox"
                    checked={this.state.terms}
                    onChange={this.handleChange}
                  />
                  I agree to the{" "}
                  <a href="https://google.com">terms and conditions</a>
                </label>
              </div>
            </div>

            <div className="field">
              <div className="control">
                <label className="label">
                  Do you test your React code?
                </label>
                <label className="radio">
                  <input
                    type="radio"
                    name="test"
                    onChange={this.handleChange}
                    value="Yes"
                    checked={this.state.test === "Yes"}
                  />
                  Yes
                </label>
                <label className="radio">
                  <input
                    type="radio"
                    name="test"
                    onChange={this.handleChange}
                    value="No"
                    checked={this.state.test === "No"}
                  />
                  No
                </label>
              </div>
            </div>

            <div className="field">
              <div className="control">
                <button type="submit" className="button is-link">Submit</button>
              </div>
            </div>
          </form>
        </div>
      </div>
    );
  }
}

export default App;

The App.js has being updated to have a contact form and the submit button simply logs the form data to the console.

Next, add the code block below to your App.test.js file.

describe('Contact Form', () => {
  test('Can submit contact form', async () => {
    let browser = await puppeteer.launch({
      headless: false,
      devtools: true,
      slowMo: 250
    });
    let page = await browser.newPage();

    page.emulate({
      viewport: {
        width: 500,
        height: 900
      },
      userAgent: ''
    });

    await page.goto('http://localhost:3002/');
    await page.waitForSelector('.contact-form');
    await page.click("input[name=fullname]");
    await page.type("input[name=fullname]", person.name);
    await page.click("input[name=email]");
    await page.type("input[name=email]", person.email);
    await page.click("textarea[name=message]");
    await page.type("textarea[name=message]", person.message);
    await page.click("input[type=checkbox]");

    await page.click("input[name=question]");

    await page.click("button[type=submit]");

    browser.close();
  }, 9000000);
});

This test suite is similar to the one above, the puppeteer.launch() function launches a new browser along with some config. In this case, there are two additional options, devtools which displays the Chrome devtools and slowMo which slows down Puppeteer processes by the specified amount of milliseconds. This allows us to see what is going on.

Puppeteer has the .click and .type actions that actually simulate the whole process of clicking on input fields and typing in the values. The details for the form fields are gotten from faker which set in the person object earlier.

This test suite fills the contact form and tests if the user can actually submit this form successfully.

You can run the npm run test command in your terminal and you should a Chrome browser open and watch the actual testing process.

For the next set of tests, we’ll write tests to assert the following:

— Users can login

— Users can logout

— Users are redirected to the login page for unauthorized view

— Nonexistent views/route returns a 404 page

The tests above are end to end tests that are carried out from the user’s perspective. We are checking if a user can actually use the app for the most basic things.

To carry out these tests, we’ll need a React app. We’ll use Robin Wieruch’s React Firebase Authentication Boilerplate code on GitHub. It ships with a built in authentication system, all that needs to be done is creating a Firebase project and adding the Firebase keys.

I modified the code a bit and added some selectors and IDs that makes the app suitable for testing. You can see that here on GitHub. Go ahead and clone the GitHub repo to your local system and run the following commands.

npm i
npm start

Don’t forget to create a Firebase account and add your credentials in the src/firebase/firebase.js file.

React Firebase Authentication Boilerplate App

Let’s go ahead with writing tests for the React app. Once again, we’ll need to install jest faker, and puppeteer, also a App.test.js file is needed.

npm i --save-dev jest jest-cli puppeteer faker

Once the installation is done, create a file named App.test.js and let’s begin to edit with the code block below.

const faker = require('faker');
const puppeteer = require('puppeteer');
const person = {
   email: faker.internet.email(),
   password: faker.random.word(),
};
const appUrlBase = 'http://localhost:3002'
const routes = {
public: {
      register: `${appUrlBase}/register`,
      login: `${appUrlBase}/login`,
      noMatch: `${appUrlBase}/ineedaview`,
   },
private: {
      home: `${appUrlBase}/home`,
      account: `${appUrlBase}/account`,
   },
};

Just like the tests written above, faker and puppeteer are imported. A person object is created and it stores a random email and password that will be used for testing. The appUrlBase constant is the link to the React app — if you haven’t started the React app, run npm start in your terminal and change appUrlBase to the link.

The routes object contains the various URLs to the views we’ll be testing. The public object contains links to routes in the React app that can viewed by anyone (not logged in), while the private object contains links to routes that can only be viewed if you’re logged in.

Note that noMatch is what will be used to test for nonexistent views/route returns a 404 page, that’s why it aptly leads to /ineedaview.

Alright, let’s write the first test now.

Users can login

//create global variables to be used in the beforeAll function
let browser
let page

beforeAll(async () => {
  // launch browser 
  browser = await puppeteer.launch(
    {
      headless: false, // headless mode set to false so browser opens up with visual feedback
      slowMo: 250, // how slow actions should be
    }
  )
  // creates a new page in the opened browser   
  page = await browser.newPage()
})

describe('Login', () => {
  test('users can login', async () => {
    await page.goto(routes.public.login);
    await page.waitForSelector('.signin-form');

    await page.click('input[name=email]')
    await page.type('input[name=email]', 'yomi@mail.com')
    await page.click('input[name=password]')
    await page.type('input[name=password]', 'password')
    await page.click('button[type=submit]')
    await page.waitForSelector('[data-testid="homepage"]')
  }, 1600000);
});

// This function occurs after the result of each tests, it closes the browser
afterAll(() => {
  browser.close()
})

The code block above is different from the first set of tests we wrote above. First, the beforeAll function is used to launch a new browser with its options and create a new page in that browser as opposed to creating a new browser in every single test suite like we did in the tests earlier.

So how do we test here? In the test suite, the browser is directed to the login page which is routes.public.login and just like the contact form test, puppeteer is used to fill the form and submit it. After submitting the form, puppeteer then waits for a selector data-testid='homepage' which is a data-id that is present on the Home page — the page the React app redirects to after a successful login.

I already created an account with the user details in the code block, therefore this test should pass.

The afterAll function happens after the end of tests and it closes the browser.

Users can logout

🤮This is not a CSS tutorial

This is the view that’s shown after a successful login. Now we want to test what happens when a user clicks on the sign out button. The expected outcome is the localStorage is cleared, logged out and the user is redirected back to the Sign In page.

In the same App.test.js file, add the code below just before the afterAll function.

describe('Logout', () => {
  test('users can logout', async () => {
    await page.waitForSelector('.nav-link');

    await page.click('[data-testid="signoutBtn"]')
    await page.waitForSelector('.signin-form')
  }, 9000000);
});

This test is fairly straightforward. puppeteer waits for the .nav-link selector and it clicks on the button with a data attribute of data-testid=”signoutBtn” and that is actually testing if the button can be clicked. After the page.click() function, puppeteer waits for the selector .signin-form which can be found on the Sign In page.

Congratulations, another test passed.

Users are redirected to the login page for unauthorized view

We don’t want users having access to views and routes that they are not authorized to view. So let’s test if the code does that.

Add the code block below to the existing code, just before the afterAll function

describe('Unathorized view', () => {
  test('users that are not logged in are redirected to sign in page', async () => {
    await page.goto(routes.private.home);
    await page.waitForSelector('.signin-form')
  }, 9000000);
});

In the code block above, we test by going to a private route in the React and then wait for the signin-form selector.

This means after a user navigates to a private route, they are automatically redirected to the login form.

Nonexistent views/route returns a 404 page

It’s important for all apps to have a 404 page so as to explain to a user that that particular route doesn’t exist. It was implemented in this React app too, let’s test if it works as expected.

Add the code block below to the existing code, just before the afterAll function.

describe('404 Page', () => {
  test('users are redirected to a 404 page for nonexistent views', async () => {
    await page.goto(routes.public.noMatch);
    await page.waitForSelector('.no-match')
  }, 9000000);
});

The routes.public.noMatch link we created earlier points to a route that doesn’t exist. Therefore when puppeteer goes to that link, it’s expecting that it automatically redirects to the 404 page. The .no-match selector is located on the 404 page.

Conclusion

In this tutorial, we have seen firsthand how to write tests for React apps using Jest as a testing suite and puppeteer for simulations like typing in inputs, clicking etc.

Jest and Puppeteer are a combination that can surely never go wrong when it comes to testing to React apps. Puppeteer is still being actively developed so make sure to check the API reference for more features.

The codebase for this tutorial can be seen on GitHub here and here.

Resources

Jest: https://facebook.github.io/jest/

Puppeteer: https://github.com/GoogleChrome/puppeteer


Plug: LogRocket, a DVR for web apps

https://logrocket.com/signup/

LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single page apps.

Try it for free.

 

Yomi Eluwande JavaScript Developer. Wannabe Designer and Chief Procrastinator at Selar.co and Worklogs.co

One Reply to “End-to-end testing React apps with Puppeteer and Jest”

  1. The CRA seems to have updated the App.js, the css selector is now .App-header and the content is not Welcome to React. It would be helpful if you can update the App.test.js. Thanks!

Leave a Reply