A quick guide to writing end-to-end tests with jest-puppeteer

This article provides a quick guide to writing efficient end-to-end tests with jest-puppeteer, emphasizing the setup process, commonly used APIs, and practical testing scenarios using a simple to-do app as an example.
Yijun
YijunDeveloper
September 28, 202312 min read
A quick guide to writing end-to-end tests with jest-puppeteer

As part of our commitment to ensuring the quality of Logto and continuous improvement, we employ jest-puppeteer for end-to-end automated testing. This allows us to swiftly iterate on Logto's development without disruptions.

In this article, we will share our experience with writing efficient jest-puppeteer test scripts using a simple to-do app as an example. Our aim is to help you quickly get started with writing jest-puppeteer testing code for your own projects.

Introduction to end-to-end testing with jest-puppeteer

End-to-end testing is a way to ensure that your application works correctly from the user's perspective. To achieve this, we utilize two essential tools: Jest and Puppeteer.

Jest is a popular JavaScript testing framework that offers a user-friendly API for writing tests and assertions. It's widely used for unit and integration testing.

Puppeteer is a Node.js library developed by the Chrome team that provides a high-level API for controlling headless Chrome or Chromium browsers. This makes it an ideal choice for automating browser interactions in end-to-end tests.

jest-puppeteer is a Jest preset that enables end-to-end testing with Puppeteer. It offers a straightforward API for launching new browser instances and interacting with web pages through them.

Now that you have a basic understanding of the essential tools, let's dive into setting up your testing environment.

Setting up your testing environment

Setting up your testing environment for end-to-end testing with jest-puppeteer is a straightforward process that involves three main steps:

  1. Install dependencies

In your project (or create a new project), open the terminal and run:

npm install --save-dev jest jest-puppeteer puppeteer

Then go to the node_modules/puppeteer and install Chromium (which is needed for Puppeteer):

cd node_modules/puppeteer
npm run postinstall
  1. Configure Jest

Next, you need to configure Jest to work seamlessly with Puppeteer.

Create a Jest configuration file (e.g., jest.config.js) in your project's root directory if you don't already have one. In this configuration file, specify jest-puppeteer as a preset:

/ @type {import('jest').Config} */;
const config = {
  preset: 'jest-puppeteer',
  // Other Jest configurations...
};

module.exports = config;

You can customize other Jest settings in this file as needed. For more information on customizing Jest configurations, please refer to the Jest configuration.

  1. Write your tests

Create test files in your project, typically named with the .test.js extension. Jest will automatically discover and execute these test files.

Here is an example from the Jest doc:

describe('Google', () => {
  beforeAll(async () => {
    await page.goto('https://google.com');
  });

  it('should be titled "Google"', async () => {
    await expect(page.title()).resolves.toMatch('Google');
  });
});

Then execute the command to run the test:

npx jest

By following these three steps, you'll have a well-configured testing environment for conducting end-to-end tests using jest-puppeteer.

However, please note that this is just a basic example. For more detailed information on environment configuration, please refer to the relevant documentation:

Commonly used APIs

In the upcoming steps, we will rely on APIs provided by Jest, Puppeteer, and jest-puppeteer to assist us in our tests.

Jest primarily offers APIs for organizing tests and asserting expected results. You can explore the specific details in the documentation.

Puppeteer's APIs are primarily designed for interacting with browsers, and their support for testing may not be as straightforward. You can refer to the Puppeteer documentation to understand the functionalities it provides. In our subsequent test examples, we will cover some common use cases.

Because Puppeteer's APIs were not originally designed for testing, using them to write tests can be challenging. To simplify the process of writing Puppeteer tests, jest-puppeteer includes an embedded library called expect-puppeteer. It offers a set of concise and user-friendly APIs. The library is detailed in its documentation, which introduces various helpful features, as shown in the examples below:

// Assert that the current page contains 'Text in the page'
await expect(page).toMatchTextContent('Text in the page');

// Assert that the current page has a div element contains 'Home'
await expect(page).toMatchElement('div', { text: 'Home' });

// Assert that a button containing text "Home" will be clicked
await expect(page).toClick('button', { text: 'Home' });

// Assert that a form will be filled
await expect(page).toFillForm('form[name="myForm"]', {
  firstName: 'James',
  lastName: 'Bond',
});

In the following examples, we will combine the APIs provided by these libraries to complete our testing scenarios.

Now it's time to begin learning how to write test code using our simple to-do app.

The simple to-do app

Assuming we have a simple to-do app running at http://localhost:3000, the main HTML code for this app is as follows:

<div id="app">
  <div class="title">Alex's List</div>
  <ul>
    <li class="item">
      <div class="itemName">Buy an apple</div>
      <div class="itemNotes">For Christmas</div>
      <button>Check</button>
    </li>
    <li class="item">
      <div class="itemName">Read Logto get-started document</div>
      <div class="itemNotes">
        <span>Doc link:</span>
        <a href="https://docs.logto.io/docs/tutorials/get-started" target="_blank"> Get started </a>
      </div>
      <button>Check</button>
    </li>
  </ul>
  <form action="/items" method="post">
    <div>itemName: <input name="itemName" /></div>
    <div>itemNotes: <input name="itemNotes" /></div>
    <button type="submit">Add</button>
  </form>
</div>

When conducting end-to-end testing, we aim to cover the following scenarios:

  1. When we access the app's URL, the app and its data should load correctly.
  2. The item’s check button should be able to click.
  3. Clicking an external link inside the item notes should open the link in a new tab.
  4. Can add an item from the form.

Later, we will write test code to validate these scenarios.

Expect the app and its data have been loaded

We consider that the app has loaded successfully when the following conditions are met:

  1. An element with the ID "app" should exist on the page after accessing the app's URL.
  2. The data inside the app should be rendered correctly.

So, we have written the following test code:

it('should load the app successfully', async () => {
  // Go to the app page
  await page.goto('http://localhost:3000');

  // Wait for the app to be loaded
  await page.waitForSelector('#app');

  // Expect the data to be loaded
  await expect(page).toMatchElement('div[class=title]', {
    text: "Alex's List",
  });
});

In the code:

  • page.goto is equivalent to entering "http://localhost:3000" in the browser, allowing you to navigate to any URL.
  • page.waitForSelector is used to wait for a specific CSS selector to match a particular element, with a default waiting time of 30 seconds.
  • expect(page).toMatchElement is from the expect-puppeteer lib, It’s similar to page.waitForSelector, but it also supports matching the text within an element. Additionally, the default timeout for toMatchElement is only 500ms.

It may seem perfect at first glance, but when running this test and deploying it to a CI environment, it occasionally fails after multiple executions. The failure message indicates:

After waiting for 500ms, we can not find a “div” with a class name of “title” and containing the text "Alex's List".

Based on the failure information and the fact that this test passes most of the time, we can infer that the data requested by the app may not always return within the first 500ms after the app loads. Therefore, we want to wait for the data to be loaded before making the assertion.

Typically, there are two common approaches to achieve this:

  1. Increase the assertion waiting time

From the error message, we can see that the default waiting time for toMatchElement is set to 500ms. We can increase the waiting time by adding the timeout option to the function like this:

await expect(page).toMatchElement('div[class=title]', {
  text: "Alex's List",
  timeout: 2000, // Increase to 2s
});

This approach can reduce the occurrence of failed tests to some extent, but it doesn't completely solve the problem because we can't know for sure how much time is needed for the data to be fetched.

Therefore, we only use this approach when we are certain about the required waiting time, such as in scenarios like "a tooltip appears after hovering over an element for more than 2 seconds.”

  1. Wait for the completion of the network request before making an assertion

This is the correct approach. Although we may not know how long the data request will take, waiting for the network request to finish is always a safe choice. At this point, we can use page.waitForNavigation({ waitUntil: 'networkidle0' }) to wait for the network requests to complete:

it('should load the app successfully', async () => {
	await page.goto('http://localhost:3000');

	/
	 * Wait for network requests ended.
	 * Add here to ensure both the app and the data have been loaded.
	 */
	await page.waitForNavigation({ waitUntil: 'networkidle0' });

	await page.waitForSelector('#app');
	await expect(page).toMatchElement('div[class=title]', {
		text: "Alex's List",
	});
});

This way, before we execute the assertion for the App and its loaded data, we can ensure that our network requests have already concluded. This ensures that the test consistently produces the correct results.

Expect to click a specific button

Next, we are testing the functionality of clicking the check button inside an item.

In the example app, we've noticed that all items share the same structure. Their check buttons have the CSS selector li[class$=item] > button, and the button text is always "Check". This means we cannot directly specify which item's check button to click. Therefore, we need a new solution.

Suppose we want to click the check button of the "Read Logto get-started document" item. We can break this down into two steps:

  1. First, obtain a reference to that specific item.
  2. Then, click the check button located within that item.
it('should be able to click the check button of the read doc item', async () => {
  // Get the read doc item reference
  const readDocItem = await expect(page).toMatchElement(
    'li[class$=item]:has(div[class$=itemName])',
    { text: 'Read Logto get-started document' }
  );

  // Expect to click the related button
  await expect(readDocItem).toClick('button', { text: 'Check' });
});

As shown in the code, we use the toMatchElement function to match the item with the itemName set to "Read Logto get-started document". Then, we use the readDocItem reference to click the button with the text content 'Check' located beneath it.

It's important to note that when obtaining the readDocItem, the CSS selector used is li[class$=item]:has(div[class$=itemName]). This selector matches the root li element of the item, not the itemName inside it, because we intend to click the button under the li tag later.

The usage of expect(readDocItem).toClick is similar to toMatchElement. In the example code, we pass { text: 'Check' } to further match the textContent of the button. However, you can choose whether or not to match the text content of the button based on your needs.

Next, we want to test whether we can open an external link in a new tab when clicking on it within the item notes.

In the example app, we've found that the "Read Logto get-started document" item has an external link to the Logto doc within its notes. Here is our test code:

it('should open the an external link in a new tab', async () => {
  // Narrow down to the specific item by geting the read doc item reference
  const readDocItem = await expect(page).toMatchElement(
    'li[class$=item]:has(div[class$=itemName])',
    { text: 'Read Logto get-started document' }
  );

  await expect(readDocItem).toClick('div[class=itemNotes] a', { text: 'Get Started' });

  const target = await browser.waitForTarget(
    (target) => target.url() === 'https://docs.logto.io/docs/tutorials/get-started'
  );

  const logtoDocPage = await target.page();
  expect(logtoDocPage).toBeTruthy();

  // Remember to close the page after testing
  await logtoDocPage?.close();
});

In the code, we utilize toClick to click on the link within itemNotes.

Subsequently, we use browser.waitForTarget to capture a newly opened tab with the URL "https://docs.logto.io/docs/tutorials/get-started."

After obtaining the tab, we use target.page() to get an instance of the Logto doc page and further check whether the page has loaded.

Also, remember to close the newly opened page after completing our test.

Expect to create an item from the form

Now, we want to test the functionality of adding an item.

We need to fill in the item name and item notes in the form, click the "Add" button and check if the content we added appears in the list:

it('should create an item from the form', async () => {
  // Fill the form and submit
  await expect(page).toFill('form input[name=itemName]', 'Contact with John');
  await expect(page).toFill('form input[name=itemNotes]', 'Send an email to [email protected]');
  await expect(page).toClick('form button[type=submit]', { text: 'Add' });

  // Expect the item has been created
  await expect(page).toMatchElement('li[class$=item]:has(div[class$=itemName])', {
    text: 'Contact with John',
  });
});

You'll notice that we use the expect(page).toFill(inputSelector, content) function to fill in the form content. This function replaces all the content in the input with the content.

If you want to add some characters to the input without replacing the existing content, you can use page.type(selector, content) to directly append the desired content to the input.

However, when we have many fields to fill in our form, calling toFill multiple times is not convenient. In this case, we can use the following approach to fill in all the content in one call:

await expect(page).toFillForm('form', {
  itemName: 'Contact with John',
  itemNotes: 'Send an email to [email protected]',
});

The provided object key is the name attribute of the input element.

Summary

We've covered a simple example to introduce common testing requirements and corresponding code writing techniques when using jest-puppeteer for end-to-end testing. We hope it can help you quickly grasp the basics of writing jest-puppeteer test code.