Skip to content

Instantly share code, notes, and snippets.

@sebinsua
Last active Jan 5, 2022
Embed
What would you like to do?
Playwright `@testing-library/dom` custom selectors. Contributed to discussion here: https://github.com/hoverinc/playwright-testing-library/pull/330#issuecomment-956348391
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}
`);
});
}
@sebinsua
Copy link
Author

sebinsua commented Oct 14, 2021

Use like so:

import { test, expect } from "@playwright/test";
import { installTestingLibrarySelectors } from "./testUtils";

installTestingLibrarySelectors();

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?");
});

@petetnt
Copy link

petetnt commented Dec 22, 2021

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 in beforeAll? I tried to global-setup for example but that didn't work.

I also filed that as microsoft/playwright#11058

@petetnt
Copy link

petetnt commented Dec 23, 2021

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}
          `);
    });
  }

@sebinsua
Copy link
Author

sebinsua commented Jan 5, 2022

@petetnt Just seen this now. Thank you for posting some improvements to my Gist!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment