Modern React testing, part 5: Playwright

This is a draft post, please don’t share it until it’s published.

Playwright is a framework-agnostic end-to-end testing (also known as E2E, or integration testing) tool for web apps. Playwright has the best test writing experience and makes writing good, resilient to changes, tests straightforward.

This is the fifth article in the series, where we learn how to test React apps end-to-end using Playwright, and how to mock network requests using Mock Service Worker.

Check out the GitHub repository with all the examples.

Getting started with Playwright

We’ll set up and use these tools:

Why Playwright

Playwright has many benefits over other end-to-end test runners:

  • The best experience of writing and debugging tests.
  • Ability to inspect the page at any moment during the test run using the browser developer tools.
  • All commands wait for the DOM to change when necessary, which simplifies testing async behavior.
  • Tests better resemble real user behavior. For example, Playwright checks that a button is visible, isn’t disabled, and isn’t hidden behind another element before clicking it.
  • Supports Chromium, WebKit, Firefox, as well as Google Chrome for Android and Mobile Safari.
  • Convenient semantic queries, like finding elements by their label text or ARIA role, similar to React Testing Library.
  • It’s very fast.

Semantic queries help us write good tests and make writing bad tests difficult. It allows us to interact with the app similar to how a real user would do that: for example, find form elements and buttons by their labels. It helps us to avoid testing implementation details, making our tests resilient to code changes that don’t change the behavior.

Why not Cypress

Playwright is very similar to the combination of Cypress, Cypress Testing Library, and start-server-and-test, which have been my choice for end-to-end testing for many years, but gives you all the necessary tools in a single package. Also, the API feels more cochesive and intentional. There wasn’t a lot of new things to learn.

Some of the benefits of Playwright over Cypress:

  • better API;
  • easier setup;
  • multi-tabs support
  • speed.

Setting up Playwright

First, run the installation wizard:

npm init playwright@latest

This will install all the dependencies, and generate the config files. We’ll need to choose:

  • whether to use TypeScript or JavaScript (we’ll use JavaScript in this article);
  • where to put the tests (tests folder in the project root);
  • whether to generate GitHub Actions to run the tests on CI (we won’t cover this here);
  • whether we want to install the browsers (it’s a good idea, we’ll need them anyway).

Playwright installation wizard

Then add a few scripts to our package.json file:

{
  "name": "pizza",
  "version": "1.0.0",
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test:e2e": "npx playwright test --ui",
    "test:e2e:ci": "npx playwright test"
  },
  "dependencies": {
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "react-scripts": "5.0.1"
  },
  "devDependencies": {
    "@playwright/test": "^1.41.2"
  }
}

Playwright, unlike React Testing Library or Enzyme, tests a real app in a real browser, so we need to run our development server before running Playwright. We can run both commands manually in separate terminal windows — good enough for local development — or we can set up Playwright to run it for us (see below), so have a single command that we can also use on continuous integration (CI).

As a development server, we can use an actual development server of our app, like Create React App in this case, or another tool like React Styleguidist or Storybook, to test isolated components.

We’ve added two scripts to run Create React App development server and Playwright together:

  • npm run test:e2e to run dev server and Playwright ready for local development;
  • npm run test:e2e:ci to run dev server and all Playwright tests in headless mode, ideal for CI.

Then, edit the Playwright config file, playwright.config.js in the project root folder:

const { defineConfig, devices } = require('@playwright/test');
 
module.exports = defineConfig({
  testDir: './tests',
  /* Run tests in files in parallel */
  fullyParallel: true,
  /* Fail the build on CI if you accidentally left test.only in the source code. */
  forbidOnly: !!process.env.CI,
  /* Retry on CI only */
  retries: process.env.CI ? 2 : 0,
  /* Opt out of parallel tests on CI. */
  workers: process.env.CI ? 1 : undefined,
  /* Reporter to use. See https://playwright.dev/docs/test-reporters */
  reporter: 'html',
  /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
  use: {
    /* Base URL to use in actions like `await page.goto('/')`. */
    baseURL: 'http://localhost:3000',
    /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
    trace: 'on-first-retry'
  },
  /* Configure projects for major browsers */
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] }
    },
 
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] }
    },
 
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] }
    }
  ],
  /* Run your local dev server before starting the tests */
  webServer: {
    command: 'npm run start',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI
  }
});

The options we’ve changed are:

  • use.baseURL is the URL of our development server to avoid writing it in every test;
  • webServer block describes how to run development server, we also want to reuse already running server unless we’re in the CI environment.

Setting up Mock Service Worker

We’re going to use Mock Service Worker (MSW) for mocking network requests in our integration tests, and in the app during development.

  • It uses Service Workers, so it intercepts all network requests, no matter how there are made.
  • A single place to define mocks for the project, with the ability to override responses for particular tests.
  • An ability to reuse mocks in integration tests and during development.
  • Requests are still visible in the browser developer tools.
  • Supports REST API and GraphQL.

First, install MSW from npm:

npm install --save-dev msw

Create mock definitions, src/mocks/handlers.js:

import { http, HttpResponse } from 'msw';
 
export const handlers = [
  // GET requests to https://httpbin.org/anything with any parameters
  http.get('https://httpbin.org/anything', () => {
    // Return OK status with a JSON object
    return HttpResponse.json({
      args: {
        ingredients: ['bacon', 'tomato', 'mozzarella', 'pineapples']
      }
    });
  })
];

Note To mock GraphQL requests instead of REST, we could use the graphql namespace.

Here, we’re intercepting GET requests to https://httpbin.org/anything with any parameters and returning a JSON object with OK status.

Now we need to generate the Service Worker script:

npx msw init ./public --save

The --save flag will save the package to package.json so we could update the script later by running msw init.

Note The public directory may be different for projects not using Create React App.

Create another JavaScript module that will register our Service Worker with our mocks, src/mocks/browser.js:

import { setupWorker } from 'msw/browser';
import { http, HttpResponse } from 'msw';
import { handlers } from './handlers';
 
// This configures a Service Worker with the given request handlers
export const worker = setupWorker(...handlers);
 
// Expose method globally to make them available in integration tests
window.msw = { worker, http, HttpResponse };

And the last step is to start the worker function when we run our app in development mode. Add these lines to our app root module (src/index.js for Create React App):

async function enableMocking() {
  if (process.env.NODE_ENV !== 'development') {
    return;
  }
 
  const { worker } = await import('./mocks/browser');
 
  // `worker.start()` returns a Promise that resolves
  // once the Service Worker is up and ready to intercept requests.
  return worker.start();
}

And update the way we render the React app to await the Promise returned by enableMocking() function before rendering anything:

enableMocking().then(() => {
  const root = createRoot(document.getElementById('root'));
  root.render(<App />);
});
+ enableMocking().then(() => {
+  const root = createRoot(document.getElementById('root'));
-  root.render(<App />);
- });

Now, every time we run our app in the development mode or on CI, network requests will be mocked, without any changes to the application code or tests, except these few lines of code in the root module.

Creating our first test

As defined in our config file, Playwright looks for test files inside the tests/ folder. Feel free to remove the example.spec.js file from there — we won’t need it.

So, let’s create our first test, tests/hello.spec.js:

const { test, expect } = require('@playwright/test');
 
test('hello world', async ({ page }) => {
  await page.goto('/');
  await expect(page.getByText('welcome back')).toBeVisible();
});

Here, we’re visiting the homepage of our app running in the development server, then validating that the text “welcome back” is present on the page using Playwright’s getByText() locator, and toBeVisible() assertion.

Running tests

Start Playwright in the UI mode by runnig npm run test:e2e. From here run a single test or all tests. You could press an eye icon next to a single test or a group to automatically rerun them on every change in the code of the test.

Running a test in Playwright

When I write tests, I usually watch a single test (meaning Playwrite reruns it for me on every change), otherwise it’s too slow and too hard to see what’s wrong if there are any issues.

Run npm run test:e2e:ci to run all tests in the headless mode, meaning we won’t see the browser window:

Running Playwright in the terminal

Querying DOM elements for tests

Tests should resemble how users interact with the app. That means we shouldn’t rely on implementation details because the implementation can change and we’ll have to update our tests. This also increases the chance of false positives when tests are passing but the actual feature is broken.

Let’s compare different methods of querying DOM elements:

SelectorRecommendedNotes
buttonNeverWorst: too generic
.btn.btn-largeNeverBad: coupled to styles
#mainNeverBad: avoid IDs in general
[data-testid="cookButton"]SometimesOkay: not visible to the user, but not an implementation detail, use when better options aren’t available
[alt="Chuck Norris"], [role="banner"]OftenGood: still not visible to users, but already part of the app UI
[children="Cook pizza!"]AlwaysBest: visible to the user part of the app UI

To summarise:

  • Text content may change and we’ll need to update our tests. This may not be a problem if our translation library only render string IDs in tests, or if we want our test to work with the actual text users see in the app.
  • Test IDs clutter the markup with props we only need in tests. Test IDs are also something that users of our app don’t see: if we remove a label from a button, a test with test ID will still pass.

Playwright has methods for all good queries, which are called locators:

  • page.getByAltText() finds an image by its alt text;
  • page.getByLabel() finds a form element by its <label>;
  • page.getByPlaceholder() finds a form element by its placeholder text;
  • page.getByRole() finds an element by its ARIA role;
  • page.getByTestId() finds an element by its test ID;
  • page.getByText() finds an element by its text content;
  • page.getByTitle() finds an element by its title attribute.

Let’s see how to use locators. To select this button in a test:

<button data-testid="cookButton">Cook pizza!</button>

We can either query it by the test ID:

page.getByTestId('cookButton');

Or query it by its text content:

page.getByText('cook pizza');

Note text locators are partial and case-insensitive by default which makes them more resilient to small tweaks and changes in the content. For an exact match, use the exact opiton: page.getByText('Cook pizza!', {exact: true});.

Or, the best method, query it by its ARIA role and label:

page.getByRole('button', { name: 'cook pizza' });

Benefits of the last method are:

  • doesn’t clutter the markup with test IDs, that aren’t perceived by users;
  • doesn’t give false positives when the same text is used in non-interactive content;
  • makes sure that the button is an actual button element or at least have the button ARIA role.

Check the Testing Library docs for more details on best practices, and inherent roles of HTML elements.

Testing React apps end-to-end

Testing basic user interaction

A typical integration test looks like this: visit the page, interact with it, check the changes on the page after the interaction. For example:

const { test, expect } = require('@playwright/test');
 
test('navigates to another page', async ({ page }) => {
  await page.goto('/');
 
  // Opening the pizza page
  await page.getByRole('link', { name: 'remotepizza' }).click();
 
  // We are on the pizza page
  await expect(
    page.getByRole('heading', { name: 'pizza' })
  ).toBeVisible();
});

Here we’re finding a link by its ARIA role and text using the Playwright’s getByRole() locator, and clicking it using the click() method. Then we’re verifying that we’re on the correct page by checking its heading, first by finding it the same way we found the link before, and testing with the toBeVisible() assertion.

With Playwright we generally don’t have to care if the actions are synchronous or asynchronous: each command will wait for some time for the queried element to appear on the page. Though, we should use await manually for most operations. This avoids flakiness and complexity of asynchronous testing, and keeps the code straightforward.

Testing forms

Playwright’s locators allow us to access any form element by its visible or accessible label.

For example, we have a registration form with text inputs, selects, checkboxes and radio buttons. We can test it like this:

const { test, expect } = require('@playwright/test');
 
test('should show success page after submission', async ({
  page
}) => {
  await page.goto('/signup');
 
  // Filling the form
  await page.getByLabel('first name').fill('Chuck');
  await page.getByLabel('last name').fill('Norris');
  await page.getByLabel('country').selectOption({ label: 'Russia' });
  await page.getByLabel('english').check();
  await page.getByLabel('subscribe to our newsletter').check();
 
  // Submit the form
  await page.getByRole('button', { name: 'sign in' }).click();
 
  // We are on the success page
  await expect(
    page.getByText('thank you for signing up')
  ).toBeVisible();
});

Here we’re using getByLabel() and getByRole() locators to find elements by their label text or ARIA role. Then we’re using the fill(), selectOption(), and check() methods to fill the form, and the click() method to submit it by clicking the submit button.

Testing complex forms

In the previous example, we used the getByLabel() locator to find form elements, which works when all form elements have unique labels, but this isn’t always the case.

For example, we have a passport number section in our registration form where multiple inputs have the same label — like “year” of the issue date and “year” of the expiration date. The markup of each field group looks like so:

<fieldset>
  <legend>Passport issue date</legend>
  <input type="number" aria-label="Day" placeholder="Day" />
  <select aria-label="Month">
    <option value="1">Jan</option>
    <option value="2">Feb</option>
    ...
  </select>
  <input type="number" aria-label="Year" placeholder="Year" />
</fieldset>

To access a particular field, we can select a fieldset by its legend text, and then select an input by its label inside the fieldset.

const passportIssueDateGroup = page.getByRole('group', {
  name: 'passport issue date'
});
await passportIssueDateGroup.getByLabel('day').fill('12');
await passportIssueDateGroup
  .getByLabel('month')
  .selectOption({ label: 'May' });
await passportIssueDateGroup.getByLabel('year').fill('2004');

We call getByRole() locator with group — ARIA role of fieldset — and its legend text. Then we chain getByLabel() locator to query form fields by their labels.

There are several ways to test links that open in a new tab:

  • check the link’s href without clicking it;
  • get the handle of the new page and use it instead of the current one (page).

In the first method, we query the link by its ARIA role and text, and verify that the URL in its href attribute is correct:

await expect(
  page.getByRole('link', { name: 'terms and conditions' })
).toHaveAttribute('href', /\/toc/);

The main drawback of this method is that we’re not testing that the link is actually clickable. It might be hidden, or might have a click handler that prevents the default browser behavior.

In the second method, we query the link by its ARIA role and text again, click it, get the handle of the new page, and use it instead of the current one:

const pagePromise = context.waitForEvent('page');
await page
  .getByRole('link', { name: 'terms and conditions' })
  .click();
const newPage = await pagePromise;
await expect(newPage.getByText("i'm baby")).toBeVisible();

Now we could check that we’re on the correct page by finding some text unique to this page.

I recommend this method because it better resembles the actual user behavior.

There are other solutions, but I don’t think they are any better than these two.

Testing network requests, and mocks

Having MSW mocks setup (see “Setting up Mock Service Worker” above), happy path tests of pages with asynchronous data fetching aren’t different from any other tests.

For example, we have an API that returns a list of pizza ingredients:

const { test, expect } = require('@playwright/test');
 
const ingredients = ['bacon', 'tomato', 'mozzarella', 'pineapples'];
 
test('load ingredients asynchronously', async ({ page }) => {
  await page.goto('/remote-pizza');
 
  // Ingredients list is not visible
  await expect(page.getByText(ingredients[0])).toBeHidden();
 
  // Load ingredients
  await page.getByRole('button', { name: 'cook' }).click();
 
  // All ingredients appear on the screen
  for (const ingredient of ingredients) {
    await expect(page.getByText(ingredient)).toBeVisible();
  }
 
  // The button is not clickable anymore
  await expect(
    page.getByRole('button', { name: 'cook' })
  ).toBeDisabled();
});

Playwright will wait until the data is fetched and rendered on the screen, and thanks to network calls mockings it won’t be long.

For not so happy path tests, we may need to override global mocks inside a particular test. For example, we could test what happens when our API returns an error:

test('shows an error message', async ({ page }) => {
  await page.goto('/remote-pizza');
 
  page.evaluate(() => {
    // Reference global instances set in src/browser.js
    const { worker, http, HttpResponse } = window.msw;
    worker.use(
      http.get(
        'https://httpbin.org/anything',
        () => HttpResponse.json(null, { status: 500 }),
        { once: true }
      )
    );
  });
 
  // Ingredients list is not visible
  await expect(page.getByText(ingredients[0])).toBeHidden();
 
  // Load ingredients
  await page.getByRole('button', { name: 'cook' }).click();
 
  // Ingredients list is still not visible and error message appears
  await expect(page.getByText(ingredients[0])).toBeHidden();
  await expect(page.getByText('something went wrong')).toBeVisible();
});

Here, we’re using the MSW’s use() method to override the default mock response for our endpoint during a single test. Also note that we’re using once option of the http.get() method, otherwise the override will be added permanently and may interfere with other tests.

Testing complex pages

We should avoid test IDs wherever possible, and use more semantic queries instead. However, sometimes we need to be more precise. For example, we have a “delete profile” button on our user profile page that shows a confirmation modal with “delete profile” and “cancel” buttons inside. We need to know which of the two delete buttons we’re pressing in our tests.

The markup could look like so:

<button type="button">Delete profile</button>
<div
  role="dialog"
  aria-label="Delete profile modal"
  aria-modal="true"
>
  <h1>Delete profile</h1>
  <button type="button">Delete profile</button>
  <button type="button">Cancel</button>
</div>

The first “delete profile” isn’t a problem becase when we click it, it’s the only one present on the page. However, when the modal is open, we have two buttons with the same label. So, how do we click the one inside the modal dialog?

First option would be to assign a test ID to this button, and sometimes it’s the only way. Usually, though, we can do better. We can nest locators, so we could target the container (the modal dialog) first, and then the button we need inside the container:

await page
  // Locate the dialog by its aria-label
  .getByRole('dialog', { name: 'delete profile modal' })
  // Locate the button by its label inside the dialog
  .getByRole('button', { name: 'delete profile' })
  // Click the button
  .click();

It’s slighly more complex when the container doesn’t have any semantic way to target it, like a section with a heading (h1, h2, an so on) inside. In this case we can target all sections on a page, and then filter then to find the one we’re looking for.

Imagine markup like so:

<section>
  <h2>Our newsletter</h2>
  <form>
    <input
      type="email"
      name="email"
      arial-label="Email"
      placeholder="Email"
    />
    <button type="submit">Subscribe</button>
  </form>
</section>

We can click the “Subscribe” button inside the “Our newsletter” section in a test like so:

await page
  // Locate all sections on a page
  .getByRole('section')
  .filter({
    // Filter only ones that contain "Our newsletter" heading
    has: page.getByRole('heading', { name: 'our newsletter' })
  })
  // Locate the button inside the section
  .getByRole('button', { name: 'subscribe' })
  .click();

Coming back to our profile deletion modal, we can test it like so:

const { test, expect } = require('@playwright/test');
 
test('should show success message after profile deletion', async ({
  page
}) => {
  await page.goto('/profile');
 
  // Attempting to delete profile
  await page.getByRole('button', { name: 'delete profile' }).click();
 
  // Confirming deletion
  await page
    .getByRole('dialog', { name: 'delete profile modal' })
    .getByRole('button', { name: 'delete profile' })
    .click();
 
  // We are on the success page
  await expect(
    page.getByRole('heading', { name: 'your profile was deleted' })
  ).toBeVisible();
});

Here, we’re using getByRole() locator, as in previous examples, to find all elements we need.

Debugging

Playwright docs have a thorough debugging guide.

However, it’s usually enough to check the locator or inspect the DOM for a particular step of the test after running the tests. Click any operation in the log first, and then:

To debug a locator the DOM, click the Pick locator button, and hover over an element you want to target. You can use the Locator tab below to edit it and see if it still matches the element you need.

Using browser developer tools in Playwright

To inspect the DOM, click the Open snapshot in a new tab button, and use the browser developer tools the way you’d normally do.

Using browser developer tools in Playwright

I also often focus on a single test with test.only(), and watch a single file by toggling the eye button in the Playwright UI to make reruns faster and avoid seeing too many errors while I debug why tests are failing.

test.only('hello world', async ({ page }) => {
  // Playwright will skip other tests in this file
});

Conclusion

Good tests interact with the app similar to how a real user would do that, they don’t test implementation details, and they are resilient to code changes that don’t change the behavior. We’ve learned how to write good end-to-end tests using Playwright, how to set it up, and how to mock network requests using Mock Service Worker.

However, Playwright has many more features that we haven’t covered in the article, and that may be useful one day.