Reusable, typed selector descriptors for Testing Library and Playwright.
Define selectors once in dependency-free files, then execute those same selectors through the adapter used by each test runtime.
npm install --save-dev testing-library-queriespnpm add --save-dev testing-library-queriesyarn add --dev testing-library-queries@testing-library/dom is an optional peer dependency. Install it only when you use the Testing Library adapter.
Playwright is not a peer dependency. The Playwright adapter uses structural types and does not import Playwright at runtime.
Selector files should import from the root package only. The root package is descriptor-only and does not import Testing Library or Playwright.
import { buildQuery, by } from 'testing-library-queries';
export const browserWorkspaceSelector = {
folderPanel: () => by.selector('[data-type="folder-panel"]'),
folderPanelTitle: (name: string) => by.text(name),
createListInput: () => by.selector('input[placeholder="List title..."]'),
createTaskInput: () => by.selector('input[placeholder="New task..."]'),
createSubtaskInput: () => by.role('textbox', { name: 'New subtask' }),
activeTask: (title: string) => buildQuery.selectorWithText(() => '[data-type="task-row"]')(title),
};Use the Testing Library adapter from testing-library-queries/testing-library.
import { render } from '@testing-library/react';
import { screen, within } from 'testing-library-queries/testing-library';
import { browserWorkspaceSelector } from './BrowserWorkspace.selector';
render(<BrowserWorkspace />);
const folderPanel = await screen.find(browserWorkspaceSelector.folderPanel());
expect(within(folderPanel).get(browserWorkspaceSelector.folderPanelTitle('Work HQ'))).toBeInTheDocument();The adapter also keeps CSS selector helpers:
screen.getBySelector('[data-type="folder-panel"]');
screen.queryAllBySelector('[data-type="task-row"]');Use the Playwright adapter from testing-library-queries/playwright.
import { expect, test } from '@playwright/test';
import { createPlaywrightQueries } from 'testing-library-queries/playwright';
import { browserWorkspaceSelector } from '../src/BrowserWorkspace.selector';
test('creates a list', async ({ page }) => {
const q = createPlaywrightQueries(page);
await page.goto('/workspace');
await q.get(browserWorkspaceSelector.createListInput()).fill('Today');
await q.get(browserWorkspaceSelector.createListInput()).press('Enter');
await expect(q.get(browserWorkspaceSelector.folderPanelTitle('Today'))).toBeVisible();
});get() returns a Playwright Locator when called with a Playwright Page or Locator, so normal Locator methods are inferred:
const q = createPlaywrightQueries(page);
await q.get(browserWorkspaceSelector.createTaskInput()).fill('Ship v3');
await q.within(q.get(browserWorkspaceSelector.folderPanel())).get(browserWorkspaceSelector.folderPanelTitle('Work HQ'));import { by, buildQuery } from 'testing-library-queries';
import { screen, within } from 'testing-library-queries/testing-library';
import { createPlaywrightQueries } from 'testing-library-queries/playwright';The root entrypoint exports descriptor builders only:
bybuildQuery- descriptor and helper types
The root entrypoint intentionally does not export screen, within, or any runtime adapter.
These descriptors are portable across Testing Library and Playwright:
by.selector(css)by.role(role, options)by.text(text, options)by.labelText(text, options)by.placeholderText(text, options)by.altText(text, options)by.title(text, options)by.testId(id)buildQuery.selectorWithText(...)buildQuery.chain(...), when every step is portable and the chain has no transform
Playwright option behavior:
by.role(..., { hidden: true })is translated to Playwright'sincludeHidden.selector,ignore, andnormalizeroptions throw in Playwright because they are Testing Library-only semantics.- function text matchers throw in Playwright because Playwright supports string and RegExp matchers, not arbitrary predicate functions.
These descriptors are supported by the Testing Library adapter only:
by.displayValue(...)buildQuery.from(...)buildQuery.transform(...)buildQuery.intersect(...)buildQuery.hasText(...)- chains with
parent() - chains with
transform(...) - custom function text matchers
- Testing Library-only options such as
selector,ignore, andnormalizer
The Playwright adapter throws a clear unsupported-query or unsupported-option error for these cases.
by.role('button', { name: 'Save' });
by.text('Item');
by.labelText('Email');
by.placeholderText('Search');
by.testId('submit-button');
by.altText('Avatar');
by.title('More information');
by.displayValue('Current value');
by.selector('[data-type="task-row"]');const taskRow = buildQuery.selectorWithText((status: string) => `[data-status="${status}"]`);
screen.get(taskRow('Active task', 'active'));
await q.get(taskRow('Active task', 'active')).click();const buttonInPanel = buildQuery
.chain(by.selector('[data-panel="actions"]'))
.pipe(by.role('button', { name: 'Save' }))
.build();
screen.get(buttonInPanel);
await q.get(buttonInPanel).click();const activeButton = buildQuery.from((container) => container.querySelectorAll('button.active'), {
name: 'active button',
});
screen.get(activeButton);const cardFromHeader = buildQuery.transform(
(container) => container.querySelectorAll('[data-card-title]'),
(title) => title.closest('[data-card]'),
{ name: 'card' },
);
const card = screen.get(cardFromHeader);See MIGRATION.md for the v2 to v3 migration guide.
npm run lint
npm run typecheck
npm test -- --run
npm run build
npm run check:exports