Enhanced query builder for Testing Library with custom selectors and composable queries. Write cleaner, more maintainable tests with type-safe query composition.
Testing Library is fantastic, but its query API can become verbose and repetitive, especially when:
- Building complex queries - Combining multiple conditions or transforming results requires custom utilities
- Type safety - Generic query builders lack strong TypeScript inference
- CSS selectors - No built-in support for raw CSS selectors when you need them
- Query reusability - Difficult to create and share custom query logic across tests
testing-library-queries solves these problems by providing:
✅ Composable query builder - Create complex queries with buildQuery.intersect(), transform(), and more
✅ Chaining API - Compose multi-step queries where each result becomes the container for the next
✅ Full TypeScript support - Strongly typed with excellent IDE autocomplete
✅ CSS selector queries - Built-in getBySelector(), queryBySelector(), etc.
✅ Concise syntax - screen.get(by.role('button')) instead of screen.getByRole('button')
✅ Custom query helpers - Build reusable query logic with buildQuery.from()
✅ Framework agnostic - Works with React, Vue, Angular, or any Testing Library setup
This library was inspired by query-extensions, expanding on its ideas with full TypeScript support, additional query builders, and a more flexible API for creating custom queries.
npm install --save-dev testing-library-queriesyarn add --dev testing-library-queriespnpm add --save-dev testing-library-queriesPeer dependency: @testing-library/dom >= 10.0.0
import { screen, within, by, buildQuery } from "testing-library-queries";
import { render } from "@testing-library/react";
// Simple queries with `by`
render(<button>Submit</button>);
const button = screen.get(by.role("button", { name: "Submit" }));
// CSS selectors
const element = screen.getBySelector('.my-class[data-active="true"]');
// Custom queries
const activeButton = buildQuery.from((container) =>
container.querySelectorAll("button.active")
);
const button = screen.get(activeButton);
// Chained queries - compose multi-step queries
const cardButton = buildQuery
.chain(buildQuery.from((c) => c.querySelectorAll("[data-card]")))
.pipe(by.role("button", { name: "Edit" }))
.build();
const button = screen.get(cardButton);
// Scoped queries with `within`
const dialog = screen.getBySelector('[role="dialog"]');
const closeButton = within(dialog).get(by.role("button", { name: "Close" }));Drop-in replacements for Testing Library's screen and within with additional methods:
// All standard Testing Library queries work as normal
screen.getByRole("button");
screen.queryByText("Hello");
// Plus new enhanced query methods
screen.get(by.role("button"));
screen.getAll(by.text("Item"));
screen.query(by.testId("my-id"));
screen.queryAll(by.role("listitem"));
screen.find(by.text("Loading..."));
screen.findAll(by.role("option"));
// CSS selector queries
screen.getBySelector(".class-name");
screen.getAllBySelector("[data-test]");
screen.queryBySelector("#my-id");
screen.queryAllBySelector("button.primary");
screen.findBySelector(".async-element");
screen.findAllBySelector(".items");The by object provides a concise way to build query parameters for all Testing Library query types:
// by.role
screen.get(by.role("button"));
screen.get(by.role("button", { name: "Submit" }));
screen.get(by.role("textbox", { name: /username/i }));
// by.text
screen.get(by.text("Hello World"));
screen.get(by.text(/hello/i));
screen.getAll(by.text("Item"));
// by.labelText
screen.get(by.labelText("Username"));
screen.get(by.labelText(/email/i));
// by.placeholderText
screen.get(by.placeholderText("Enter your name"));
// by.testId
screen.get(by.testId("submit-button"));
screen.getAll(by.testId("list-item"));
// by.altText
screen.get(by.altText("Profile picture"));
// by.title
screen.get(by.title("More information"));
// by.displayValue
screen.get(by.displayValue("Current value"));
// by.selector (CSS selectors)
screen.get(by.selector('.my-class[data-active="true"]'));Available query methods with by:
get()- Get single element, throws if not found or multiple foundgetAll()- Get all matching elements, throws if none foundquery()- Get single element, returns null if not foundqueryAll()- Get all matching elements, returns empty array if none foundfind()- Async get single element, waits until foundfindAll()- Async get all matching elements, waits until found
Create reusable, composable custom queries:
Create a custom query from a queryAll function:
// Basic custom query
const byDataStatus = buildQuery.from(
(container) => container.querySelectorAll('[data-status="active"]'),
{ name: "active status element" }
);
screen.get(byDataStatus);
screen.getAll(byDataStatus);
within(element).query(byDataStatus);Parameters:
queryFn: (container: HTMLElement) => HTMLElement[]- Function that returns matching elements (supports NodeList)options.name?: string- Name for error messages (default: 'element')options.getMultipleError?: (container) => string- Custom error when multiple foundoptions.getMissingError?: (container) => string- Custom error when none found
Transform elements after Testing Library finds them. The transformation is applied after element lookup, allowing the result to be any type (not just Element):
// Transform to non-element type (e.g., extract text content)
const questionTexts = buildQuery.transform(
(container) => container.querySelectorAll("[data-question]"),
(element) => element.textContent,
{ name: "question text" }
);
const texts = screen.queryAll(questionTexts); // string[]Parameters:
baseQuery: (container: HTMLElement) => HTMLElement[]- Base query function (supports NodeList)transform: (element: HTMLElement) => TResult- Transform function applied after elements are found. Can return any type.options.name?: string- Name for error messagesoptions.getMultipleError?: (container) => string- Custom multiple erroroptions.getMissingError?: (container) => string- Custom missing error
Use cases:
- Find parent element:
element.closest('.parent') - Find next sibling:
element.nextElementSibling - Find related element:
element.querySelector('.child') - Extract data:
element.textContent,element.getAttribute('data-id')
Intersect multiple queries:
// Find elements matching ALL conditions
const primaryButton = buildQuery.intersect(
[
(c) => c.querySelectorAll("button"),
(c) => c.querySelectorAll(".primary"),
(c) => c.querySelectorAll('[data-enabled="true"]'),
],
{ name: "enabled primary button" }
);
const button = screen.get(primaryButton);Parameters:
queries: Array<(container: HTMLElement) => HTMLElement[]>- Array of query functions (supports NodeList)options.name?: string- Name for error messagesoptions.getMultipleError?: (container) => string- Custom multiple erroroptions.getMissingError?: (container) => string- Custom missing error
Use case: Complex selectors that would be difficult to express as a single CSS selector.
Create a query that filters by text content:
// Find by selector and optionally filter by text
const statusElement = buildQuery.selectorWithText(
(status: string) => `[data-status="${status}"]`,
{ textMatcher: "partial" } // 'exact' | 'partial' | 'regex'
);
// Find any element with data-status="active"
screen.get(statusElement(undefined as any, "active"));
// Find element with data-status="active" containing "Hello"
screen.get(statusElement("Hello", "active"));
// Find element with data-status="active" with exact text "Active"
const exactMatcher = buildQuery.selectorWithText(
(status: string) => `[data-status="${status}"]`,
{ textMatcher: "exact" }
);
screen.get(exactMatcher("Active", "active"));
// Find with regex
screen.get(statusElement(/Hello \w+/, "active"));Parameters:
selectorBuilder: (...params) => string- Function that builds CSS selectoroptions.name?: string- Name for error messagesoptions.textMatcher?: 'exact' | 'partial' | 'regex'- Text matching mode (default: 'partial')
Check if the container element itself contains the specified text. Unlike by.text(), which searches for descendant elements containing the text, hasText matches the container itself if it contains such text.
This is especially useful in pipe operations to filter out only elements that have specific text content.
// Check if element contains text (partial match by default)
const elementWithText = buildQuery.hasText("Active");
screen.get(elementWithText); // Returns the element if it contains "Active"
// Exact text match
const exactMatch = buildQuery.hasText("Active", { textMatcher: "exact" });
screen.get(exactMatch); // Returns element only if its text is exactly "Active"
// Regex match
const regexMatch = buildQuery.hasText(/active/i);
screen.query(regexMatch);
// Use in pipe to filter elements
const activeCards = buildQuery
.chain(buildQuery.from((c) => c.querySelectorAll("[data-card]")))
.pipe(buildQuery.hasText("Active Status"))
.build();
screen.getAll(activeCards); // Returns only cards containing "Active Status"Parameters:
text: string | RegExp- Text or pattern to match in the container's textContentoptions.name?: string- Name for error messages (default: 'element')options.textMatcher?: 'exact' | 'partial' | 'regex'- Text matching mode (default: 'partial')
Key difference from by.text():
by.text('search query')finds descendant elements that contain the textbuildQuery.hasText('search query')checks if the container itself contains the text
This makes hasText perfect for filtering in chains where you want to check the text of each element in the result set, not search within them.
Build multi-step queries where each step's results become the containers for the next query. This is powerful for navigating complex DOM structures and composing queries together.
How it works:
.chain(query)- Start a chain with an initial query.pipe(query)- Add more query steps (can be called multiple times).transform(fn, options?)- Transform the final results (optional, terminal operation).build()- Finalize and return aCustomQueryParamsyou can use withscreen/within
// Basic chain
const buttonInCard = buildQuery
.chain(buildQuery.from((c) => c.querySelectorAll("[data-card]")))
.pipe(by.role("button", { name: "Edit" }))
.build();
screen.get(buttonInCard);Each step narrows down the search:
- First query runs on the container (e.g.,
document.body) - Each subsequent
.pipe()runs on the results from the previous step - Elements from step N become containers for step N+1
Type safety through the chain:
// TypeScript tracks element types through the entire chain
const query = buildQuery
.chain<HTMLDivElement>(
buildQuery.from((c) => c.querySelectorAll("[data-card]"))
)
.pipe<HTMLButtonElement>(by.role("button"))
.transform<string>((btn) => btn.textContent)
.build(); // Returns CustomQueryParams<string>
const text = screen.get(query); // stringChaining with by.* queries:
All Testing Library query types work in chains:
// Chain with by.role
const query = buildQuery
.chain(by.role("dialog"))
.pipe(by.role("button", { name: "Close" }))
.build();
// Chain with by.text
const query = buildQuery
.chain(buildQuery.from((c) => c.querySelectorAll("[data-section]")))
.pipe(by.text("Welcome"))
.build();
// Chain with by.selector
const query = buildQuery
.chain(by.selector("[data-container]"))
.pipe(by.selector(".active"))
.build();Multiple pipe operations:
// Complex multi-step navigation
const query = buildQuery
.chain(buildQuery.from((c) => c.querySelectorAll("[data-section]")))
.pipe(buildQuery.from((c) => c.querySelectorAll("[data-card]")))
.pipe(
buildQuery.intersect([
(c) => c.querySelectorAll(".active"),
(c) => c.querySelectorAll('[data-enabled="true"]'),
])
)
.pipe(by.role("button"))
.build();Transform in chains:
Transform is a terminal operation - after calling it, only .build() is available:
// Transform to parent element
const cardQuery = buildQuery
.chain(buildQuery.from((c) => c.querySelectorAll("[data-header]")))
.pipe(
buildQuery.intersect([
(c) => c.querySelectorAll(".active"),
(c) => c.querySelectorAll('[data-enabled="true"]'),
])
)
.transform((div: HTMLDivElement) => div.closest<HTMLElement>("[data-card]"))
.build();
// Transform to non-element type
const values = buildQuery
.chain(by.role("listitem"))
.transform((el: HTMLElement) => parseInt(el.dataset.value || "0"))
.build();
const numbers = screen.getAll(values); // number[]
// Transform with options
const textQuery = buildQuery
.chain(buildQuery.from((c) => c.querySelectorAll("[data-item]")))
.transform((el: HTMLElement) => el.textContent, {
name: "item text",
getMissingError: () => "No items found",
getMultipleError: () => "Multiple items found",
})
.build();Using selectorWithText in chains:
const statusCard = buildQuery
.chain(buildQuery.from((c) => c.querySelectorAll("[data-container]")))
.pipe(
buildQuery.selectorWithText(
(status: string) => `[data-status="${status}"]`
)("Active", "pending")
)
.transform((el: HTMLElement) => el.closest<HTMLDivElement>("[data-card]"))
.build();Chaining API Parameters:
.chain<T>(query)- Start chain with any query (CustomQueryParams<T>orByParams<T>).pipe<U>(query)- Add query step, returnsChainBuilder<U>.transform<R>(fn, options?)- Transform elements, returnsTransformBuilder<R>fn: (el: T) => R- Transform function (can return any type)options.name?: string- Name for error messagesoptions.getMultipleError?: (container) => string- Custom multiple erroroptions.getMissingError?: (container) => string- Custom missing error
.build()- Return finalCustomQueryParams
import { screen, by } from "testing-library-queries";
// Instead of this:
const button = screen.getByRole("button", { name: "Submit" });
// Write this:
const button = screen.get(by.role("button", { name: "Submit" }));
// Query variants
const button = screen.query(by.role("button")); // Returns null if not found
const buttons = screen.getAll(by.role("button")); // Get multiple
const button = await screen.find(by.role("button")); // Asyncimport { screen, within, by } from "testing-library-queries";
const dialog = screen.getBySelector('[role="dialog"]');
// Scope all queries to the dialog
const title = within(dialog).get(by.text("Confirmation"));
const cancelButton = within(dialog).get(by.role("button", { name: "Cancel" }));
const confirmButton = within(dialog).get(
by.role("button", { name: "Confirm" })
);// Complex selectors
const element = screen.getBySelector('.parent > .child[data-active="true"]');
// Attribute selectors
const items = screen.getAllBySelector('[data-testid^="item-"]');
// Pseudo-selectors
const firstItem = screen.getBySelector("li:first-child");
// With within
const container = screen.getBySelector(".container");
const active = within(container).getBySelector(".active");// Define once
const byDataTestId = (id: string) =>
buildQuery.from(
(container) => container.querySelectorAll(`[data-testid="${id}"]`),
{ name: `element with data-testid="${id}"` }
);
// Use anywhere
screen.get(byDataTestId("submit-button"));
within(form).query(byDataTestId("email-input"));
// Parameterized queries
const byStatus = (status: string, enabled: boolean) =>
buildQuery.from(
(container) =>
container.querySelectorAll(
`[data-status="${status}"][data-enabled="${enabled}"]`
),
{ name: `${enabled ? "enabled" : "disabled"} ${status} element` }
);
screen.get(byStatus("active", true));// Find button inside a specific card
const editButton = buildQuery
.chain(buildQuery.from((c) => c.querySelectorAll('[data-card-id="123"]')))
.pipe(by.role("button", { name: "Edit" }))
.build();
screen.get(editButton);
// Multi-step navigation with multiple pipes
const complexQuery = buildQuery
.chain(buildQuery.from((c) => c.querySelectorAll('[data-section="main"]')))
.pipe(buildQuery.from((c) => c.querySelectorAll("[data-card]")))
.pipe(
buildQuery.intersect([
(c) => c.querySelectorAll(".highlighted"),
(c) => c.querySelectorAll('[data-visible="true"]'),
])
)
.pipe(by.role("button"))
.build();
const button = screen.get(complexQuery);
// Chain with transform to find related elements
const parentCard = buildQuery
.chain(by.role("button", { name: "Edit Profile" }))
.transform((btn: HTMLElement) => btn.closest<HTMLDivElement>("[data-card]"))
.build();
const card = screen.get(parentCard);
// Transform to extract data
const userNames = buildQuery
.chain(buildQuery.from((c) => c.querySelectorAll("[data-user]")))
.pipe(buildQuery.from((c) => c.querySelectorAll(".user-name")))
.transform((el: HTMLElement) => el.textContent)
.build();
const names = screen.getAll(userNames); // string[]
// Complex real-world example
const activeCardButton = buildQuery
.chain(buildQuery.from((c) => c.querySelectorAll("[data-container]")))
.pipe(
buildQuery.selectorWithText((type: string) => `[data-type="${type}"]`)(
"Premium",
"premium"
)
)
.pipe(
buildQuery.intersect([
(c) => c.querySelectorAll(".card"),
(c) => c.querySelectorAll('[data-active="true"]'),
])
)
.pipe(by.role("button", { name: /edit/i }))
.build();
const button = within(container).get(activeCardButton);// Find buttons that are both primary AND enabled
const enabledPrimaryButton = buildQuery.intersect(
[
(c) => c.querySelectorAll("button"),
(c) => c.querySelectorAll(".primary"),
(c) => c.querySelectorAll(":not([disabled])"),
],
{ name: "enabled primary button" }
);
const button = screen.get(enabledPrimaryButton);// Find a card by its header text
const cardByHeaderText = (text: string) =>
buildQuery.transform(
(container) => {
const headers = container.querySelectorAll("[data-card-header]");
return Array.from(headers).filter((h) => h.textContent?.includes(text));
},
(header) => header.closest("[data-card]") as HTMLElement,
{ name: "card" }
);
const card = screen.get(cardByHeaderText("User Profile"));
const editButton = within(card).get(by.role("button", { name: "Edit" }));import { screen, by } from "testing-library-queries";
import userEvent from "@testing-library/user-event";
// Find form inputs
const emailInput = screen.get(by.labelText("Email"));
const passwordInput = screen.get(by.labelText("Password"));
const submitButton = screen.get(by.role("button", { name: "Sign In" }));
// Interact
await userEvent.type(emailInput, "user@example.com");
await userEvent.type(passwordInput, "password123");
await userEvent.click(submitButton);
// Assert
const error = screen.query(by.text("Invalid credentials"));
expect(error).toBeInTheDocument();This library is written in TypeScript and provides full type safety:
import { screen, by, buildQuery } from "testing-library-queries";
// Full type inference
const button = screen.get(by.role("button")); // HTMLElement
const inputs = screen.getAll(by.role("textbox")); // [HTMLElement, ...HTMLElement[]]
// Generic type support
const div = screen.getBySelector<HTMLDivElement>(".my-div");
const buttons = screen.getAllBySelector<HTMLButtonElement>("button");
// Custom queries are fully typed
const customQuery = buildQuery.from((container): HTMLButtonElement[] =>
container.querySelectorAll("button")
);
const button = screen.get(customQuery); // HTMLButtonElement
// Chaining preserves types
const query = buildQuery
.chain<HTMLDivElement>(
buildQuery.from((c) => c.querySelectorAll("[data-card]"))
)
.pipe<HTMLButtonElement>(by.role("button"))
.transform<string>((btn) => btn.textContent)
.build();
const text = screen.get(query); // string (not HTMLElement!)testing-library-queries is a drop-in enhancement. You can migrate incrementally:
// Before
import { screen, within } from "@testing-library/react";
// After - everything still works!
import { screen, within } from "testing-library-queries";
// Gradually adopt new features
import { screen, within, by, buildQuery } from "testing-library-queries";All existing Testing Library queries continue to work:
screen.getByRole()✅screen.queryByText()✅within(element).getAllByTestId()✅
Works with any Testing Library setup:
- React:
@testing-library/react - Vue:
@testing-library/vue - Angular:
@testing-library/angular - Svelte:
@testing-library/svelte - Vanilla JS:
@testing-library/dom
// ✅ Good - Uses accessible queries
screen.get(by.role("button", { name: "Submit" }));
screen.get(by.labelText("Email"));
// ⚠️ Use selectors only when necessary
screen.getBySelector('[data-testid="complex-component"]');// ✅ Good - Reusable across tests
const byCardTitle = (title: string) =>
buildQuery.transform(
(c) => c.querySelectorAll("[data-card-title]"),
(el) => (el.textContent === title ? el.closest("[data-card]") : null),
{ name: "card" }
);
// Use in multiple tests
test("edit card", () => {
const card = screen.get(byCardTitle("Profile"));
// ...
});// ✅ Good - Scoped to specific section
const dialog = screen.getBySelector('[role="dialog"]');
within(dialog).get(by.role("button", { name: "Close" }));
// ❌ Avoid - Might match wrong button
screen.get(by.role("button", { name: "Close" }));// ✅ Good - Clear, composable query
const editButton = buildQuery
.chain(buildQuery.from((c) => c.querySelectorAll('[data-card="profile"]')))
.pipe(by.role("button", { name: "Edit" }))
.build();
// ❌ Avoid - Manual navigation with multiple queries
const card = screen.getBySelector('[data-card="profile"]');
const editButton = within(card).getByRole("button", { name: "Edit" });// Use get() when element must exist
const button = screen.get(by.role("button"));
// Use query() when element might not exist
const error = screen.query(by.text("Error"));
expect(error).toBeNull();
// Use find() for async elements
const data = await screen.find(by.text("Loaded!"));Contributions are welcome! Please feel free to submit a Pull Request.
MIT © Temuri Mikava
Inspired by query-extensions by the Testing Library community.