Puppeteer Testing Library
Custom queries and Jest matchers for Puppeteer and Playwright with enforced best practices.
Quick Start
import { find } from 'puppeteer-testing-library';
import 'puppeteer-testing-library/extend-expect';
const userNameInput = await find({ role: 'textbox', name: 'User Name' });
await userNameInput.type('My name');
expect(userNameInput).toMatchQuery({ value: 'My name' });
const submitButton = await find({ role: 'button', name: 'Submit' });
await submitButton.click();
Installation and Usage
npm install --save-dev puppeteer-testing-library
yarn add -D puppeteer-testing-library
puppeteer-testing-library
needs a special flag to be passed to chromium for the queries to work. Import launchArgs
and pass it to the args
options in puppeteer.launch
.
import { launchArgs } from 'puppeteer-testing-library';
const browser = await puppeteer.launch({
args: launchArgs(),
});
You can pass in additional args to launchArgs()
, it will handle the merging for you.
const browser = await puppeteer.launch({
args: launchArgs(['--mute-audio']),
});
If you're using jest-puppeteer
, pass it to jest-puppeteer.config.js
const { launchArgs } = require('puppeteer-testing-library');
module.exports = {
launch: {
args: launchArgs(),
},
};
puppeteer-testing-library
also supports Playwright out of the box. The configuration is very similar with jest-playwright
. (Note that it only supports chromium browsers at the time though.)
const { launchArgs } = require('puppeteer-testing-library');
module.exports = {
launchOptions: {
args: launchArgs(),
},
};
puppeteer-testing-library
expects there's a page
variable globally to perform most of the queries. This is already the default in jest-puppeteer
, so you don't have to do anything if you're using it. Or, you can directly assign global.page
to the current page
instance.
global.page = await browser.newPage();
You can also use the configure
API to assign them globally.
import { configure } from 'puppeteer-testing-library';
const page = await browser.newPage();
configure({
page,
});
If you'd rather do it explicitly on every query, you can pass your page
instance to the options.
const page = await browser.newPage();
const button = await find(
{ role: 'button' },
{ page }
);
Import from the extend-expect
endpoint if you want to use all of the helpful matchers in Jest. Either include it directly in the test file, or include it in the setupFilesAfterEnv
array.
Import directly:
import 'puppeteer-testing-library/extend-expect';
expect(elementHandle).toBeVisible();
In setupFilesAfterEnv
:
module.exports = {
setupFilesAfterEnv: [
'puppeteer-testing-library/extend-expect',
],
};
pptr-testing-library
is a great library with the same purpose but with the same API as the testing-library
family. It works by injecting dom-testing-library
into the browser, and expose the API. Since dom-testing-library
uses a pure JavaScript implementation of finding the role
and name
of the DOM element, it could fail in some cases where the the implementation doesn't cover. puppeteer-testing-library
uses Chrome's ComputedAccessibilityInfo
API to get their accessibility roles and names directly from the browser, hence could be a lot more predictable and match how the browser interprets the properties.
puppeteer-testing-library
also uses a simmer but more powerful API to query the elements. Instead of choosing from the findBy*
family, we can just use find
/findAll
to query almost all the elements.
jest-dom
is often used with the Testing Library family, to provide helpful custom Jest matchers. However, jest-dom
doesn't support Puppeteer for now, which makes asserting difficult and tedious. puppeteer-testing-library
bundles with a set of custom matchers which under the hood matches how the query system works, so that you can get the asserting for free with the same set of API.
How to write queries
Writing accessible queries is a lot easier with the help of browser's devtools. Since puppeteer-testing-library
only works with Chromium browsers, it's natural to also use the Chrome devtools.
Right click the element you want to query, and select "Inspect element". Let's say we want to select this button element with the text "Save settings".
The browser will open the devtools. You can see your element being highlighted in the "Elements" panel. Under "Accessibility" -> "Computed Properties", you can then see all the accessible properties of the element.
The most useful ones are name
and role
. Making a query is as simple as copying the values of them into your query.
const saveSettingsButton = await find({
role: 'button',
name: 'Save settings',
});
Sometimes, there are more than one result of the query, and puppeteer-testing-library
will throw an error if you're using find
. You can fix that by adding more accessible properties into the query to narrow down the results. You can see the full list of the available properties in the Puppeteer accessibility API doc.
API
configure({ page?: Page = global.page, timeout?: number = 3000 })
Use configure
to setup all the default options globally. Queries after configure
will all use the updated defaults.
import { configure } from 'puppeteer-testing-library';
configure({
page,
timeout: 5000,
});
configure
will return the previous assigned config, so that you can restore the configuration back to its original state.
const originalConfig = configure({ page: newPage });
configure(originalConfig);
Queries
find(query: Query, options: FindOptions): Promise<ElementHandle>
Find the element matching the query
. Will throw errors when there are no matching elements or more than one matching elements. By default, it will wait up to 3 seconds until finding the element. You can customize the timeout in the options.
Given the DOM of:
<button>My button</button>
It finds the button with the specified role
and name
:
const button = await find({ role: 'button', name: 'My button' });
FindOptions
The options has the following type:
interface FindOptions {
page?: Page = global.page;
timeout?: number | false = 3000;
root?: ElementHandle;
visible?: boolean = true;
}
You can customize the timeout
to 0
or false
to disable waiting for the element to appear in the DOM.
const buttonShouldExist = await find(
{ role: 'button', name: 'My button' },
{ timeout: 0 }
);
You can specify root
to limit the search to only within certain element.
const buttonInTheFirstSection = await find(
{ role: 'button' },
{ root: firstSection }
);
By default, it will only find the elements that are visible in the page (not necessary visible in the viewport). You can disable this check by specifying visible
to false
. Note that we just disable the check, the results could still contain visible elements.
const hiddenButton = await find(
{ role: 'button' },
{ visible: false }
);
findAll(query: Query, options: FindOptions): Promise<ElementHandle[]>
Find all the elements matching the query
. Will throw errors when there are no matching elements. By default, it will wait up to 3 seconds until finding the elements. You can customize the timeout in the options.
Given the DOM of:
<button>Button 1</button>
<button>Button 2</button>
It finds all the buttons with the specified role
:
const buttons = await findAll({ role: 'button' });
In the case where you want to assert if there're no matching elements, wrap the statement inside a try/catch block and check if the error name matches QueryEmptyError
. You will probably want to also decrease the timeout value so that the test doesn't have to wait that long.
try {
const shouldBeEmptyResults = await findAll({ role: 'button' }, { timeout: 0 });
} catch (err) {
if (err.name !== 'QueryEmptyError') {
throw err;
}
}
With Jest, you can wrap it in a assertion.
await expect(findAll({ role: 'button' }, { timeout: 0 })).rejects.toThrow('QueryEmptyError');
Or, you can assert if the error matches QueryEmptyError
.
import { QueryEmptyError } from 'puppeteer-testing-library';
await expect(findAll({ role: 'button' }, { timeout: 0 })).rejects.toThrow(QueryEmptyError);
Query
A full list of possible fields in the query is as follow:
- role
<string>
The role. - name
<string|RegExp>
A human readable name for the node. - text
<string|RegExp>
Matches the textContent
of the node. - selector
<string>
A CSS selector to query the node. - value
<string|number|RegExp>
The current value of the node. - description
<string|RegExp>
An additional human readable description of the node. - keyshortcuts
<string>
Keyboard shortcuts associated with this node. - roledescription
<string|RegExp>
A human readable alternative to the role. - valuetext
<string|RegExp>
A description of the current value. - disabled
<boolean>
Whether the node is disabled. - expanded
<boolean>
Whether the node is expanded or collapsed. - focused
<boolean>
Whether the node is focused. - modal
<boolean>
Whether the node is modal. - multiline
<boolean>
Whether the node text input supports multiline. - multiselectable
<boolean>
Whether more than one child can be selected. - readonly
<boolean>
Whether the node is read only. - required
<boolean>
Whether the node is required. - selected
<boolean>
Whether the node is selected in its parent node. - checked
<boolean|"mixed">
Whether the checkbox is checked, or "mixed". - pressed
<boolean|"mixed">
Whether the toggle button is checked, or "mixed". - level
<number>
The level of a heading. - valuemin
<number>
The minimum value in a node. - valuemax
<number>
The maximum value in a node. - autocomplete
<string>
What kind of autocomplete is supported by a control. - haspopup
<string>
What kind of popup is currently being shown for a node. - invalid
<string>
Whether and in what way this node's value is invalid. - orientation
<string>
Whether the node is oriented horizontally or vertically.
At least one of role
, name
, text
, or selector
is required in the query. While also preferring the order of role
> name
> text
> selector
when combining the query. Note that you can combine multiple fields together in your query to increase confidence.
Matchers
All matchers below are asynchronous, remember to add await
in front of the expect
statement.
toMatchQuery(query: Query)
Test if the element matches the specified query
.
await expect(elementHandle).toMatchQuery({
role: 'button',
name: 'My button',
});
toBeElement(expectedElement: ElementHandle)
Test if the element is the same element as the expectedElement
.
await expect(elementHandle).toBeElement(myButton);
toBeVisible()
Test if the element is visible in the page (but not necessary visible in the viewport).
await expect(elementHandle).toBeVisible();
toHaveFocus()
Test if the element has focus, i.e. if it is the document.activeElement
.
await expect(elementHandle).toHaveFocus();
toThrowQueryEmptyError()
Test if the query throw the QueryEmptyError
. It's just a syntax sugar to manually catch the error and checking if the error is QueryEmptyError
.
const findAllButton = findAll(
{ role: 'button', name: 'not in the DOM' },
{ timeout: 0 }
);
await expect(findAllButton).toThrowQueryEmptyError();
toBeFound(findOptions: FindOptions)
Test if the query
can be found eventually within certain timeout. This is basically the same as find
but more semantically correct. It is also useful for asserting when the element will only be disappearing after a certain amount of time (e.g. transitions or animations) with the .not
prefix.
await expect({ role: 'button', name: 'Button' }).toBeFound();
await expect({ role: 'textbox' }).not.toBeFound();
await expect({ role: 'button' }).toBeFound({ timeout: 100 });