KEMBAR78
GitHub - thomasmikava/testing-library-queries
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 version License: 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>);
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" }));

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

by - Query Builder

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

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

Scoped Queries with within

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

CSS Selector Queries

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

Custom Reusable Queries

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

Chaining Queries

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

Intersecting Multiple Conditions

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

Finding Related Elements

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

Testing Forms

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

TypeScript Support

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

Migration from Testing Library

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

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 queries
screen.get(by.role("button", { name: "Submit" }));
screen.get(by.labelText("Email"));

// ⚠️ Use selectors only when necessary
screen.getBySelector('[data-testid="complex-component"]');

2. Create reusable custom queries

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

3. Use within for scoped queries

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

4. Use chaining for complex DOM navigation

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

5. Choose the right query method

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

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