Playwright is a framework-agnostic end-to-end testing (also known as E2E, or integration testing) tool for web apps. Playwright has great developer experience and makes writing good and 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.
Playwright has many benefits over other end-to-end test runners:
The best experience writing and debugging tests.
An 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 present in the DOM, isn’t disabled, and isn’t hidden behind another element or offscreen 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 in a way that is similar to how a real user would do that: for example, find form elements and buttons by their labels. It helps us avoid testing implementation details, making our tests resilient to code changes that don’t change the behavior.
Playwright is 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 us all the necessary tools in a single package. Also, the API feels more cohesive and intentional. There wasn’t a lot of new things to learn.
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 them for us (see below) and have a single command that we can also use on a continuous integration (CI) server.
As a development server, we can use an actual development server of our app, like Create React App (that we use for the examples) or Vite, or another tool like React Styleguidist or Storybook, to test isolated components.
We’ve added two scripts to run the development server and Playwright together:
npm run test:e2e to run a development server and Playwright ready for local development;
npm run test:e2e:ci to run a development 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:
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 a run development server; we also want to reuse an already-running server unless we’re in the CI environment.
The --save flag will save the public directory path to package.json so we can update the worker script later by running just msw init.
Note The public directory may be different for projects not using Create React App.
Then, create another JavaScript module that will register our Service Worker with our mocks, src/mocks/browser.js:
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):
And update the way we render the React app to await the Promise returned by enableMocking() function before rendering anything:
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.
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.
Here, we’re visiting the homepage of our app running on the development server, then validating that the text “welcome back” is present on the page using Playwright’s getByText() locator, and toBeVisible() assertion.
Start Playwright in the UI mode by running npm run test:e2e. From here, either run a single test or all tests. We can 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.
When I write tests, I usually watch a single test (meaning Playwright 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:
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:
Selector
Recommended
Notes
button
Never
Worst: too generic
.btn.btn-large
Never
Bad: coupled to styles
#main
Never
Bad: avoid IDs in general
[data-testid="cookButton"]
Sometimes
Okay: not visible to the user, but not an implementation detail; use when better options aren’t available
[alt="Chuck Norris"], [role="banner"]
Often
Good: still not visible to users, but already part of the app UI
[children="Cook pizza!"]
Always
Best: visible to the user part of the app UI
To summarize:
Prefer to query elements by their visible (for example, button label) or accessible name (for example, image alt).
Use test IDs as the last resort. They 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.
Note I often hear this complaint about using labels to query elements: they break when the app copy is updated. I consider this a feature: I’ve seen more than once that a button label change on one screen broke some other screen where this change was undesired.
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:
We can either query it by its test ID:
Or query it by its text content:
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 option: page.getByText('Cook pizza!', {exact: true}).
Or, the best method is to query it by its ARIA role and label:
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 has the button ARIA role.
A typical integration test looks like this: visit the page, interact with it, and check the changes on the page after the interaction. For example:
Here, we’re finding a link by its ARIA role (link) 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 the heading 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 explicitly await most operations. This avoids the flakiness and complexity of asynchronous testing and keeps the code straightforward.
Playwright’s locators allow us to access any form element by its visible (for example, <label> element) or accessible (for example, aria-label attribute) label.
For example, we have a registration form with a bunch of text inputs, select boxes, checkboxes, and radio buttons. This is how we can test it:
Here we’re using getByLabel() and getByRole() locators to find elements by their label texts or ARIA roles. Then we use the fill(), selectOption(), and check() methods to fill the form, and the click() method to submit it by clicking the submit button.
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:
To access a particular field, we can select a fieldset by its legend text first, and then select an input by its label inside the fieldset.
We call getByRole() locator with the ARIA role of fieldset (group) and the text of itslegend. Then we chain the 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 attribute without clicking it;
click the link, and then 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:
The main drawback of this method is that we’re not testing whether the link is actually clickable. It might be hidden, or it 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 the new page handle instead of the current one:
Now, we could verify that we’re on the correct page by finding some text unique to this page.
I recommend the second 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.
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:
Playwright will wait until the data is fetched and rendered on the screen, and thanks to network calls mocking 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:
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 the once option of the http.get() method; otherwise the override will be added permanently and may interfere with other tests.
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 first “delete profile” isn’t a problem because 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?
The first option would be to assign a test ID to this button, and sometimes that’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:
It’s slightly more complex when the container doesn’t have any semantic way to target it, like a section with a heading (h1, h2, and so on) inside. In this case, we can target all sections on a page and then filter them to find the one we’re looking for.
Imagine markup like so:
We can click the “Subscribe” button inside the “Our newsletter” section in a test like so:
Coming back to our profile deletion modal, we can test it like so:
Here, we’re using the getByRole() locator, as in previous examples, to find all elements we need.
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 do one of the following:
To debug a locator the DOM, click the Pick locator button, and hover over an element we want to target. We can use the Locator tab below to edit it and see if it still matches the element we need.
To inspect the DOM, click the Open snapshot in a new tab button and use the browser developer tools the way we’d normally do.
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.
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 that may be useful one day.