Automated tests for your Flex plugins using Jest and Puppeteer

November 12, 2020
Written by
Reviewed by
Adam Shimali
Contributor
Opinions expressed by Twilio contributors are their own
Tammy Ben-David
Contributor
Opinions expressed by Twilio contributors are their own

flex-jest.png

Intro

In this article we will see how to automate testing of your Flex plugin. Manual frontend testing is very tedious, error prone and an outdated approach. It can seriously impact your development process. In the spirit of good modern software development practice, the more you automate your frontend testing, the better. This article will cover both snapshot testing (to check your component renders correctly) and end-to-end browser testing (to check that your component responds correctly to user interactions).

This article aims to be an introduction to this very complex topic and provide you the tools to get started.

Requirements

To follow along with this blogpost you need the following:

  • A Twilio Flex project. You can get a free one here
  • The Twilio CLI. You can install it using the instructions here
  • The Flex Plugin CLI: you can install it using the instructions here. Make sure you have at least version 1.2.3-beta.0 (beta) (more details on how to check which version you are running and how to upgrade can be found here)

The full code for this article can be found in this repo. Feel free to clone the repo and use that to follow along.

Set-up

Let’s start by creating a new flex plugin using the Twilio CLI. The first step is to login to your Twilio like this:

$ twilio login

Make sure you select the correct Flex project when you login. Once you have initialised the CLI, you can create a new Flex plugin using:

$ twilio flex:plugins:create plugin-test

Now cd into the newly created plugin and change the package.json to add a new dependency:

{
  "name": "plugin-automated-test",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "postinstall": "flex-plugin pre-script-check",
  },
  "dependencies": {
    "flex-plugin-scripts": "^4.3.2-beta.0",
    "react": "16.5.2",
    "react-dom": "16.5.2",
    "react-test-renderer": "16.5.2"
  },
  "devDependencies": {
    "@twilio/flex-ui": "^1",
    "puppeteer-core": "^5.4.1"
  }
}

We are going to use react-test-render to execute snapshot tests. Make sure the version of this package is the same as react.

Also, as always when creating a new plugin, copy the sample appConfig.example.js :

cp public/appConfig.example.js public/appConfig.js

And now, let's install all these dependencies:

npm install

Executing the first test

The default plugin template comes with a test for the sample component, included in file src/component/__tests__/CustomTaskListComponent.spec.jsx. In order to execute this test, use:

$ twilio flex:plugins:test

If everything goes right, you should see the following:

Using profile xxxxxx (ACXXXXXXXXXXXXXXX)

Running tests...
 PASS  src/components/__tests__/CustomTaskListComponent.spec.jsx
  CustomTaskListComponent
    ✓ should render demo component (14 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        3.297 s
Ran all test suites.

If you look at the code in the test file, you will see that this test checks that content of the CustomTaskListComponent matches the expected string:

...
    expect(wrapper.render().text()).toMatch(
      'This is a dismissible demo component'
    );
...

Let's say that we now want to be sure that the all the other properties of the component (and not only the text) are as we expect. In order to do that we could use a feature of Jest called snapshot testing. This type of test procedure creates a snapshot of the whole component when you first execute the test. Each subsequent test execution, the rendered component is compared with the snapshot created. This is very useful to make sure your rendered component doesn't change given the same props.

To execute the test you can create a new test file or update the existing one:

import React from 'react';
import renderer from 'react-test-renderer';

import CustomTaskList from '../CustomTaskList/CustomTaskList';

describe('CustomTaskListComponent', () => {
  it('should render demo component', () => {
    const props = {
      isOpen: true,
      dismissBar: () => undefined,
    };
    const tree = renderer.create(<CustomTaskList {...props} />).toJSON();
    expect(tree).toMatchSnapshot();
  });
});

Let's now execute the test and see what happens:

Using profile xxxxxx (ACXXXXXXXXXXXXXXX)

Running tests...
 PASS  src/components/__tests__/CustomTaskListComponent.spec.jsx
  CustomTaskListComponent
    ✓ should render demo component (14 ms)

 › 1 snapshot written.
Snapshot Summary
 › 1 snapshot written from 1 test suite.

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   1 written, 1 total
Time:        4.624 s
Ran all test suites.

What happened during the first execution:

  • A new snapshot of the component is created: if you want to see it, it's in src/components/__tests__/__snapshots__/CustomTaskListComponent.spec.jsx.snap
  • The component is tested against the snapshot. Obviously this test is passed

What happens if we try to change the original component now? Let's try adding a style to the <i> tag:

<i className="accented" onClick={props.dismissBar} style={{backgroundColor: 'white'}}>

If run the test now, you’ll see the following:

● CustomTaskListComponent › should render demo component

    expect(received).toMatchSnapshot()

    Snapshot name: `CustomTaskListComponent should render demo component 1`

    - Snapshot  - 0
    + Received  + 5

    @@ -3,9 +3,14 @@
      >
        This is a dismissible demo component
        <i
          className="accented"
          onClick={[Function]}
    +     style={
    +       Object {
    +         "backgroundColor": "white",
    +       }
    +     }
        >

As you can see, Jest highlighted the part of the component that changed. If you want to update the snapshot with these changes, just delete the snapshot file and execute the test again.

Snapshot testing is very powerful, and there are some good practices to keep in mind. When you create a snapshot, treat it just like the rest of your test source code, to be committed to your repository along with the component code under test. For a list of best practices have a (thorough) look at this document.

End-to-end testing

So far we have seen how to perform a basic static test. But what if we want to test how our plugin responds to user interactions? One possible approach is to have a browser that can be controlled programmatically through code. In this example we are going to use Puppeteer. As defined in its README:

Puppeteer is a Node library which provides a high-level API to control Chrome or Chromium over the DevTools Protocol. Puppeteer runs headless by default, but can be configured to run full (non-headless) Chrome or Chromium.

In this blogpost we are going to execute it non-headless, but the example can be tweaked to work headless as well.

First of all, let's install Puppeteer:

$ npm install --save-dev puppeteer-core

As you can see we opted to install puppeteer-core which is a version of puppeteer that doesn't install any browser and leverages the one already installed in the system. If you are planning to use this test in an automated test environment, you will likely have to install the full version of Puppeteer that comes with the latest version of Chromium.

Let's now set-up our test case: the first step is to create a new file src/__tests__/browser.test.js with the following content:

const puppeteer = require('puppeteer-core');
var browser;

describe('End to end Flex plugin testing', () => {
  beforeAll(async () => {
    jest.setTimeout(60000);
    browser = await puppeteer.launch({
      defaultViewport: null,
      headless: false,
      args: ['--start-maximized'],
      executablePath:
        '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
    });
    const loginPage = await browser.newPage();
    await loginPage.goto(`http://localhost:3000/`);
  });

  it('Plugin renders', () => {
    // TODO
  })
});

The code above is just set-up to prepare the environment for our tests. In particular, it opens Chrome (make sure you change the executablePath to reflect your own setup) then it creates a new tab that loads Flex. This is all part of the beforeAll() method which executes before any of the test cases are executed.

Let's now open a new terminal window and launch the local development server

$ twilio flex:plugins:start

Wait until the server is up and running and then execute the test script. You will see that a new instance of Chrome is opened and the familiar Flex login page is loaded. You will also see a warning in the Jest output. Just ignore it for now.

We now need to make sure we login first before we check that the plugin is loaded. This can be done automatically, but we will do it manually in this article. What we want to do is to wait until the Flex agent desktop is loaded. We do that using the Page.on() method of Puppeteer to be notified every time a new DOM is loaded, and we check the url of the page, until we detect that the admin page gets opened. For that we are going to use the following function waitForLogin :

function waitForLogin(loginPage) {
  return new Promise((resolve, reject) => {
    loginPage.on('domcontentloaded', () => {
      console.log(loginPage.url())
      if (loginPage.url().startsWith('http://localhost:3000/admin')) {
        resolve();
      }
    });
  });
}

Note that there are several alternatives approaches to detect that Flex login has happened (e.g. waitForSelector()). I just chose this one to show you one possible approach.

We are now ready to execute the first test:

it('Close button renders', async () => {
    const agentDesktop = await browser.newPage(); 
    await agentDesktop.goto('http://localhost:3000/agent-desktop')
    await agentDesktop.waitForSelector('.Twilio-RootContainer')
    expect(
      await agentDesktop.evaluate(() => document.querySelectorAll('i')[0].textContent)
    ).toBe('close')
  });

The above test performs the following steps:

  • Open a new tab and load http://localhost:3000/agent-desktop
  • Wait until a component with class Twilio-RootContainer is created. This is to make sure that all the react components are rendered before performing the test. You should be really careful which component you use to detect that the page have been rendered. Twilio-RootContainter may not be the right one for your test
  • Use the Page.evaluate() method to select the first <i> DOM element and return the textContent value
  • Use the expect() to check if the value returned above is the string close

Let's now put it all together. Your browser.test.js should look like that:

const puppeteer = require('puppeteer-core');
var browser;

function waitForLogin(loginPage) {
  return new Promise((resolve, reject) => {
    loginPage.on('domcontentloaded', () => {
      if (loginPage.url().startsWith('http://localhost:3000/admin')) {
        resolve();
      }
    });
  });
}

describe('End to end Flex plugin testing', () => {
  beforeAll(async () => {
    jest.setTimeout(600000);
    browser = await puppeteer.launch({
      defaultViewport: null,
      headless: false,
      args: ['--start-maximized'],
      executablePath:
        '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
    });
    const loginPage = await browser.newPage();
    await loginPage.goto(`http://localhost:3000/`);
    await waitForLogin(loginPage)
  });

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

  it('Close button renders', async () => {
    const agentDesktop = await browser.newPage(); 
    await agentDesktop.goto('http://localhost:3000/agent-desktop')
    await agentDesktop.waitForSelector('.Twilio-RootContainer')
    expect(
      await agentDesktop.evaluate(() => document.querySelectorAll('i')[0].textContent)
    ).toBe('close')
  });
});

Note how we added a afterAll() function to our test. This is to close the browser and signal to Jest that we are done with our test.

If you now run the test script, the following will happen:

  • A new browser will open
  • A new tab in the browser is open with the Flex login page
  • Login to Flex
  • As soon as the admin / agent desktop is open, a new tab is created
  • If everything works, the browser will close and the test script will output something like:
$ twilio flex:plugins:test
Using profile xxxxxx (ACXXXXXXXXXXXXXXXXXXXXX)

Running tests...
 PASS  src/components/__tests__/CustomTaskListComponent.spec.jsx
 PASS  src/__tests__/browser.test.js (62.946 s)

Test Suites: 2 passed, 2 total
Tests:       2 passed, 2 total
Snapshots:   1 passed, 1 total
Time:        65.456 s

Testing interactions

Let's now make sure that when the user clicks the close button, the component is removed. In order to do that we can add the following test:

it('Dismiss the component when clicking close', async () => {
    const agentDesktop = await browser.newPage();
    await agentDesktop.goto('http://localhost:3000/agent-desktop');
    await agentDesktop.waitForSelector('.accented');
    await agentDesktop.click('.accented');
    expect(
      await agentDesktop.evaluate(
        () => document.querySelectorAll('.accented').length === 0
      )
    ).toBe(true);
    agentDesktop.close()
  });

The above test is using the Page.click() method to click the (first) element selected by the selector .accented. After that we are using the Page.evaluate() again to check that there are no more element with class accent.

Add this test to the browser.test.js and see what happens when running the test script. Also note that the above code is not DRY and should be further optimised (not the scope of this introductory blogpost)

Conclusions and next steps

In this article we have seen how to automate some basic tests using the template component. Other things you can do from here are (among others):

  • Test the plugin interaction with other plugin(s) in the hosted flex instance (i.e. flex.twilio.com)
  • Add automated login to flex with headless execution (to integrate this test in your CI/CD pipeline)
  • Add Twilio Events to the test scripts (e.g. incoming phone call)

Giuseppe Verni is a Principal Solutions Engineer at Twilio. He's currently helping companies in EMEA design great customer engagement solutions powered by Twilio. He can be reached at gverni [at] twilio.com, or you can collaborate with him on GitHub at https://github.com/vernig.