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>);constbutton=screen.get(by.role("button",{name: "Submit"}));// CSS selectorsconstelement=screen.getBySelector('.my-class[data-active="true"]');// Custom queriesconstactiveButton=buildQuery.from((container)=>container.querySelectorAll("button.active"));constbutton=screen.get(activeButton);// Chained queries - compose multi-step queriesconstcardButton=buildQuery.chain(buildQuery.from((c)=>c.querySelectorAll("[data-card]"))).pipe(by.role("button",{name: "Edit"})).build();constbutton=screen.get(cardButton);// Scoped queries with `within`constdialog=screen.getBySelector('[role="dialog"]');constcloseButton=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 normalscreen.getByRole("button");screen.queryByText("Hello");// Plus new enhanced query methodsscreen.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 queriesscreen.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.rolescreen.get(by.role("button"));screen.get(by.role("button",{name: "Submit"}));screen.get(by.role("textbox",{name: /username/i}));// by.textscreen.get(by.text("Hello World"));screen.get(by.text(/hello/i));screen.getAll(by.text("Item"));// by.labelTextscreen.get(by.labelText("Username"));screen.get(by.labelText(/email/i));// by.placeholderTextscreen.get(by.placeholderText("Enter your name"));// by.testIdscreen.get(by.testId("submit-button"));screen.getAll(by.testId("list-item"));// by.altTextscreen.get(by.altText("Profile picture"));// by.titlescreen.get(by.title("More information"));// by.displayValuescreen.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 queryconstbyDataStatus=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)constquestionTexts=buildQuery.transform((container)=>container.querySelectorAll("[data-question]"),(element)=>element.textContent,{name: "question text"});consttexts=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 conditionsconstprimaryButton=buildQuery.intersect([(c)=>c.querySelectorAll("button"),(c)=>c.querySelectorAll(".primary"),(c)=>c.querySelectorAll('[data-enabled="true"]'),],{name: "enabled primary button"});constbutton=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 textconststatusElement=buildQuery.selectorWithText((status: string)=>`[data-status="${status}"]`,{textMatcher: "partial"}// 'exact' | 'partial' | 'regex');// Find any element with data-status="active"screen.get(statusElement(undefinedasany,"active"));// Find element with data-status="active" containing "Hello"screen.get(statusElement("Hello","active"));// Find element with data-status="active" with exact text "Active"constexactMatcher=buildQuery.selectorWithText((status: string)=>`[data-status="${status}"]`,{textMatcher: "exact"});screen.get(exactMatcher("Active","active"));// Find with regexscreen.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)constelementWithText=buildQuery.hasText("Active");screen.get(elementWithText);// Returns the element if it contains "Active"// Exact text matchconstexactMatch=buildQuery.hasText("Active",{textMatcher: "exact"});screen.get(exactMatch);// Returns element only if its text is exactly "Active"// Regex matchconstregexMatch=buildQuery.hasText(/active/i);screen.query(regexMatch);// Use in pipe to filter elementsconstactiveCards=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 chainconstbuttonInCard=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 chainconstquery=buildQuery.chain<HTMLDivElement>(buildQuery.from((c)=>c.querySelectorAll("[data-card]"))).pipe<HTMLButtonElement>(by.role("button")).transform<string>((btn)=>btn.textContent).build();// Returns CustomQueryParams<string>consttext=screen.get(query);// stringChaining with by.* queries:
All Testing Library query types work in chains:
// Chain with by.roleconstquery=buildQuery.chain(by.role("dialog")).pipe(by.role("button",{name: "Close"})).build();// Chain with by.textconstquery=buildQuery.chain(buildQuery.from((c)=>c.querySelectorAll("[data-section]"))).pipe(by.text("Welcome")).build();// Chain with by.selectorconstquery=buildQuery.chain(by.selector("[data-container]")).pipe(by.selector(".active")).build();Multiple pipe operations:
// Complex multi-step navigationconstquery=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 elementconstcardQuery=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 typeconstvalues=buildQuery.chain(by.role("listitem")).transform((el: HTMLElement)=>parseInt(el.dataset.value||"0")).build();constnumbers=screen.getAll(values);// number[]// Transform with optionsconsttextQuery=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:
conststatusCard=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:constbutton=screen.getByRole("button",{name: "Submit"});// Write this:constbutton=screen.get(by.role("button",{name: "Submit"}));// Query variantsconstbutton=screen.query(by.role("button"));// Returns null if not foundconstbuttons=screen.getAll(by.role("button"));// Get multipleconstbutton=awaitscreen.find(by.role("button"));// Asyncimport{screen,within,by}from"testing-library-queries";constdialog=screen.getBySelector('[role="dialog"]');// Scope all queries to the dialogconsttitle=within(dialog).get(by.text("Confirmation"));constcancelButton=within(dialog).get(by.role("button",{name: "Cancel"}));constconfirmButton=within(dialog).get(by.role("button",{name: "Confirm"}));// Complex selectorsconstelement=screen.getBySelector('.parent > .child[data-active="true"]');// Attribute selectorsconstitems=screen.getAllBySelector('[data-testid^="item-"]');// Pseudo-selectorsconstfirstItem=screen.getBySelector("li:first-child");// With withinconstcontainer=screen.getBySelector(".container");constactive=within(container).getBySelector(".active");// Define onceconstbyDataTestId=(id: string)=>buildQuery.from((container)=>container.querySelectorAll(`[data-testid="${id}"]`),{name: `element with data-testid="${id}"`});// Use anywherescreen.get(byDataTestId("submit-button"));within(form).query(byDataTestId("email-input"));// Parameterized queriesconstbyStatus=(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 cardconsteditButton=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 pipesconstcomplexQuery=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();constbutton=screen.get(complexQuery);// Chain with transform to find related elementsconstparentCard=buildQuery.chain(by.role("button",{name: "Edit Profile"})).transform((btn: HTMLElement)=>btn.closest<HTMLDivElement>("[data-card]")).build();constcard=screen.get(parentCard);// Transform to extract dataconstuserNames=buildQuery.chain(buildQuery.from((c)=>c.querySelectorAll("[data-user]"))).pipe(buildQuery.from((c)=>c.querySelectorAll(".user-name"))).transform((el: HTMLElement)=>el.textContent).build();constnames=screen.getAll(userNames);// string[]// Complex real-world exampleconstactiveCardButton=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();constbutton=within(container).get(activeCardButton);// Find buttons that are both primary AND enabledconstenabledPrimaryButton=buildQuery.intersect([(c)=>c.querySelectorAll("button"),(c)=>c.querySelectorAll(".primary"),(c)=>c.querySelectorAll(":not([disabled])"),],{name: "enabled primary button"});constbutton=screen.get(enabledPrimaryButton);// Find a card by its header textconstcardByHeaderText=(text: string)=>buildQuery.transform((container)=>{constheaders=container.querySelectorAll("[data-card-header]");returnArray.from(headers).filter((h)=>h.textContent?.includes(text));},(header)=>header.closest("[data-card]")asHTMLElement,{name: "card"});constcard=screen.get(cardByHeaderText("User Profile"));consteditButton=within(card).get(by.role("button",{name: "Edit"}));import{screen,by}from"testing-library-queries";importuserEventfrom"@testing-library/user-event";// Find form inputsconstemailInput=screen.get(by.labelText("Email"));constpasswordInput=screen.get(by.labelText("Password"));constsubmitButton=screen.get(by.role("button",{name: "Sign In"}));// InteractawaituserEvent.type(emailInput,"[email protected]");awaituserEvent.type(passwordInput,"password123");awaituserEvent.click(submitButton);// Assertconsterror=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 inferenceconstbutton=screen.get(by.role("button"));// HTMLElementconstinputs=screen.getAll(by.role("textbox"));// [HTMLElement, ...HTMLElement[]]// Generic type supportconstdiv=screen.getBySelector<HTMLDivElement>(".my-div");constbuttons=screen.getAllBySelector<HTMLButtonElement>("button");// Custom queries are fully typedconstcustomQuery=buildQuery.from((container): HTMLButtonElement[]=>container.querySelectorAll("button"));constbutton=screen.get(customQuery);// HTMLButtonElement// Chaining preserves typesconstquery=buildQuery.chain<HTMLDivElement>(buildQuery.from((c)=>c.querySelectorAll("[data-card]"))).pipe<HTMLButtonElement>(by.role("button")).transform<string>((btn)=>btn.textContent).build();consttext=screen.get(query);// string (not HTMLElement!)testing-library-queries is a drop-in enhancement. You can migrate incrementally:
// Beforeimport{screen,within}from"@testing-library/react";// After - everything still works!import{screen,within}from"testing-library-queries";// Gradually adopt new featuresimport{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 queriesscreen.get(by.role("button",{name: "Submit"}));screen.get(by.labelText("Email"));// ⚠️ Use selectors only when necessaryscreen.getBySelector('[data-testid="complex-component"]');// ✅ Good - Reusable across testsconstbyCardTitle=(title: string)=>buildQuery.transform((c)=>c.querySelectorAll("[data-card-title]"),(el)=>(el.textContent===title ? el.closest("[data-card]") : null),{name: "card"});// Use in multiple teststest("edit card",()=>{constcard=screen.get(byCardTitle("Profile"));// ...});// ✅ Good - Scoped to specific sectionconstdialog=screen.getBySelector('[role="dialog"]');within(dialog).get(by.role("button",{name: "Close"}));// ❌ Avoid - Might match wrong buttonscreen.get(by.role("button",{name: "Close"}));// ✅ Good - Clear, composable queryconsteditButton=buildQuery.chain(buildQuery.from((c)=>c.querySelectorAll('[data-card="profile"]'))).pipe(by.role("button",{name: "Edit"})).build();// ❌ Avoid - Manual navigation with multiple queriesconstcard=screen.getBySelector('[data-card="profile"]');consteditButton=within(card).getByRole("button",{name: "Edit"});// Use get() when element must existconstbutton=screen.get(by.role("button"));// Use query() when element might not existconsterror=screen.query(by.text("Error"));expect(error).toBeNull();// Use find() for async elementsconstdata=awaitscreen.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.