Testing with mocha, chai, and puppeteer

Ryan Weal

July 23, 2018

In this article I will outline how I am using Puppeteer with Mocha (a JS test runner) and Chai (a set of JS "tests" that we will make use of).

Puppeteer, for those who are unfamiliar, is "headless Chrome" (or more specifically, chromium, the open-source version of the Chrome web browser). We are going to use Mocha to run Puppeteer, and Chai to test some expectations on what we should be seeing inside puppeteer.

I have used a variety of test frameworks in the past, particularly in PHP... and I was never really very interested in it. That changed due to some of the reasons I enjoy puppeteer which you will see documented below.

Testing the happy path...

Before we get any further with this it is worth noting that end-to-end tests are typically very brittle so we are going to focus on testing the happy path which is what we think the user will be doing in the majority of cases. Following this, we will add additional tests to catch the edge cases we may not uncover until we work through some of the initial workflows of our site.

With that in mind, if you want to test a site that already exists and doesn't have any tests - you are in the perfect place. Puppeteer is perfect for making tests for projects that are already live because you are using a standard browser to run the tests.

Puppeteer: what is so good about it?

Some of the key features of Puppeteer set it apart from the other tools out there, here is a quick summary:

1. Very simple installation

2. Less limiting than other libraries

3. Streamlined use of selectors

4. Async bonus

Remembering what we are not testing...

Finally, before we begin it is important to note that Puppeteer is Chrome-focused. It shouldn't be used for browser compatibility testing... for that you should keep using tools like Selenium which automate the interaction across browsers but use an abstraction layer to do so, rather than native JavaScript.

Things you can not test:

Getting setup (package.json, .gitignore)

Okay, enough talk. Let's get setup with some defaults so that our team can begin writing puppeteer-based tests!

Requirements:

Bonus points if you make these all "dev" requirements so they do not roll out into your production scripts. In my case this package was not tied to another application so there was no distinction between dev and prod.

Example package.json file:

{
  "name": "mocha-tests",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test-courses": "node_modules/mocha/bin/mocha tests/courses.js || exit 0",
    "test-content": "node_modules/mocha/bin/mocha tests/content.js || exit 0"
  },
  "author": "Joe Somebody",
  "license": "GPL-2.0",
  "dependencies": {
    "chai": "^4.1.2",
    "dotenv": "^5.0.0",
    "mocha": "^5.0.0",
    "puppeteer": "^1.0.0"
  }
}

This package.json file does the following:

Example .gitignore file

The .gitignore file will keep the puppeteer browser from getting committed to your project repo, saving a ton of potentially wasted space in your repos.

node_modules/
variables.env

Creating a test template (tests/content.js, variables.env)

If you cannot run the tests:

Example variables.env file:

MOCHA_TEST_HOST=http://example.com

Command to run the tests:

npm run test-content

Set "test-content" to run a specific file in your package.json.

Sample tests (add to tests/content.js)

These all go in your tests/content.js file, where we left that @todo in the example above.

PS. You can nest the describe/it blocks if you want!

1. Check the page title.

The first example we have just looks at the metadata coming back from puppeteer to check the page title:

    it('homepage loads and has correct page title', async function () {
      const [response] = await Promise.all([
        page.goto(host, {timeout:0}),
        page.waitForNavigation({timeout:0}),
      ]);
      expect(await page.title()).to.eql('Crazy page that takes a lot of time to load');
    });

This test is nice and quick because we aren't parsing any HTML.

2. User clicks the checkout button

This one is part of our previous example.

Note that click and waitForNavigation can happen in any order. We're not doing anything until both the click and the load of the next page are complete.

      it('proceeds to checkout', async function () {
        const [response] = await Promise.all([
          page.waitForNavigation({timeout:0}),
          page.click('#edit-checkout'),
        ]);
        expect(await page.title()).to.eql('Checkout | My Awesome Webstore');
      });

3. User logs into the application

This example loads the login form for a CMS, inputs the data, and submits it.

      it('can login', async function () {
        await page.goto(host + '/user', {timeout:0});
        await page.type('#edit-name', 'joesomebody');
        await page.type('#edit-pass', 'password1');
        const [response] = await Promise.all([
          page.waitForNavigation({timeout:0}),
          page.click('#edit-submit'),
        ]);
        let result = await page.$eval('.field-group-htabs-wrapper', e => e.innerHTML);
        expect(result).to.include('Add a New Class');
      });

The test passes if the user successfully logs in and sees some HTML with the class "field-group-htabs-wrapper", and if inside that class there is a string 'Add a New Class' that is present. If there are many HTML elements using this class, the first one is used for the test.

This test can be somewhat problematic because we are actually doing two page loads within the same test (first for the /user page, then for the "click submit" to come back). Keeping your tests to one request will make things smoother, and alleviate the need to explicitly set timeouts everywhere. You probably won't run into this problem outside of slow CMS testing though.

4. Just force it to waitFor a second...

This is a very brittle example but it does a few new things. Rather than waiting for a page to load, it simply waits 1 second (1000 ms):

    it('selects an item from a slow AJAX select field', async function () {
      const [response1] = await Promise.all([
        page.click('#edit-field-class-level-und'),
        page.waitFor(1000),
      ]);
      const [response2] = await Promise.all([
        page.keyboard.press('ArrowDown'),
        page.waitFor(1000),
      ]);
      await page.keyboard.press('Enter');

      // @todo we should have an expect here, to test for a result
    });

In this example we also make use of the virtual keyboard that puppeteer provides. After one second we presume that the AJAX content has loaded, press the down arrow, wait one more second, and then press enter. We don't actually test for anything in this example, so this would "pass" regardless of what happens unless we add an "expect" command in there.

Creating tests is now pretty much like this:

Now that you have seen a few examples you can simplify your workflow by using the provided examples.

To start a new project:

To create a new set of tests in a new test file:

To create a new test within an existing test file (such as content.js above). This is mostly a copy-and-paste adventure now:

In other words, to find a selector: when you are looking at the page in chrome, press control-shift-j to launch the inspector, click the arrow in the corner, then click on the element on the page you want to use for your test. After clicking, the inspector will highlight some HTML in the inspector window. Right click that HTML. Choose Copy->Copy Selector. Paste that into your test.

Occasionally the test selector is too specific. I have found if the selector is very long I can often take elements out of the middle... makes it generic enough to target the first element. Depends on what garbage HTML you are dealing with. More often than not simply copying over a selector "just works" but sometimes you will have to edit them.

Things to watch out for

Refactoring ideas... Puppeteer as a migration source!

It should be pretty obvious that all of this could easily be a web scraper rather than a test suite. My team has been making use of Puppeteer in other contexts to extract data for extract-transform-load migrations. Puppeteer provides a quick way of getting a real-life rendered DOM with a consistent use of selectors. Huge time-saver when the alternative is doing dozens of database joins.


Written by:
Ryan Weal @ryan_weal
Web developer based in Montréal. I run Kafei Interactive Inc. Node.js, Vue.js, Cassandra. Distributed data. Hire us to help with your data-driven projects.