Last active
June 21, 2022 12:49
-
-
Save sebinsua/205682c7af6966fdadc103b4e15d0d8b to your computer and use it in GitHub Desktop.
Playwright `@testing-library/dom` custom selectors. Contributed to discussion here: https://github.com/hoverinc/playwright-testing-library/pull/330#issuecomment-956348391
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { test, selectors } from '@playwright/test'; | |
import { readFile } from 'fs-extra'; | |
import type * as TestingLibraryDom from '@testing-library/dom'; | |
// This type was copied across from Playwright's repo. | |
// See: https://github.com/microsoft/playwright/blob/82ff85b106e31ffd7b3702aef260c9c460cfb10c/packages/playwright-core/src/client/types.ts#L108-L117 | |
type SelectorEngine = { | |
/** | |
* Returns the first element matching given selector in the root's subtree. | |
*/ | |
query(root: HTMLElement, selector: string): HTMLElement | null; | |
/** | |
* Returns all elements matching given selector in the root's subtree. | |
*/ | |
queryAll(root: HTMLElement, selector: string): HTMLElement[]; | |
}; | |
// Both `SelectorEngine#query` and `SelectorEngine#queryAll` receive a selector | |
// including any quote marks wrapped around it. The `@testing-library/dom` methods we're | |
// using internally expect to be passed strings that are not wrapped in quote marks | |
// therefore we strip them if they exist. | |
function stripWrappingQuotesFromSelector(selector: string): string { | |
const firstChar = selector.charAt(0); | |
const lastChar = selector.charAt(selector.length - 1); | |
if ( | |
(firstChar === `'` && lastChar === `'`) || | |
(firstChar === `"` && lastChar === `"`) || | |
(firstChar === '`' && lastChar === '`') | |
) { | |
return selector.substring(1, selector.length - 1); | |
} | |
return selector; | |
} | |
// **The more your tests resemble the way your software is used, the more confidence they can give you.** | |
// | |
// With this in mind, we recommend this order of priority: | |
// | |
// 1. `role=button >> text="Text inside button"` | |
// 2. `label="Label name of form element displayed to user"` | |
// 3. `placeholder="Placeholder text of input element displayed to user"` | |
// 4. `text="Text currently shown on page"` | |
// 5. `value="current-value"` | |
// 6. `alt="Alternative text used to describe image"` | |
// 7. `title="Advisory information against element"` | |
// 8. `data-testid="some-test-id-you-attached-to-the-element"` | |
// | |
// Selectors defined as `engine=body` or in short-form can be combined with the `>>` token, | |
// e.g. `selector1 >> selector2 >> selector3`. When selectors are chained, the next one is | |
// queried relative to the previous one's result. | |
// | |
// See: https://testing-library.com/docs/queries/about#priority | |
// See: https://playwright.dev/docs/selectors#best-practices | |
// See: https://playwright.dev/docs/selectors/#chaining-selectors | |
export function installTestingLibrarySelectors() { | |
// We use `selectors#register` to register custom selectors in the browser. | |
// | |
// It calls `toString` on any function passed into it, before transpiling | |
// this and sending it on to the browser. Therefore, any reference here | |
// to imports in this file are unreliable due to renaming (e.g. `_dom`, | |
// `_dom2`, etc). | |
// | |
// That is why we grab `@testing-library/dom` from the `window` and need | |
// to inject any objects or functions into the page before the selectors | |
// can work. | |
// | |
// See: https://playwright.dev/docs/api/class-selectors/#selectors-register | |
test.beforeAll(async () => { | |
await selectors.register( | |
'role', | |
(): SelectorEngine => ({ | |
query(root, selector) { | |
// @ts-ignore | |
const testingLibrary: typeof TestingLibraryDom = window['TestingLibraryDom']; | |
return testingLibrary.queryByRole(root, stripWrappingQuotesFromSelector(selector)); | |
}, | |
queryAll(root, selector) { | |
// @ts-ignore | |
const testingLibrary: typeof TestingLibraryDom = window['TestingLibraryDom']; | |
return testingLibrary.queryAllByRole(root, stripWrappingQuotesFromSelector(selector)); | |
}, | |
}) | |
); | |
await selectors.register( | |
'label', | |
(): SelectorEngine => ({ | |
query(root, selector) { | |
// @ts-ignore | |
const testingLibrary: typeof TestingLibraryDom = window['TestingLibraryDom']; | |
return testingLibrary.queryByLabelText(root, stripWrappingQuotesFromSelector(selector)); | |
}, | |
queryAll(root, selector) { | |
// @ts-ignore | |
const testingLibrary: typeof TestingLibraryDom = window['TestingLibraryDom']; | |
return testingLibrary.queryAllByLabelText(root, stripWrappingQuotesFromSelector(selector)); | |
}, | |
}) | |
); | |
await selectors.register( | |
'placeholder', | |
(): SelectorEngine => ({ | |
query(root, selector) { | |
// @ts-ignore | |
const testingLibrary: typeof TestingLibraryDom = window['TestingLibraryDom']; | |
return testingLibrary.queryByPlaceholderText(root, stripWrappingQuotesFromSelector(selector)); | |
}, | |
queryAll(root, selector) { | |
// @ts-ignore | |
const testingLibrary: typeof TestingLibraryDom = window['TestingLibraryDom']; | |
return testingLibrary.queryAllByPlaceholderText(root, stripWrappingQuotesFromSelector(selector)); | |
}, | |
}) | |
); | |
await selectors.register( | |
'value', | |
(): SelectorEngine => ({ | |
query(root, selector) { | |
// @ts-ignore | |
const testingLibrary: typeof TestingLibraryDom = window['TestingLibraryDom']; | |
return testingLibrary.queryByDisplayValue(root, stripWrappingQuotesFromSelector(selector)); | |
}, | |
queryAll(root, selector) { | |
// @ts-ignore | |
const testingLibrary: typeof TestingLibraryDom = window['TestingLibraryDom']; | |
return testingLibrary.queryAllByDisplayValue(root, stripWrappingQuotesFromSelector(selector)); | |
}, | |
}) | |
); | |
await selectors.register( | |
'alt', | |
(): SelectorEngine => ({ | |
query(root, selector) { | |
// @ts-ignore | |
const testingLibrary: typeof TestingLibraryDom = window['TestingLibraryDom']; | |
return testingLibrary.queryByAltText(root, stripWrappingQuotesFromSelector(selector)); | |
}, | |
queryAll(root, selector) { | |
// @ts-ignore | |
const testingLibrary: typeof TestingLibraryDom = window['TestingLibraryDom']; | |
return testingLibrary.queryAllByAltText(root, stripWrappingQuotesFromSelector(selector)); | |
}, | |
}) | |
); | |
await selectors.register( | |
'title', | |
(): SelectorEngine => ({ | |
query(root, selector) { | |
// @ts-ignore | |
const testingLibrary: typeof TestingLibraryDom = window['TestingLibraryDom']; | |
return testingLibrary.queryByTitle(root, stripWrappingQuotesFromSelector(selector)); | |
}, | |
queryAll(root, selector) { | |
// @ts-ignore | |
const testingLibrary: typeof TestingLibraryDom = window['TestingLibraryDom']; | |
return testingLibrary.queryAllByTitle(root, stripWrappingQuotesFromSelector(selector)); | |
}, | |
}) | |
); | |
}); | |
test.beforeEach(async ({ context }) => { | |
const testingLibraryDomUmdScript = await readFile( | |
// The location of the `@testing-library/dom` UMD script is found inside | |
// the `node_modules/` directory. | |
require.resolve('@testing-library/dom/dist/@testing-library/dom.umd.js'), | |
'utf8' | |
); | |
// On every page load, we must ensure that both `window['TestingLibraryDom']` | |
// and `stripWrappingQuotesFromSelector` are available to our custom selectors. | |
await context.addInitScript(` | |
${testingLibraryDomUmdScript} | |
${stripWrappingQuotesFromSelector} | |
`); | |
}); | |
} |
I got good tips from microsoft/playwright#11058, which recommended adding the selectors as automatic fixtures, which worked great. Here's my current implementation, see the few changes on registering the selectors, scripts and usage:
/**
* Copied from https://gist.github.com/sebinsua/205682c7af6966fdadc103b4e15d0d8b as
* playwright-testing-library doesn't support Locators properly yet. See
* https://github.com/testing-library/playwright-testing-library/pull/330 for additional info.
*
* Usage:
*
*
import { test, except, installTestingLibraryScripts } from "./test-utils";
installTestingLibraryScripts();
test("role test", async ({ page }) => {
await page.goto("https://playwright.dev/");
await expect(page.locator("role=heading").first()).toContainText(
"Playwright enables reliable end-to-end testing for modern web apps."
);
});
test("placeholder test", async ({ page }) => {
await page.goto("https://playwright.dev/");
await page
.locator("placeholder=Search")
.type("typity typity", { delay: 100 });
});
test("label test", async ({ page }) => {
await page.goto("https://43cvn.csb.app/");
await page.locator("label=Standard").first().click();
await page.locator("label=Standard").first().type("OK?");
});
*/
/* eslint-disable
import/no-extraneous-dependencies,
@typescript-eslint/no-unsafe-assignment,
@typescript-eslint/restrict-template-expressions,
@typescript-eslint/ban-ts-comment,
dot-notation
*/
import { test as base, selectors } from '@playwright/test';
import { readFile } from 'fs/promises';
import type * as TestingLibraryDom from '@testing-library/dom';
declare global {
interface Window {
TestingLibraryDom: typeof TestingLibraryDom
}
}
// This type was copied across from Playwright's repo.
// See: https://github.com/microsoft/playwright/blob/82ff85b106e31ffd7b3702aef260c9c460cfb10c/packages/playwright-core/src/client/types.ts#L108-L117
type SelectorEngine = {
/**
* Returns the first element matching given selector in the root's subtree.
*/
query(root: HTMLElement, selector: string): HTMLElement | null;
/**
* Returns all elements matching given selector in the root's subtree.
*/
queryAll(root: HTMLElement, selector: string): HTMLElement[];
};
// Both `SelectorEngine#query` and `SelectorEngine#queryAll` receive a selector
// including any quote marks wrapped around it. The `@testing-library/dom` methods we're
// using internally expect to be passed strings that are not wrapped in quote marks
// therefore we strip them if they exist.
function stripWrappingQuotesFromSelector(selector: string): string {
const firstChar = selector.charAt(0);
const lastChar = selector.charAt(selector.length - 1);
if (
(firstChar === `'` && lastChar === `'`) ||
(firstChar === `"` && lastChar === `"`) ||
(firstChar === '`' && lastChar === '`')
) {
return selector.substring(1, selector.length - 1);
}
return selector;
}
// **The more your tests resemble the way your software is used, the more confidence they can give you.**
//
// With this in mind, we recommend this order of priority:
//
// 1. `role=button >> text="Text inside button"`
// 2. `label="Label name of form element displayed to user"`
// 3. `placeholder="Placeholder text of input element displayed to user"`
// 4. `text="Text currently shown on page"`
// 5. `value="current-value"`
// 6. `alt="Alternative text used to describe image"`
// 7. `title="Advisory information against element"`
// 8. `data-testid="some-test-id-you-attached-to-the-element"`
//
// Selectors defined as `engine=body` or in short-form can be combined with the `>>` token,
// e.g. `selector1 >> selector2 >> selector3`. When selectors are chained, the next one is
// queried relative to the previous one's result.
//
// See: https://testing-library.com/docs/queries/about#priority
// See: https://playwright.dev/docs/selectors#best-practices
// See: https://playwright.dev/docs/selectors/#chaining-selectors
export default async function installTestingLibrarySelectors() {
// We use `selectors#register` to register custom selectors in the browser.
//
// It calls `toString` on any function passed into it, before transpiling
// this and sending it on to the browser. Therefore, any reference here
// to imports in this file are unreliable due to renaming (e.g. `_dom`,
// `_dom2`, etc).
//
// That is why we grab `@testing-library/dom` from the `window` and need
// to inject any objects or functions into the page before the selectors
// can work.
//
// See: https://playwright.dev/docs/api/class-selectors/#selectors-register
try {
await selectors.register(
'role',
(): SelectorEngine => ({
query(root, selector) {
const testingLibrary = window['TestingLibraryDom'];
return testingLibrary.queryByRole(
root,
stripWrappingQuotesFromSelector(selector),
);
},
queryAll(root, selector) {
const testingLibrary = window['TestingLibraryDom'];
return testingLibrary.queryAllByRole(
root,
stripWrappingQuotesFromSelector(selector),
);
},
}),
);
await selectors.register(
'label',
(): SelectorEngine => ({
query(root, selector) {
const testingLibrary = window['TestingLibraryDom'];
return testingLibrary.queryByLabelText(
root,
stripWrappingQuotesFromSelector(selector),
);
},
queryAll(root, selector) {
const testingLibrary = window['TestingLibraryDom'];
return testingLibrary.queryAllByLabelText(
root,
stripWrappingQuotesFromSelector(selector),
);
},
}),
);
await selectors.register(
'placeholder',
(): SelectorEngine => ({
query(root, selector) {
const testingLibrary = window['TestingLibraryDom'];
return testingLibrary.queryByPlaceholderText(
root,
stripWrappingQuotesFromSelector(selector),
);
},
queryAll(root, selector) {
const testingLibrary = window['TestingLibraryDom'];
return testingLibrary.queryAllByPlaceholderText(
root,
stripWrappingQuotesFromSelector(selector),
);
},
}),
);
await selectors.register(
'value',
(): SelectorEngine => ({
query(root, selector) {
const testingLibrary = window['TestingLibraryDom'];
return testingLibrary.queryByDisplayValue(
root,
stripWrappingQuotesFromSelector(selector),
);
},
queryAll(root, selector) {
const testingLibrary = window['TestingLibraryDom'];
return testingLibrary.queryAllByDisplayValue(
root,
stripWrappingQuotesFromSelector(selector),
);
},
}),
);
await selectors.register(
'alt',
(): SelectorEngine => ({
query(root, selector) {
const testingLibrary = window['TestingLibraryDom'];
return testingLibrary.queryByAltText(
root,
stripWrappingQuotesFromSelector(selector),
);
},
queryAll(root, selector) {
const testingLibrary = window['TestingLibraryDom'];
return testingLibrary.queryAllByAltText(
root,
stripWrappingQuotesFromSelector(selector),
);
},
}),
);
await selectors.register(
'title',
(): SelectorEngine => ({
query(root, selector) {
// @ts-ignore
const testingLibrary: typeof TestingLibraryDom =
window['TestingLibraryDom'];
return testingLibrary.queryByTitle(
root,
stripWrappingQuotesFromSelector(selector),
);
},
queryAll(root, selector) {
// @ts-ignore
const testingLibrary: typeof TestingLibraryDom =
window['TestingLibraryDom'];
return testingLibrary.queryAllByTitle(
root,
stripWrappingQuotesFromSelector(selector),
);
},
}),
);
} catch (err) {
// eslint-disable-next-line no-console
console.error('Did not register testing library functions:', err);
}
}
export const test = base.extend<Record<string, unknown>, { registerSelectors: void }>({
// eslint-disable-next-line no-empty-pattern
registerSelectors: [async ({}, use) => {
await installTestingLibrarySelectors();
await use();
}, { scope: 'worker', auto: true }],
});
export { expect } from '@playwright/test';
export function installTestingLibraryScripts() {
test.beforeEach(async ({ context }) => {
const testingLibraryDomUmdScript = await readFile(
// The location of the `@testing-library/dom` UMD script is found inside
// the `node_modules/` directory.
require.resolve('@testing-library/dom/dist/@testing-library/dom.umd.js'),
'utf8',
);
// On every page load, we must ensure that both `window['TestingLibraryDom']`
// and `stripWrappingQuotesFromSelector` are available to our custom selectors.
await context.addInitScript(`
${testingLibraryDomUmdScript}
${stripWrappingQuotesFromSelector}
`);
});
}
@petetnt Just seen this now. Thank you for posting some improvements to my Gist!
Looks like the next version of @playwright-testing-library/test
will do something similar to this (and supersede this logic).
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Thanks for this! ❤️
We've been trying out a slightly modified version of this now, but in CI we are running to
Error: "role" selector engine has been already registered
error when running the tests (with a single worker). Wrapping the registration in try / catch works (with conditionally snooping if the failure is a registration error), but any idea if the selectors could be registered, but any idea if selectors could be registered somewhere else than inbeforeAll
? I tried toglobal-setup
for example but that didn't work.I also filed that as microsoft/playwright#11058