Skip to content

thomasmikava/testing-library-queries

Repository files navigation

testing-library-queries

Enhanced query builder for Testing Library with custom selectors and composable queries. Write cleaner, more maintainable tests with type-safe query composition.

npm versionLicense: MIT

Why testing-library-queries?

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

Inspiration

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.

Installation

npm install --save-dev testing-library-queries
yarn add --dev testing-library-queries
pnpm add --save-dev testing-library-queries

Peer dependency:@testing-library/dom >= 10.0.0

Quick Start

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

API Reference

Enhanced screen and within

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

by - Query Builder

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 found
  • getAll() - Get all matching elements, throws if none found
  • query() - Get single element, returns null if not found
  • queryAll() - Get all matching elements, returns empty array if none found
  • find() - Async get single element, waits until found
  • findAll() - Async get all matching elements, waits until found

buildQuery - Custom Query Builder

Create reusable, composable custom queries:

buildQuery.from(queryFn, options?)

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 found
  • options.getMissingError?: (container) => string - Custom error when none found

buildQuery.transform(baseQuery, transform, options?)

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 messages
  • options.getMultipleError?: (container) => string - Custom multiple error
  • options.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')

buildQuery.intersect(queries, options?)

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 messages
  • options.getMultipleError?: (container) => string - Custom multiple error
  • options.getMissingError?: (container) => string - Custom missing error

Use case: Complex selectors that would be difficult to express as a single CSS selector.

buildQuery.selectorWithText(selectorBuilder, options?)

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 selector
  • options.name?: string - Name for error messages
  • options.textMatcher?: 'exact' | 'partial' | 'regex' - Text matching mode (default: 'partial')

buildQuery.hasText(text, options?)

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 textContent
  • options.name?: string - Name for error messages (default: 'element')
  • options.textMatcher?: 'exact' | 'partial' | 'regex' - Text matching mode (default: 'partial')

Key difference from by.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.

buildQuery.chain(query) - Chaining API

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:

  1. .chain(query) - Start a chain with an initial query
  2. .pipe(query) - Add more query steps (can be called multiple times)
  3. .transform(fn, options?) - Transform the final results (optional, terminal operation)
  4. .build() - Finalize and return a CustomQueryParams you can use with screen/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);// string

Chaining 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> or ByParams<T>)
  • .pipe<U>(query) - Add query step, returns ChainBuilder<U>
  • .transform<R>(fn, options?) - Transform elements, returns TransformBuilder<R>
    • fn: (el: T) => R - Transform function (can return any type)
    • options.name?: string - Name for error messages
    • options.getMultipleError?: (container) => string - Custom multiple error
    • options.getMissingError?: (container) => string - Custom missing error
  • .build() - Return final CustomQueryParams

Usage Examples

Basic Testing Library Queries

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"));// Async

Scoped Queries with within

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

CSS Selector Queries

// 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");

Custom Reusable Queries

// 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));

Chaining Queries

// 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);

Intersecting Multiple Conditions

// 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);

Finding Related Elements

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

Testing Forms

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();

TypeScript Support

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!)

Migration from Testing Library

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()

Framework Support

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

Best Practices

1. Prefer Testing Library queries over selectors

// ✅ 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"]');

2. Create reusable custom queries

// ✅ 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"));// ...});

3. Use within for scoped queries

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

4. Use chaining for complex DOM navigation

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

5. Choose the right query method

// 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!"));

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

License

MIT © Temuri Mikava

Credits

Inspired by query-extensions by the Testing Library community.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Packages

No packages published