Introducing Pleasantest

Written by Caleb Eby on

Illustration by Arianna Chau

Pleasantest is a library that integrates with Jest to help you write UI tests that interact with real browsers. It uses Puppeteer to launch and control browsers, Testing Library to find elements on the page, and jest-dom to make assertions against the DOM.

At Cloud Four, automated tests save us time by automatically checking for regressions in interactivity, accessibility, and appearance. We’ve used several different tools in the past, each with its own set of trade-offs, to help us ship quality interfaces. We created a new testing tool, Pleasantest, to make UI testing easier, more realistic, and more reliable.

Why a new testing tool?

One tool we’ve used to test our UI components is jsdom, the DOM implementation that is included with Jest. This setup was great because it allowed us to use Testing Library, which helped us write tests that were resilient to changes and that tested the accessibility of our components. But while working with this setup, we ran into several issues related to the fact that jsdom doesn’t have a rendering engine so it is missing many browser features. When we write tests that use an emulated DOM without a rendering engine, the tests cannot give us the confidence that a real browser would. Also, polyfilling and stubbing out browser features missing from jsdom is time-consuming and tedious.

Another tool we’ve used is Cypress, which avoids many of jsdom’s problems. Cypress lets you write tests that run in real browsers, which helps improve confidence compared to tests that run in jsdom. Cypress is great at testing entire applications, where you point your Cypress tests to the URL of your app server. They recently added support for testing individual components. However, one of our main gripes with Cypress is that it is a separate test runner from what we use for our unit tests. Each time developers switch between writing a unit test for some logic and writing a UI test, they have to make the mental jump to remember how to use a separate test runner, different assertion syntax, and different conventions. Because of its design, Cypress implements its own functionality (command chains, aliases, custom commands) rather than supporting language features that developers are often familiar with (await, variables, functions). This leads to an increased barrier to entry for people who are already familiar with JavaScript.

The best of both worlds

We began making Pleasantest as an experiment to see if we could create a testing tool that took what we liked from both kinds of tests. Pleasantest integrates with all of our favorite testing tools from the Testing Library ecosystem. It uses Puppeteer to avoid the problems associated with using an emulated DOM. You can render and test individual components, or point Pleasantest to a URL to load to test entire applications.

By writing tests using Pleasantest, we can maintain the quality of the work we ship, and we can ensure reliability and consistency in functionality and features as time goes on. We’ll walk through an example of how to test a component using Pleasantest.

Writing your first test with Pleasantest

Video demo of how to write a test using Pleasantest (this is the same as the below content in video form)

For this example, we’ll write tests for a React component. We can start by installing Jest and Pleasantest, and the types for Jest for editor autocompletion:

npm i -D jest @types/jest pleasantest

The component we’ll test is an example modal from @reach/dialog. You can see the code for the demo on GitHub and you can preview it on Netlify.

Screenshot of demo showing page with modal open

We’ll start by creating a new test file, index.test.js, with an empty test:

test('Shows modal when button is pressed', async () => {

})

We can run the test by running npx jest --watch. It will rerun whenever we change the test file, or we can manually rerun it by pressing enter in the terminal.

To mark the test as a Pleasantest test, we’ll wrap the test function in withBrowser:

const { withBrowser } = require('pleasantest')

test(
  'Shows modal when button is pressed',
  withBrowser(async () => {

  })
)

In our example, there is an index.js file in the same folder as the test, which renders the button that opens the modal. We can tell Pleasantest to run that index.js file by using the utils.loadJS function. Since the index.js file renders the app into a <div> with an id of root, we’ll make sure that exists too:

const { withBrowser } = require('pleasantest')

test(
  'Shows modal when button is pressed',
  withBrowser(async ({ utils }) => {
    await utils.injectHTML('<div id="root"></div>')
    await utils.loadJS('./index.js')
  })
)

Next we’ll find the “Open Dialog” button using the getByRole query from Testing Library. In Pleasantest, all queries, matchers, and actions need to be awaited, because the communication with the browser is asynchronous. Note that the screen object needs to be added to the test function parameters. We’ll also use the queryByText query from Testing Library, and the expect(...).not.toBeInTheDocument() matcher from jest-dom to make sure that the modal contents are not present before the button is pressed.

test(
  'Shows modal when button is pressed',
  withBrowser(async ({ utils, screen }) => {
    await utils.injectHTML('<div id="root"></div>')
    await utils.loadJS('./index.js')

    const button = await screen.getByRole('button', { name: /open dialog/i });
    await expect(
      await screen.queryByText(/I am a dialog/i),
    ).not.toBeInTheDocument();
  })
);

Then we can click the button and make sure that the modal appears. Adding the user parameter to our test function gives us access to interaction methods like user.click():

test(
  'Shows modal when button is pressed',
  withBrowser(async ({ utils, screen, user }) => {
    await utils.injectHTML('<div id="root"></div>')
    await utils.loadJS('./index.js')

    const button = await screen.getByRole('button', { name: /open dialog/i });
    await expect(
      await screen.queryByText(/I am a dialog/i)
    ).not.toBeInTheDocument();

    await user.click(button);

    await expect(await screen.queryByText(/I am a dialog/i)).toBeVisible();
  })
);

This test covers the basic functionality of making sure the modal opens correctly. Next, we can add tests for the various ways to close the modal.

Testing closing the modal

There are three ways to close the modal: The close button, clicking on the overlay outside the modal, and pressing the escape key. We’ll start with the escape key since it is the easiest.

Since the logic for rendering the component and opening the modal is the same for all the tests, we can create reusable render and openDialog functions (outside of the test call):

const render = async (utils) => {
  await utils.injectHTML('<div id="root"></div>');
  await utils.loadJS('./index.js');
};

const openDialog = async (screen, user) => {
  const button = await screen.getByRole('button', { name: /open dialog/i });
  await user.click(button);
};

Then we can create a new test and use the functions. We’ll use the page.keyboard.press method from Puppeteer to press the escape key.

test(
  'Escape key closes modal',
  withBrowser(async ({ utils, screen, user, page }) => {
    await render(utils);
    await openDialog(screen, user);

    await expect(await screen.queryByText(/I am a dialog/i)).toBeVisible();

    await page.keyboard.press('Escape');

    await expect(
      await screen.queryByText(/I am a dialog/i)
    ).not.toBeInTheDocument();
  })
);

One thing to keep in mind is that we ran the same queryByText query twice without assigning the result to a variable because we specifically want the test to re-query the DOM after the dialog is closed, rather than reusing the same result.

Testing to make sure the close button works correctly is nearly the same, but we’ll use the user.click method to click the button:

test(
  'Close button closes modal',
  withBrowser(async ({ utils, screen, user }) => {
    await render(utils);
    await openDialog(screen, user);

    await expect(await screen.queryByText(/I am a dialog/i)).toBeVisible();

    const closeButton = await screen.getByRole('button', { name: /close/i });
    await user.click(closeButton);

    await expect(
      await screen.queryByText(/I am a dialog/i)
    ).not.toBeInTheDocument();
  })
);

Lastly, for testing clicking the overlay, we can use Puppeteer’s page.mouse.click to trigger a click at a specific x and y position:

test(
  'Clicking outside modal closes modal',
  withBrowser(async ({ utils, screen, user, page }) => {
    await render(utils);
    await openDialog(screen, user);

    await expect(await screen.queryByText(/I am a dialog/i)).toBeVisible();

    // (10px, 10px) should be outside the modal
    await page.mouse.click(10, 10);

    await expect(
      await screen.queryByText(/I am a dialog/i)
    ).not.toBeInTheDocument();
  })
);

Screen reader and keyboard accessibility

Those tests cover the most obvious behaviors of the modal, but there is still more functionality to test. One important aspect of the implementation is the accessibility of the component. Pleasantest lets us use Testing Library queries and jest-dom matchers that help us make sure the elements have the right accessible roles and labels. We’ll use getByRole and expect(...).toHaveAccessibleName() to check the role and label of the modal:

test(
  'Accessibility structure of modal',
  withBrowser(async ({ utils, screen, user }) => {
    await render(utils);
    await openDialog(screen, user);
    const modal = await screen.getByRole('dialog');
    await expect(modal).toHaveAccessibleName('example dialog');
  })
);

We can also test that the focus is trapped within the modal. This means that when you press tab and shift-tab to navigate through the focusable elements, it should only cycle through elements inside the modal, and skip anything behind the modal. Puppeteer’s page.keyboard.press method, and jest-dom’s expect(...).toHaveFocus() let us test cycling through the focusable button and links inside the dialog.

test(
  'Focus is trapped in the modal when it opens',
  withBrowser(async ({ utils, screen, user, page }) => {
    await render(utils);
    await openDialog(screen, user);

    // When the modal is opened, the close button should be focused automatically
    const closeButton = await screen.getByRole('button', { name: /close/i });
    await expect(closeButton).toHaveFocus();

    await page.keyboard.press('Tab');
    const firstLink = await screen.getByRole('link', { name: /here/i });
    await expect(firstLink).toHaveFocus();

    await page.keyboard.press('Tab');
    const secondLink = await screen.getByRole('link', { name: /focusable/i });
    await expect(secondLink).toHaveFocus();

    // After pressing tab the third time it should cycle back to the close button,
    // instead of focusing on content that is hidden behind the modal
    await page.keyboard.press('Tab');
    await expect(closeButton).toHaveFocus();

    // Shift-tab should cycle back to the last focusable element within the modal
    await page.keyboard.down('Shift');
    await page.keyboard.press('Tab');
    await page.keyboard.up('Shift');

    await expect(secondLink).toHaveFocus();
  })
);

This test suite now tests the user-facing functionality of the component and will catch regressions before users do. You can see the full code for this example and download Pleasantest on GitHub.

We’re excited to use Pleasantest on our projects. Going forward, we’ll continue to add features to Pleasantest that will make testing easier and more comprehensive. We encourage you to give it a try and let us know how it works!

Caleb Eby

Caleb Eby is a developer passionate about web performance, efficient JavaScript, and helpful web tooling. You can find his abandoned side-projects on GitHub

Never miss an article!

Get Weekly Digests


Leave a Comment

Please be kind, courteous and constructive. You may use simple HTML or Markdown in your comments. All fields are required.


Let’s discuss your project! Email Us