
Accelint React Testing
- 244 installs
- 21 repo stars
- Updated August 4, 2026
- gohypergiant/agent-skills
For development and infrastructure management.
About
accelint-react-testing is an AI coding tool that enhances development workflows. Builders use it for infrastructure, integration, and platform development within the catalog ecosystem.
- accelint-react-testing
- Development
Accelint React Testing by the numbers
- 244 all-time installs (skills.sh)
- Ranked #1,547 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/gohypergiant/agent-skills --skill accelint-react-testingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 244 |
|---|---|
| repo stars | ★ 21 |
| Last updated | August 4, 2026 |
| Repository | gohypergiant/agent-skills ↗ |
What it does
For development and infrastructure management.
Files
React Testing Best Practices
Expert guidance for writing maintainable, user-centric React component tests with Testing Library. Focused on query selection, accessibility-first testing, and avoiding implementation details.
NEVER Do When Writing React Tests
- NEVER query by test IDs before trying accessible queries - Test IDs bypass accessibility verification: a button with
data-testid="submit"but no accessible name works in tests but fails for screen reader users. When tests pass with test IDs, you ship inaccessible UIs. Query hierarchy:getByRole>getByLabelText>getByText>getByTestId. Each step down this list means less confidence your UI is usable. - NEVER use `fireEvent` for user interactions when `userEvent` is available -
fireEventdispatches single DOM events, missing the event sequence real users trigger:fireEvent.click()fires one click event, but real users trigger focus → mousedown → mouseup → click. Components that work with fireEvent break in production when users interact normally.userEvent.click()simulates the full interaction sequence, catching bugs fireEvent misses. - NEVER test implementation details instead of user behavior - Tests that verify "state variable X equals Y" or "function Z was called" create false failures: you refactor from useState to useReducer, all tests fail, yet the UI works identically. Testing implementation details punishes refactoring and provides zero confidence the user experience works. Test what users see and do (rendered output, interaction results), not how your component achieves it internally.
- NEVER query from `container` or use destructured queries after initial render -
const { getByText } = render(<Component />)creates stale queries that miss updates: after state changes, destructured queries search the initial DOM snapshot, missing newly rendered elements. This causes "element not found" errors for elements that are actually present. Always usescreen.getByText()which automatically queries the current DOM state. Using screen consistently also makes tests more maintainable - adding a new query doesn't require updating the destructuring. - NEVER add aria-label or role attributes solely for tests - If you're adding
aria-label="submit-button"orrole="button"just so tests can find elements, you're working backwards. Tests should verify the component is already accessible, not make it accessible for tests. Adding test-only ARIA pollutes production code and masks real accessibility problems. Fix the component's semantic HTML and existing ARIA first. - NEVER snapshot entire component trees without specific assertions - Massive snapshots with 500+ lines break on any change (updated classname, new prop, reordered elements), forcing reviewers to approve diffs they can't meaningfully evaluate. When test failures require "just update the snapshot" without understanding why, the test has zero value. Snapshot specific critical structures (error messages, data tables) with targeted assertions for everything else.
- NEVER use `waitFor` for actions that return promises -
waitFor(() => expect(element).toBeInTheDocument())polls repeatedly until timeout when a promise-basedfindByquery solves it in one shot:await screen.findByText('loaded')waits for the element to appear without polling. Reserve waitFor for assertions that can't use findBy (checking element disappears, waiting for attribute changes). - NEVER perform side effects inside waitFor callback -
waitFor(() => { fireEvent.click(button); expect(text).toBeInTheDocument(); })runs the click multiple times as waitFor retries, causing unpredictable behavior. waitFor is for waiting on assertions, not triggering actions. Perform all actions outside waitFor, then use waitFor only for the assertion:fireEvent.click(button); await waitFor(() => expect(text).toBeInTheDocument());or better yet,await userEvent.click(button); expect(await screen.findByText(text)).toBeInTheDocument();. - NEVER create custom renders without documenting provider requirements - A custom
renderWithReduxfunction with undocumented required store shape breaks for every developer: they callrender(<Component />)instead ofrenderWithRedux(), tests fail with cryptic "Cannot read property of undefined", wasting 15 minutes debugging. Centralize provider setup in test utils with TypeScript types that enforce correct usage, or document required wrappers prominently. - NEVER mix queries from different Testing Library imports - Importing both
@testing-library/reactrender and@testing-library/domqueries creates confusion:screenfrom react package doesn't work withgetByRolefrom dom package, causing "screen.getByRole is not a function" errors. Import all queries from@testing-library/reactfor React components - it re-exports everything from dom with React-specific enhancements.
Before Writing Tests, Ask
Apply these thinking patterns before implementing React component tests:
Query Selection Strategy
- Which query matches how users find this element? Real users don't look for test IDs or CSS classes - they look for labels, buttons, headings. If you can't query by role or label, your UI lacks accessibility. Query difficulty reveals UX problems before they reach production.
- Does this element need to be found at all? Not every element needs a query assertion. Users don't verify "loading spinner exists" - they verify "data appears after loading". Test outcomes, not intermediate states.
- Should I use getBy, queryBy, or findBy? Start with getBy for immediate presence - it gives the best error messages. Use queryBy only when asserting absence (.not.toBeInTheDocument()). Use findBy for async appearance. Never use queryBy + expect(...).toBeInTheDocument() - use getBy instead for better error messages when the element is missing.
User vs Implementation Testing
- What would a user do to verify this works? Users click buttons and read text - they don't check state variables or mock function calls. If your test uses
rerender()or accesses component internals, you're testing implementation. Refactor to test through user actions. - Will this test survive a refactoring that doesn't change behavior? If renaming a function or switching from useState to useReducer breaks the test, you're testing implementation details. These tests waste time blocking safe changes while providing no confidence the UI actually works.
Async and Timing
- Is this query for something that loads asynchronously? Use
findBy*for anything loaded via useEffect, API calls, or setTimeout.getBy*throws immediately if element is missing;findBy*waits for it to appear. Using getBy for async content creates race conditions that only fail in CI. - Am I waiting for an element to appear or disappear? Appearance =
findBy*query. Disappearance =waitForElementToBeRemoved. State changes =waitForwith assertion. Each has different semantics; using the wrong one causes flaky tests or longer timeouts.
Test Isolation and Setup
- Does this component need context providers to render? Components using useContext, Redux hooks, or React Router throw without providers. Create custom render utilities that wrap components in required providers automatically. Repeating provider setup in every test file is a maintenance disaster.
- What's the minimal setup needed for this test case? Tests with excessive setup (mocking 10 functions for a button test) are fragile and slow. Mock only external dependencies (APIs, localStorage), never your own functions. If setup is complex, the component design might be the problem.
How to Use
This skill uses progressive disclosure to minimize context usage:
1. Start with the Overview (AGENTS.md)
Read AGENTS.md for a concise overview of all rules with one-line summaries.
2. Load Specific Rules as Needed
Use these explicit triggers to know when to load each reference file:
MANDATORY Loading (load entire file):
- *Writing any query (getBy, findBy, queryBy)** → query-priority.md
- Simulating user interactions (clicks, typing, etc.) → user-events.md
Load When You See These Patterns:
- Confusion about getBy vs findBy vs queryBy → query-variants.md
- waitFor, async queries, or "act" warnings → async-testing.md
- Components using Context, Redux, Router → custom-render.md
- Testing accessibility or ARIA attributes → accessibility-queries.md
- Using container, wrapper, or rerender extensively → anti-patterns.md
- Queries failing or can't find right selector → query-variants.md for screen.debug() usage
Do NOT Load Unless Specifically Needed:
- Do NOT load custom-render.md for simple components without providers
- Do NOT load async-testing.md for synchronous tests
- Do NOT load accessibility-queries.md unless testing ARIA or a11y concerns
3. Apply the Pattern
Each reference file contains:
- ❌ Incorrect examples showing the anti-pattern
- ✅ Correct examples showing the optimal implementation
- Explanations of why the pattern matters
4. Audit Existing Tests (Optional)
Use the provided scripts to audit existing test suites:
# Check query priority (testId usage, container.querySelector)
./scripts/check-query-priority.sh
# Find fireEvent that should be userEvent
./scripts/find-fire-event.sh
# Detect deprecated wrapper/container patterns
./scripts/detect-wrapper-queries.sh5. Use the Report Template
When this skill is invoked for test code review, use the standardized report format:
Template: `assets/output-report-template.md`
The report format provides:
- Executive Summary with accessibility confidence and user-centric coverage assessment
- Severity levels (Critical, High, Medium, Low) for prioritization
- Impact analysis (accessibility confidence, user-centric confidence, test reliability, refactor safety)
- Categorization (Query Priority, Query Variants, User Events, Async Testing, Custom Render, Accessibility, Anti-patterns)
- Pattern references linking to detailed guidance in references/
- Summary table for tracking all issues
When to use the report template:
- Skill invoked directly via
/accelint-react-testing <path> - User asks to "review test code" or "audit tests" across file(s), invoking skill implicitly
When NOT to use the report template:
- User asks to "write a test for this function" (direct implementation)
- User asks "what's wrong with this test?" (answer the question)
- User requests specific test fixes (apply fixes directly without formal report)
What This Skill Covers
Expert guidance on React Testing Library patterns:
1. Query Priority - Accessible query hierarchy from getByRole to getByTestId 2. Query Variants - When to use getBy, findBy, queryBy for different scenarios 3. User Events - userEvent vs fireEvent for realistic interaction testing 4. Async Testing - Handling promises, waitFor, findBy queries, avoiding act warnings 5. Custom Render - Setting up providers (Context, Redux, Router) for complex components 6. Accessibility Queries - Testing with roles, labels, and ARIA attributes 7. Anti-patterns - Avoiding implementation details, container usage, excessive snapshots 9. Audit Scripts - Automated detection of suboptimal patterns in existing tests
Query Selection Decision Tree
Use this hierarchy when selecting queries - try options from top to bottom:
1. getByRole ← Preferred: Accessible, reflects how users & ATs interact
↓ Can't find role?
2. getByLabelText ← For form fields: matches how users read forms
↓ No label?
3. getByPlaceholderText ← For inputs: less accessible than labels
↓ No placeholder?
4. getByText ← For non-interactive content: headings, paragraphs
↓ Text not unique?
5. getByDisplayValue ← For form inputs: current value
↓ No display value?
6. getByAltText ← For images: alt attribute
↓ No alt text?
7. getByTitle ← For title attribute: less accessible
↓ No title?
8. getByTestId ← Last resort: no accessibility verificationKey principles:
- Higher queries = more confidence in accessibility
- If you can't query by role/label, fix the component's accessibility first
- getByTestId means "I've verified accessibility is impossible here"
Important Notes
- The `screen` export is not magic - It's just
getQueriesForElement(document.body). Usingscreen.getByRole()is identical to destructuredgetByRole()from render, but screen never goes stale after re-renders. - Testing Library encourages accessibility by making accessible elements easiest to query - If queries are hard, your UI is hard to use. Query difficulty is a UX code smell.
- Use screen.debug() or screen.logTestingPlaygroundURL() when queries fail - When getByRole fails, run
screen.debug()to see the current DOM orscreen.logTestingPlaygroundURL()to get an interactive tool showing what queries work. Don't guess at selectors - let Testing Library show you what's available. - queryBy returns null silently - use getBy for better errors - When an element should exist,
getBy*throws with helpful suggestions about similar elements and available roles.queryBy*returns null, requiring you to add your own assertion with less helpful error output. Use queryBy only when asserting absence with .not.toBeInTheDocument(). - Act warnings mean React state updates happened outside Testing Library's awareness - Usually caused by promises resolving after test completion or missing
awaiton async queries. Not caused by correct use of findBy or waitFor. - userEvent methods are async (return promises), fireEvent methods are sync - Forgetting
await userEvent.click()causes "act" warnings and flaky tests as state updates happen after assertions run.
React Testing Library
Note:
This document is mainly for agents and LLMs to follow when writing or reviewing React component tests with Testing Library. Humans may also find it useful, but guidance here is optimized for automation and consistency by AI-assisted workflows.
---
Abstract
Comprehensive guide for React Testing Library best practices, designed for AI agents and LLMs. Each rule includes one-line summaries here, with links to detailed examples in the references/ folder. Load reference files only when you need detailed implementation guidance for a specific rule.
Token efficiency principle: This guide maximizes knowledge delta by providing only expert-level insights and non-obvious patterns. All rules assume understanding React Testing Library basics, React fundamentals, and standard testing patterns. Focus is on non-obvious decisions, accessibility-first testing, and avoiding common pitfalls.
---
How to Use This Guide
1. Start here: Scan the rule summaries to identify relevant optimizations 2. Load references as needed: Click through to detailed examples only when implementing 3. Progressive loading: Each reference file is self-contained with ❌/✅ examples
This structure minimizes context usage while providing complete implementation guidance when needed.
---
Quick Reference
- 1.1 Query Priority - Use accessible queries before test IDs (role > label > text > testId)
- 1.2 Query Variants - getBy for sync, findBy for async, queryBy for absence
- 1.3 User Events - Use userEvent over fireEvent for realistic interactions
- 1.4 Async Testing - Handle promises with findBy, waitFor, act patterns
- 1.5 Custom Render - Wrap components with providers in test utils
- 1.6 Accessibility Queries - Query by role, ARIA attributes, semantic HTML
- 1.7 Anti-patterns - Avoid implementation details, container queries, wrapper usage
---
1. Testing Patterns
1.1 Query Priority
Use accessible query hierarchy: getByRole > getByLabelText > getByText > getByTestId. View detailed examples
1.2 Query Variants
getBy for immediate presence, findBy for async appearance, queryBy* to assert absence. View detailed examples
1.3 User Events
Use @testing-library/user-event for realistic interaction sequences, not fireEvent. View detailed examples
1.4 Async Testing
Handle async with findBy queries and waitFor; avoid act warnings with proper awaiting. View detailed examples
1.5 Custom Render
Create test utils that wrap components with Context, Redux, Router providers. View detailed examples
1.6 Accessibility Queries
Query by semantic roles and labels; test ensures accessibility by design. View detailed examples
1.7 Anti-patterns
Never test implementation details, use container queries, rely on excessive snapshots, use queryBy for presence, or put side effects in waitFor. View detailed examples
// test-utils.tsx
// Reusable test utilities with all common providers
// Customize this template based on your project's needs
import { render, RenderOptions, RenderResult } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactElement, ReactNode } from 'react';
import { getQueryClient } from '~/configs/query-client';
// import { ThemeProvider } from '~/configs/theme'; // Uncomment if using themes
// Re-export Testing Library utilities for convenience
export {
screen,
waitFor,
within,
waitForElementToBeRemoved
} from '@testing-library/react';
/**
* Custom render options extending RTL's RenderOptions
* Add your project-specific provider options here
*/
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
// Redux
// preloadedState?: PreloadedState<RootState>;
// store?: ReturnType<typeof configureStore>;
// React Query
queryClient?: QueryClient;
// Theme
// theme?: Theme;
}
/**
* Custom render result including test utilities
*/
interface CustomRenderResult extends RenderResult {
// Return store for assertions
// store: ReturnType<typeof configureStore>;
// Return query client for cache inspection
queryClient: QueryClient;
// Return configured userEvent for interactions
user: ReturnType<typeof userEvent.setup>;
}
const defaultQueryClient = getQueryClient({
defaultOptions: {
queries: {
retry: false,
cacheTime: 0,
staleTime: 0
},
mutations: {
retry: false
},
},
})
/**
* Custom render function that wraps components with all necessary providers
*
* @example
* ```tsx
* test('example', async () => {
* const { user, queryClient } = renderWithProviders(<MyComponent />, {
* queryClient: new QueryClient(...),
* });
*
* await user.click(screen.getByRole('button'));
* expect(screen.getByText('Clicked')).toBeInTheDocument();
* });
* ```
*/
export function renderWithProviders(
ui: ReactElement,
{
// Redux setup
// preloadedState = {},
// store = configureStore({
// reducer: rootReducer,
// preloadedState
// }),
// React Query setup - disable retries for faster tests
queryClient = defaultQueryClient,
// Theme setup
// theme = defaultTheme,
...options
}: CustomRenderOptions = {}
): CustomRenderResult {
/**
* Wrapper component with all providers
* Add/remove providers based on your project needs
*/
function Wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{/* <ThemeProvider theme={theme}> */}
{children}
{/* </ThemeProvider> */}
</QueryClientProvider>
);
}
return {
// store,
queryClient,
user: userEvent.setup(),
...render(ui, { wrapper: Wrapper, ...options }),
};
}
/**
* Export renderWithProviders as default render for convenience
* This allows importing { render } instead of { renderWithProviders }
*/
export { renderWithProviders as render };
/**
* Helper to create a test user for mocking authentication
*/
export function createTestUser(overrides = {}) {
return {
id: '123',
name: 'Test User',
email: 'test@example.com',
role: 'user',
...overrides,
};
}
/**
* Helper to create a fresh Query Client for isolated tests
*/
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
cacheTime: 0,
staleTime: 0,
},
mutations: {
retry: false,
},
},
logger: {
log: console.log,
warn: console.warn,
error: () => {}, // Silence errors in tests
},
});
}
/**
* Helper to wait for all React Query queries to settle
*/
export async function waitForQueryClient(queryClient: QueryClient) {
await queryClient.refetchQueries();
return new Promise(resolve => setTimeout(resolve, 0));
}
╭────────────────────────╮ │ accelint-react-testing │ ╰────────────────────────╯
<!-- Make sure to display this warning block to the user --> ┌──────────────────────────────────────────────────────┐ │ ⚠️ WARNING: This skill does it's best to process │ │ the context needed to suggest correct react tests │ │ but it can make mistakes. Please make sure to read │ │ the suggested tests to make sure that they are │ │ correct. │ └──────────────────────────────────────────────────────┘
Report: [Target Name]
<!-- INSTRUCTIONS FOR COMPLETING THIS TEMPLATE:
1. Replace [Target Name] with the specific file/component being audited (e.g., "LoginForm Tests", "UserCard.test.tsx")
2. EXECUTIVE SUMMARY: Provide a high-level overview
- Summarize what was audited and the scope
- Count issues by severity and category
- Include Impact Assessment explaining confidence in user experience and accessibility
3. PHASE 1 - ISSUE GROUPING RULES:
- Group issues when they share the SAME root cause AND same fix pattern
- Example: Multiple getByTestId calls that should be getByRole → group together
- Example: Different async testing violations → separate issues
- Use subsections (4-8) for grouped issues, individual numbers (1, 2, 3) for unique issues
4. PHASE 1 - EACH ISSUE/GROUP MUST INCLUDE:
- Location (file:line or file:line-range)
- Current code with ❌ marker
- Clear explanation of the issue
- Severity (Critical, High, Medium, Low)
- Category (Query Priority, Query Variants, User Events, Async Testing, Custom Render, Accessibility, Anti-patterns)
- Impact (accessibility confidence, test reliability, user-centric coverage)
- Pattern Reference (which references/*.md file)
- Recommended Fix with ✅ marker
5. SEVERITY LEVELS:
- Critical: Tests that ship inaccessible UIs or miss bugs real users would hit
Examples: getByTestId bypassing accessibility verification, stale destructured queries missing DOM updates, fireEvent missing full interaction sequences that break in production
- High: Tests that break on safe refactors or hide user-facing bugs
Examples: testing implementation details (state variables, function call counts), side effects inside waitFor running actions multiple times, snapshot entire component trees masking real issues
- Medium: Tests that are brittle, unclear, or fail to cover important scenarios
Examples: missing custom render wrapper for components with providers, wrong query variant (getBy for async content), waitFor for promise-based queries that findBy handles better
- Low: Minor improvements to query clarity or test structure
Examples: could use more descriptive query options (name, level), minor async pattern improvements
6. CATEGORIES:
- Query Priority: getByRole > getByLabelText > getByText > getByTestId hierarchy violations; test IDs used before trying accessible queries
- Query Variants: Wrong getBy/findBy/queryBy selection; getBy for async content, queryBy where getBy gives better errors, findBy where waitFor is used
- User Events: fireEvent used instead of userEvent; missing await on userEvent (async); event sequence gaps causing undetected bugs
- Async Testing: waitFor misuse (side effects inside, polling when findBy works), missing await on async queries, act warnings from unhandled state updates
- Custom Render: Missing provider wrappers (Context, Redux, Router), undocumented render utilities, repeated provider setup across test files
- Accessibility: Test-only ARIA attributes added to pass tests, labels/roles only added for test purposes rather than accessibility
- Anti-patterns: Implementation testing (state/internals), container/wrapper usage, stale destructured queries instead of screen, excessive snapshots
7. IMPACT FIELD SHOULD DESCRIBE:
- Accessibility confidence: Does a passing test mean the UI is accessible to real users and screen readers?
- User-centric confidence: Does a passing test mean a real user can complete this interaction?
- Test reliability: Could this test fail non-deterministically, go stale after re-renders, or have race conditions?
- Refactor safety: Will this test break when safely refactoring internals without changing user-visible behavior?
- Production safety: What bugs could ship to users because this test doesn't catch them?
- Maintenance cost: How much churn does this test create for legitimate changes?
8. PHASE 2: Generate summary table from Phase 1 findings
- Include all issues with their numbers
- Keep it concise - one row per issue/group
See references/ for pattern guidance on each category. -->
Executive Summary
Completed systematic audit of [file/component path] following accelint-react-testing standards. Identified [N] test quality issues across [N] severity levels. [Brief description of what this component does and why user-centric, accessible tests matter here].
Key Findings:
- [N] Critical issues (inaccessible queries, stale DOM references, missing interaction sequences)
- [N] High severity issues (implementation testing, waitFor side effects, excessive snapshots)
- [N] Medium severity issues (wrong query variants, missing providers, brittle async patterns)
- [N] Low severity issues (minor query or async improvements)
Impact Assessment: [Explain the overall confidence in user experience and accessibility coverage. Consider:]
- Do passing tests guarantee the UI is accessible to screen readers and keyboard users?
- Are tests verifying user-observable behavior or React internals?
- Are there stale queries that miss DOM updates after interactions?
- Are async interactions properly awaited to avoid race conditions and act warnings?
- Will these tests survive a refactor from useState to useReducer, or Context to Redux?
---
Phase 1: Identified Issues
1. [Component/Location] - [Issue Type]
Location: [file:line] or [file:line-range]
// ❌ Current: [Brief description of problem]
[code snippet showing the issue]Issue:
- [Point 1 explaining the problem]
- [Point 2 with specifics about the violation]
- [Point 3 quantifying the impact if possible]
Severity: [Critical|High|Medium|Low] Category: [Query Priority|Query Variants|User Events|Async Testing|Custom Render|Accessibility|Anti-patterns] Impact:
- Accessibility confidence: [Does a passing test mean the UI is usable by screen readers?]
- User-centric confidence: [Does a passing test mean a real user can complete this interaction?]
- Reliability: [Could this go stale, have race conditions, or fail non-deterministically?]
- Refactor safety: [Will this break when refactoring internals without changing behavior?]
Pattern Reference: [filename.md]
Recommended Fix:
// ✅ [Brief description of solution]
[code snippet showing the fix]---
2. [Component/Location] - [Issue Type]
Location: [file:line] or [file:line-range]
// ❌ Current: [Brief description of problem]
[code snippet]Issue:
- [Explanation]
Severity: [Critical|High|Medium|Low] Category: [Query Priority|Query Variants|User Events|Async Testing|Custom Render|Accessibility|Anti-patterns] Impact:
- Accessibility confidence: [Does a passing test mean the UI is usable by screen readers?]
- User-centric confidence: [Does a passing test mean a real user can complete this interaction?]
- Reliability: [Could this go stale, have race conditions, or fail non-deterministically?]
- Refactor safety: [Will this break when refactoring internals without changing behavior?]
Pattern Reference: [filename.md]
Recommended Fix:
// ✅ [Brief description of solution]
[code snippet]---
3-N. [Grouped Issues] - [Shared Issue Type] ([N] instances)
<!-- Use this format when multiple issues share the same root cause and fix pattern -->
Locations:
[file:line]- [component/context][file:line]- [component/context][file:line]- [component/context]
Example from [specific location]:
// ❌ Current: [Brief description of problem]
[representative code snippet]Issue:
- [Shared root cause explanation]
- [Why this pattern is problematic]
- [Impact across all instances]
Severity: [Critical|High|Medium|Low] Category: [Query Priority|Query Variants|User Events|Async Testing|Custom Render|Accessibility|Anti-patterns] Impact:
- Accessibility confidence: [Does a passing test mean the UI is usable by screen readers, across all instances?]
- User-centric confidence: [Does a passing test mean a real user can complete these interactions?]
- Reliability: [Could these go stale, have race conditions, or fail non-deterministically?]
- Refactor safety: [Will these break when refactoring internals without changing behavior?]
Pattern Reference: [filename.md]
Recommended Fix:
// ✅ [Brief description of solution]
[fixed code snippet]Same pattern applies to all [N] instances:
// [Component/location 2]
// ❌ Current
[code snippet]
// ✅ Better
[fixed snippet]
// [Component/location 3]
// ❌ Current
[code snippet]
// ✅ Better
[fixed snippet]---
Phase 2: Categorized Issues
| # | Location | Issue | Category | Severity |
|---|---|---|---|---|
| 1 | [file:line] | [Brief issue description] | [Category] | [Severity] |
| 2 | [file:line] | [Brief issue description] | [Category] | [Severity] |
| 3 | [file:line] | [Brief issue description] | [Category] | [Severity] |
| 4-N | [multiple] | [Brief issue description] | [Category] | [Severity] |
Total Issues: [N] By Severity: Critical ([N]), High ([N]), Medium ([N]), Low ([N]) By Category: [Category1] ([N]), [Category2] ([N]), [Category3] ([N])
React Testing Library Best Practices
Expert guidance for writing maintainable, user-centric React component tests with Testing Library.
Overview
This skill provides comprehensive best practices for testing React components with @testing-library/react. It focuses on accessibility-first testing, realistic user interactions, and avoiding common anti-patterns.
Installation
npm install -D @testing-library/react @testing-library/user-event @testing-library/jest-domWhat's Included
Core Guidance (SKILL.md)
- Query priority hierarchy (role > label > text > testId)
- User interaction patterns with userEvent
- Async testing strategies
- Thinking frameworks for test design
Implementation Rules (AGENTS.md)
- Query selection guidelines
- Custom render setup
- Accessibility testing patterns
- Anti-patterns to avoid
Reference Documentation
- query-priority.md - Accessible query hierarchy and when to use each
- query-variants.md - getBy vs findBy vs queryBy selection
- user-events.md - userEvent vs fireEvent patterns and interactions
- async-testing.md - Handling promises, waitFor, avoiding act warnings
- custom-render.md - Setting up providers (Context, Redux, Router)
- accessibility-queries.md - Role-based queries and ARIA patterns
- anti-patterns.md - Implementation details, container usage to avoid
Utilities
Scripts:
check-query-priority.sh- Find suboptimal query patterns (testId before role)find-fire-event.sh- Detect fireEvent that should be userEventdetect-wrapper-queries.sh- Find deprecated wrapper/container patterns
Assets:
custom-render-template.tsx- Boilerplate for test utils with providers
Quick Start
Example Test
import { render, screen } from './test-utils';
test('user can submit form', async () => {
const { user } = render(<ContactForm />);
// Query by accessible labels
await user.type(screen.getByLabelText(/email/i), 'user@example.com');
await user.type(screen.getByLabelText(/message/i), 'Hello world');
// Interact realistically
await user.click(screen.getByRole('button', { name: /submit/i }));
// Assert on user-visible outcomes
expect(await screen.findByText(/thank you/i)).toBeInTheDocument();
});Setup test-utils.tsx
Copy assets/custom-render-template.tsx to your project as test-utils.tsx and customize with your providers.
Run Audits
# Check query priority
./scripts/check-query-priority.sh
# Find fireEvent usage
./scripts/find-fire-event.sh
# Detect deprecated patterns
./scripts/detect-wrapper-queries.shKey Principles
Accessibility First: Query elements the way users find them (roles, labels, text). If queries are hard, the UI is hard to use.
User-Centric: Test what users experience (rendered output, interactions) not implementation details (state variables, function calls).
Realistic Interactions: Use userEvent to simulate complete user interactions, not fireEvent which only dispatches single events.
Explicit Async: Always await async operations. Use findBy* for elements that load asynchronously.
Configuration
Vitest setup:
// vitest.setup.ts
import '@testing-library/jest-dom/vitest';Jest setup:
// jest.setup.ts
import '@testing-library/jest-dom';License
Apache-2.0
Author
gohypergiant
Accessibility Queries
Testing with accessible queries ensures your components work for all users, including those using assistive technologies. Query difficulty reveals accessibility problems before they reach production.
Core Principle
If you can't query an element by its accessible properties (role, label, text), users with disabilities can't interact with it either.
---
Rule: Query Interactive Elements by Role
Principle: All interactive elements should have appropriate ARIA roles and accessible names.
**❌ Incorrect: Non-semantic queries for interactive elements
// ❌ Test ID doesn't verify accessibility
const button = screen.getByTestId('submit-btn');
// ❌ Class name tests implementation
const button = container.querySelector('.btn-primary');
// ❌ Text query doesn't verify it's a button
const button = screen.getByText('Submit');Problems:
- Button might lack proper role for screen readers
- No verification of accessible name
- Doesn't test user/AT experience
**✅ Correct: Role-based queries
// ✅ Verifies button role and accessible name
const button = screen.getByRole('button', { name: /submit/i });
// ✅ Verifies link role and destination
const link = screen.getByRole('link', { name: /learn more/i });
// ✅ Verifies heading role and level
const heading = screen.getByRole('heading', { name: /welcome/i, level: 1 });Benefits:
- Fails if role is missing or wrong
- Fails if accessible name is missing
- Tests what screen readers announce
---
Rule: Use getByLabelText for Form Inputs
Principle: Form inputs must have associated labels for accessibility.
**❌ Incorrect: Unlabeled or poorly labeled inputs
// ❌ No label verification
const input = screen.getByRole('textbox');
// ❌ Placeholder is not a label
const input = screen.getByPlaceholderText('Enter email');
// ❌ Test ID bypasses label check
const input = screen.getByTestId('email-input');Problems:
- Screen readers can't identify field purpose
- Touch targets unclear for motor-impaired users
- Fails WCAG 2.1 Level A
**✅ Correct: Label-based queries
// ✅ via <label htmlFor="...">
const input = screen.getByLabelText('Email address');
// ✅ via aria-label
const input = screen.getByRole('textbox', { name: /email address/i });
// ✅ via aria-labelledby
const input = screen.getByRole('searchbox', { name: /search products/i });Component examples:
// ✅ Explicit label
<>
<label htmlFor="email">Email address</label>
<input id="email" type="email" />
</>
// ✅ Wrapped label
<label>
Email address
<input type="email" />
</label>
// ✅ aria-label
<input type="search" aria-label="Search products" />
// ✅ aria-labelledby
<>
<span id="email-label">Email address</span>
<input type="email" aria-labelledby="email-label" />
</>---
Rule: Test Accessible Names Match User Expectations
Principle: The accessible name should match what users see or what makes sense in context.
**❌ Incorrect: Mismatched or missing names
// ❌ Button text doesn't match accessible name
<button aria-label="Delete item">
<TrashIcon />
</button>
// Screen readers say "Delete item" but query by icon fails
// ❌ Generic label
<button aria-label="Button">Click here</button>
// Screen readers announce "Button button" - not helpful
// ❌ Missing name
<button><CloseIcon /></button>
// Screen readers say "button" - no context**✅ Correct: Descriptive accessible names
// ✅ Icon button with descriptive label
<button aria-label="Delete user account">
<TrashIcon />
</button>
// Test:
const deleteButton = screen.getByRole('button', { name: /delete user account/i });
// ✅ Text visible and matches accessible name
<button>Save changes</button>
// Test:
const saveButton = screen.getByRole('button', { name: /save changes/i });
// ✅ Image with descriptive alt
<img src="avatar.jpg" alt="User profile picture" />
// Test:
const avatar = screen.getByRole('img', { name: /user profile picture/i });---
Common ARIA Patterns
Dialogs/Modals
// ✅ Dialog with accessible name
<div role="dialog" aria-labelledby="dialog-title">
<h2 id="dialog-title">Confirm deletion</h2>
<p>Are you sure you want to delete this item?</p>
<button>Cancel</button>
<button>Delete</button>
</div>
// Test:
const dialog = screen.getByRole('dialog', { name: /confirm deletion/i });
const deleteButton = within(dialog).getByRole('button', { name: /delete/i });Tab Panels
// ✅ Tabs with proper ARIA
<div>
<div role="tablist" aria-label="Settings sections">
<button role="tab" aria-selected="true" aria-controls="general-panel">
General
</button>
<button role="tab" aria-selected="false" aria-controls="privacy-panel">
Privacy
</button>
</div>
<div id="general-panel" role="tabpanel" aria-labelledby="general-tab">
General settings content
</div>
</div>
// Test:
const generalTab = screen.getByRole('tab', { name: /general/i });
expect(generalTab).toHaveAttribute('aria-selected', 'true');
const panel = screen.getByRole('tabpanel', { name: /general/i });
expect(panel).toBeVisible();Combobox/Select
// ✅ Accessible select
<>
<label id="country-label">Country</label>
<select aria-labelledby="country-label">
<option>United States</option>
<option>Canada</option>
</select>
</>
// Test:
const select = screen.getByRole('combobox', { name: /country/i });
await userEvent.selectOptions(select, 'Canada');
expect(select).toHaveValue('Canada');Loading States
// ✅ Announce loading to screen readers
<div role="status" aria-live="polite">
Loading user data...
</div>
// Test:
const status = screen.getByRole('status');
expect(status).toHaveTextContent(/loading/i);
// After load:
await waitForElementToBeRemoved(() => screen.getByRole('status'));Alerts
// ✅ Error alert
<div role="alert" aria-live="assertive">
Invalid email address
</div>
// Test:
const alert = screen.getByRole('alert');
expect(alert).toHaveTextContent(/invalid email/i);---
Testing Keyboard Navigation
test('menu keyboard navigation', async () => {
const user = userEvent.setup();
render(<Menu />);
const menu = screen.getByRole('menu');
menu.focus();
// Navigate with arrow keys
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('menuitem', { name: /first/i })).toHaveFocus();
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('menuitem', { name: /second/i })).toHaveFocus();
// Select with Enter
await user.keyboard('{Enter}');
expect(screen.getByText(/selected: second/i)).toBeInTheDocument();
});---
Testing Focus Management
test('modal traps focus', async () => {
const user = userEvent.setup();
render(<Page />);
// Open modal
await user.click(screen.getByRole('button', { name: /open modal/i }));
const modal = screen.getByRole('dialog');
// Focus should be in modal
expect(modal).toContainElement(document.activeElement);
// Tab stays within modal
await user.tab();
expect(modal).toContainElement(document.activeElement);
// Close modal
await user.keyboard('{Escape}');
// Focus returns to trigger button
expect(screen.getByRole('button', { name: /open modal/i })).toHaveFocus();
});---
Common Roles Reference
| Element | Role | Required Attributes |
|---|---|---|
<button> | button | Accessible name (text content or aria-label) |
<a href="..."> | link | Accessible name (text content or aria-label) |
<input type="text"> | textbox | Label (via label, aria-label, or aria-labelledby) |
<input type="checkbox"> | checkbox | Label |
<input type="radio"> | radio | Label |
<select> | combobox | Label |
<textarea> | textbox (multiline) | Label |
<h1> - <h6> | heading (level 1-6) | Text content |
<img> | img | alt attribute (accessible name) |
<nav> | navigation | Optional aria-label for multiple navs |
<main> | main | None (only one per page) |
<dialog> | dialog | aria-labelledby or aria-label |
---
Key Takeaways
- Query by role for interactive elements (buttons, links, inputs)
- Query by label for form fields via getByLabelText or role + name
- Accessible name required for all interactive elements
- Use within() to scope queries to specific regions (dialogs, menus)
- Test keyboard navigation to verify tab order and keyboard shortcuts
- Test focus management especially for modals and dynamic content
- Query difficulty = accessibility problem - fix the component, not the test
Anti-patterns
Avoid these common mistakes that make tests brittle, hard to maintain, or provide false confidence.
Rule: Never Test Implementation Details
Principle: Test what users experience (outputs, rendered content), not how components achieve it internally (state, function calls, private methods).
**❌ Incorrect: Testing implementation
// ❌ Testing state variable
test('counter increments', () => {
const { result } = renderHook(() => useCounter());
act(() => result.current.increment());
expect(result.current.count).toBe(1); // Testing internal state
});
// ❌ Testing function was called
test('calls handler', async () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick} />);
// Component refactored to call onClick twice internally
await userEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1); // Breaks if implementation changes
});
// ❌ Testing component instance methods
test('validates input', () => {
const ref = React.createRef();
render(<Form ref={ref} />);
expect(ref.current.validateEmail('test')).toBe(false); // Testing private API
});Problems:
- Tests break during safe refactoring (useState → useReducer)
- No confidence in user experience
- Couples tests to implementation
**✅ Correct: Test user-observable behavior
// ✅ Test rendered output
test('counter increments', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
// ✅ Test effect of click
test('submits form', async () => {
const user = userEvent.setup();
render(<ContactForm />);
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(await screen.findByText(/thank you/i)).toBeInTheDocument();
});
// ✅ Test validation feedback
test('shows validation error', async () => {
const user = userEvent.setup();
render(<Form />);
await user.type(screen.getByLabelText(/email/i), 'invalid');
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(screen.getByText(/invalid email/i)).toBeInTheDocument();
});---
Rule: Always Use screen, Never container
Principle: The screen export never goes stale after re-renders. Destructured queries from render() can miss updates.
**❌ Incorrect: Using container or destructured queries
// ❌ Destructured queries go stale
const { getByText, rerender } = render(<Counter count={0} />);
rerender(<Counter count={1} />);
getByText('Count: 1'); // ❌ Searches old snapshot, might fail
// ❌ container is implementation detail
const { container } = render(<Component />);
const button = container.querySelector('.btn'); // ❌ Tests CSS class
// ❌ container.firstChild
const { container } = render(<Component />);
expect(container.firstChild).toHaveClass('active'); // ❌ FragileProblems:
- Destructured queries search initial render snapshot
- container queries test DOM structure, not user experience
- querySelector couples tests to CSS classes
**✅ Correct: Always use screen
// ✅ screen always queries current DOM
render(<Counter count={0} />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
// After state update
await userEvent.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
// ✅ Query by role, not class
const button = screen.getByRole('button', { name: /submit/i });
expect(button).toBeEnabled();---
Rule: Don't Use rerender() for State Changes
Principle: Update state through user actions, not by forcing re-renders.
**❌ Incorrect: Forcing re-render with rerender()
// ❌ Manually forcing re-render
const { rerender } = render(<Counter count={0} />);
rerender(<Counter count={1} />); // Not how users interact
expect(screen.getByText('Count: 1')).toBeInTheDocument();
// ❌ Testing prop changes directly
const { rerender } = render(<Toggle enabled={false} />);
rerender(<Toggle enabled={true} />);
expect(screen.getByRole('switch')).toBeChecked();Problems:
- Doesn't test how state actually changes
- Skips event handlers and side effects
- Tests unrealistic scenarios
**✅ Correct: Trigger state changes through interactions
// ✅ User clicks button
test('counter increments', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
// ✅ Test prop changes from parent interaction
test('toggle switch', async () => {
const user = userEvent.setup();
render(<SettingsPanel />);
const toggle = screen.getByRole('switch', { name: /notifications/i });
await user.click(toggle);
expect(toggle).toBeChecked();
});When rerender() is OK:
- Testing how component responds to prop changes from parent
- Integration tests where parent passes different props
- But still prefer testing through realistic user interactions
---
Rule: Don't Mock Your Own Components
Principle: Mocking internal components hides integration bugs and reduces test value.
**❌ Incorrect: Mocking internal components
// ❌ Mocking internal component
vi.mock('./UserAvatar', () => ({
UserAvatar: () => <div data-testid="mock-avatar" />
}));
test('profile page', () => {
render(<ProfilePage />);
expect(screen.getByTestId('mock-avatar')).toBeInTheDocument();
// Doesn't test if UserAvatar actually works with ProfilePage
});Problems:
- Integration bugs slip through
- Doesn't test real component interactions
- Mock gets out of sync with real component
**✅ Correct: Test with real components
// ✅ Use real component
test('profile page', async () => {
render(<ProfilePage user={mockUser} />);
// Test actual rendered output
const avatar = screen.getByRole('img', { name: mockUser.name });
expect(avatar).toHaveAttribute('src', mockUser.avatarUrl);
});
// ✅ Mock external dependencies, not internals
vi.mock('./api', () => ({
fetchUser: vi.fn(() => Promise.resolve(mockUser))
}));What to mock:
- External APIs (fetch, axios)
- Third-party services
- Browser APIs (localStorage, navigator)
- Expensive operations (image processing, crypto)
What NOT to mock:
- Your own React components
- Your own custom hooks
- Redux actions/reducers
- Context providers
---
Rule: Avoid Testing Library Internal APIs
Principle: If you're importing from @testing-library/react/internal or using debug() in production tests, you're doing it wrong.
**❌ Incorrect: Using internal APIs
// ❌ Internal imports
import { buildQueries } from '@testing-library/react/internal';
// ❌ debug() in committed tests
test('example', () => {
render(<Component />);
screen.debug(); // ❌ Left in committed code
});**✅ Correct: Use public API
// ✅ Use documented Testing Library exports
import { render, screen, within, waitFor } from '@testing-library/react';
// ✅ Remove debug() before committing
test('example', () => {
render(<Component />);
expect(screen.getByRole('button')).toBeInTheDocument();
});---
Rule: Use getBy for Better Error Messages, Not queryBy
Principle: queryBy returns null silently. getBy throws with helpful suggestions about what's actually in the DOM.
**❌ Incorrect: queryBy with toBeInTheDocument
// ❌ null + expect gives unhelpful errors
const button = screen.queryByRole('button', { name: /submit/i });
expect(button).toBeInTheDocument();
// When fails: "Expected null to be in document" - no hints about what roles existProblems:
- Error message just says "null" without context
- Doesn't suggest similar elements or available roles
- Adds extra line of code for no benefit
**✅ Correct: getBy for presence, queryBy only for absence
// ✅ getBy throws with helpful error immediately
const button = screen.getByRole('button', { name: /submit/i });
// When fails: "Unable to find button with name /submit/i
// Here are the available roles: link (2), heading (1)..."
// ✅ queryBy only for asserting absence
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();Benefits:
- getBy error messages suggest what queries would work
- queryBy reserved for its one valid use case
- Less code, better debugging experience
---
Rule: Never Perform Side Effects Inside waitFor
Principle: waitFor retries its callback multiple times. Side effects get executed repeatedly, causing unpredictable behavior.
**❌ Incorrect: Actions inside waitFor
// ❌ Click runs on every retry
await waitFor(() => {
fireEvent.click(incrementButton);
expect(screen.getByText('Count: 5')).toBeInTheDocument();
});
// Button clicked 10+ times before assertion passes
// ❌ Multiple queries and actions
await waitFor(() => {
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: 'test' } });
expect(input).toHaveValue('test');
});
// Value set multiple times as waitFor retriesProblems:
- Actions execute on every retry (could be 10+ times)
- Unpredictable behavior based on timing
- Hard to debug flaky tests
**✅ Correct: Actions outside waitFor, only assertions inside
// ✅ Click once, then wait for result
await userEvent.click(incrementButton);
await waitFor(() => {
expect(screen.getByText('Count: 5')).toBeInTheDocument();
});
// ✅ Or better: use findBy
await userEvent.click(incrementButton);
expect(await screen.findByText('Count: 5')).toBeInTheDocument();
// ✅ Setup outside, assertion inside
const input = screen.getByRole('textbox');
await userEvent.type(input, 'test');
await waitFor(() => {
expect(input).toHaveValue('test');
});Benefits:
- Actions execute exactly once
- Predictable test behavior
- waitFor only retries assertions, not side effects
---
Rule: Don't Add ARIA Attributes Just for Tests
Principle: If you need to add aria-label or role to make tests pass, the component has an accessibility problem.
**❌ Incorrect: Adding test-only ARIA
// ❌ Component code
function SubmitButton() {
return (
<div
onClick={handleSubmit}
aria-label="submit-button" // ❌ Added only for tests
role="button" // ❌ Should use <button> element
>
Submit
</div>
);
}
// Test works but component is inaccessible
const button = screen.getByRole('button', { name: /submit/i });Problems:
- Masks underlying accessibility issues
- Manual ARIA attributes often wrong or incomplete
- Test passes while real users struggle
**✅ Correct: Fix the component semantics
// ✅ Component code
function SubmitButton() {
return (
<button onClick={handleSubmit}>
Submit
</button>
);
}
// ✅ Test works because component is properly semantic
const button = screen.getByRole('button', { name: /submit/i });
// ✅ Icon button needs aria-label (this is OK)
function DeleteButton() {
return (
<button onClick={handleDelete} aria-label="Delete item">
<TrashIcon />
</button>
);
}When ARIA is acceptable:
- Icon-only buttons need accessible names
- Complex custom components (after trying semantic HTML)
- Enhancing existing semantic elements
Never:
- Adding role="button" instead of using
<button> - Adding aria-label to avoid using
<label>for forms - Any ARIA added only because tests failed
---
Key Takeaways
- Test behavior, not implementation - what users see, not how code achieves it
- Always use screen - never container or destructured queries
- Avoid large snapshots - use targeted assertions
- Don't force re-renders - trigger updates through user interactions
- Don't mock your own code - mock external dependencies only
- Use public Testing Library API - avoid internal imports
Async Testing
Handling asynchronous operations correctly is critical for reliable React tests. Understanding when and how to wait for async updates prevents flaky tests and act warnings.
Core Principle
React state updates are asynchronous. Tests must wait for updates to complete before asserting on the result.
---
Rule: Use findBy* for Elements That Appear Asynchronously
Principle: findBy* queries wait for elements loaded via useEffect, API calls, or timers.
**❌ Incorrect: Synchronous query for async content
test('loads user data', () => {
render(<UserProfile id={123} />);
// ❌ Throws immediately - data not loaded yet
const name = screen.getByText('John Doe');
});**✅ Correct: Async query waits for content
test('loads user data', async () => {
render(<UserProfile id={123} />);
// ✅ Waits up to 1000ms for element to appear
const name = await screen.findByText('John Doe');
expect(name).toBeInTheDocument();
});---
Rule: Use waitFor for Complex Assertions
Principle: waitFor retries callback until it succeeds or times out. Use for assertions that can't be expressed as findBy queries.
**❌ Incorrect: Synchronous assertions on async state
// ❌ Assertion runs before state updates
await userEvent.click(button);
expect(callback).toHaveBeenCalled(); // Might not be called yet
// ❌ Element might still be visible
await userEvent.click(closeButton);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();**✅ Correct: waitFor for non-query assertions
// ✅ Waits for callback invocation
await userEvent.click(button);
await waitFor(() => expect(callback).toHaveBeenCalled());
// ✅ Or use findBy for element queries
await userEvent.click(button);
await screen.findByText('Success');*Prefer findBy over waitFor + getBy when possible.**
---
Rule: Avoid Act Warnings with Proper Awaiting
Principle: Act warnings mean state updates happened outside Testing Library's awareness.
**❌ Incorrect: Missing await on async operations
// ❌ No await - state update after test finishes
userEvent.click(button);
expect(screen.getByText('Clicked')).toBeInTheDocument();
// ❌ Promise resolves after assertion
fetchData().then(data => setState(data));
expect(screen.getByText('Data')).toBeInTheDocument();**✅ Correct: Await all async operations
// ✅ Wait for click to complete
await userEvent.click(button);
expect(screen.getByText('Clicked')).toBeInTheDocument();
// ✅ Wait for data to load and render
await screen.findByText('Data');---
Rule: Use waitForElementToBeRemoved for Disappearance
Principle: When elements should disappear, waitForElementToBeRemoved is more explicit than queryBy.
**❌ Incorrect: queryBy without waiting
await userEvent.click(closeButton);
// ❌ Might still be visible due to animation
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();**✅ Correct: Wait for removal
const dialog = screen.getByRole('dialog');
await userEvent.click(closeButton);
await waitForElementToBeRemoved(dialog);---
Common Async Patterns
Loading state → Success
test('shows loading then data', async () => {
render(<DataList />);
// Loading appears immediately
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Wait for data to replace loading
const items = await screen.findAllByRole('listitem');
expect(items).toHaveLength(5);
// Loading gone
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});Error handling
test('shows error on failure', async () => {
server.use(
http.get('/api/users', () => HttpResponse.error())
);
render(<UserList />);
const error = await screen.findByRole('alert');
expect(error).toHaveTextContent(/failed to load/i);
});Debounced input
test('debounced search', async () => {
const user = userEvent.setup();
render(<Search />);
const input = screen.getByRole('searchbox');
await user.type(input, 'react');
// Wait for debounce + API call
const results = await screen.findAllByRole('listitem');
expect(results).toHaveLength(3);
});Polling/retry
test('retries failed request', async () => {
let attempts = 0;
server.use(
http.get('/api/data', () => {
attempts++;
return attempts < 3
? HttpResponse.error()
: HttpResponse.json({ data: 'success' });
})
);
render(<RetryComponent />);
// Eventually succeeds after retries
expect(await screen.findByText('success')).toBeInTheDocument();
}, { timeout: 10000 }); // Increase timeout for retry logic---
Rule: Never Put Side Effects in waitFor Callbacks
Principle: waitFor retries its callback multiple times. Side effects inside get executed repeatedly.
**❌ Incorrect: Actions inside waitFor
// ❌ Button clicked on every retry (10+ times)
await waitFor(() => {
fireEvent.click(button);
expect(callback).toHaveBeenCalled();
});
// ❌ API called repeatedly
await waitFor(async () => {
const data = await fetchData(); // ❌ Fetches on every retry
expect(data).toBeDefined();
});
// ❌ State mutations on every retry
await waitFor(() => {
setItems(prev => [...prev, newItem]); // ❌ Adds item multiple times
expect(screen.getByText('Item added')).toBeInTheDocument();
});Problems:
- Actions execute 10+ times as waitFor retries
- Network requests duplicated unnecessarily
- State mutations accumulate unpredictably
- Tests become flaky and slow
**✅ Correct: Side effects outside, assertions inside
// ✅ Click once, wait for callback
await userEvent.click(button);
await waitFor(() => {
expect(callback).toHaveBeenCalled();
});
// ✅ Or better: use findBy
await userEvent.click(button);
expect(await screen.findByText('Success')).toBeInTheDocument();
// ✅ Fetch once, wait for result
const dataPromise = fetchData();
await waitFor(async () => {
const data = await dataPromise; // Same promise, not re-fetching
expect(data).toBeDefined();
});
// ✅ State change once, wait for UI update
setItems(prev => [...prev, newItem]);
await waitFor(() => {
expect(screen.getByText('Item added')).toBeInTheDocument();
});Rule of thumb: If it changes state, calls an API, or triggers events, it belongs OUTSIDE waitFor.
---
Rule: Prefer findBy Over waitFor + getBy
Principle: findBy queries are specifically designed for waiting. waitFor + getBy is verbose and less clear.
**❌ Incorrect: waitFor wrapping getBy
// ❌ Verbose and less intention-revealing
await waitFor(() => {
expect(screen.getByText('Loaded')).toBeInTheDocument();
});
// ❌ Even worse
await waitFor(() => {
const element = screen.getByText('Loaded');
expect(element).toBeInTheDocument();
});**✅ Correct: Use findBy directly
// ✅ Concise and clear
expect(await screen.findByText('Loaded')).toBeInTheDocument();
// ✅ Or just
await screen.findByText('Loaded');Use waitFor only when:
- Asserting on something that's not a DOM query (callback called, attribute changed)
- Waiting for element to disappear (use waitForElementToBeRemoved instead)
- Complex assertions that can't be expressed as queries
---
Timeout Configuration
Default timeout is 1000ms. Configure per query or globally:
// Per query
await screen.findByText('Slow content', {}, { timeout: 3000 });
// Per waitFor
await waitFor(() => expect(element).toBeVisible(), { timeout: 5000 });
// Globally in setup
import { configure } from '@testing-library/react';
configure({ asyncUtilTimeout: 2000 });---
Key Takeaways
- findBy* for elements that load asynchronously
- waitFor for complex assertions not expressible as queries
- Always await userEvent calls and async queries
- waitForElementToBeRemoved for elements that should disappear
- Act warnings = missing await or state update after test finishes
Custom Render
Components using Context, Redux, React Router, or other providers need wrapped renders for tests. Centralizing provider setup in test utils prevents duplication and ensures consistency.
Core Principle
Don't repeat provider setup in every test. Create custom render utilities that wrap components automatically.
---
Rule: Create Custom Render for Providers
Principle: Centralize provider setup in test utils instead of duplicating in every test file.
**❌ Incorrect: Repeating provider setup
// ❌ Every test file repeats this
test('component with theme', () => {
render(
<ThemeProvider theme={theme}>
<Component />
</ThemeProvider>
);
});
// ❌ Duplicated in another test file
test('other component', () => {
render(
<ThemeProvider theme={theme}>
<OtherComponent />
</ThemeProvider>
);
});Problems:
- Provider setup duplicated across test files
- Hard to update when providers change
- Easy to forget required providers
**✅ Correct: Custom render utility
// test-utils.tsx
import { render } from '@testing-library/react';
import { ThemeProvider } from './theme';
export function renderWithTheme(ui: React.ReactElement, theme = defaultTheme) {
return render(
<ThemeProvider theme={theme}>
{ui}
</ThemeProvider>
);
}
// component.test.tsx
import { renderWithTheme } from './test-utils';
test('component with theme', () => {
renderWithTheme(<Component />);
// Test assertions...
});---
Common Provider Patterns
Theme Provider
// test-utils.tsx
import { render, RenderOptions } from '@testing-library/react';
import { ThemeProvider } from './ThemeProvider';
import { theme } from './theme';
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
theme?: typeof theme;
}
export function renderWithTheme(
ui: React.ReactElement,
{ theme: customTheme = theme, ...options }: CustomRenderOptions = {}
) {
function Wrapper({ children }: { children: React.ReactNode }) {
return <ThemeProvider theme={customTheme}>{children}</ThemeProvider>;
}
return render(ui, { wrapper: Wrapper, ...options });
}
// Usage
test('uses theme colors', () => {
renderWithTheme(<Button />, { theme: darkTheme });
// ...
});Redux Store
// test-utils.tsx
import { render, RenderOptions } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { rootReducer } from './store';
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
preloadedState?: RootState;
store?: ReturnType<typeof configureStore>;
}
export function renderWithStore(
ui: React.ReactElement,
{
preloadedState = {},
store = configureStore({ reducer: rootReducer, preloadedState }),
...options
}: CustomRenderOptions = {}
) {
function Wrapper({ children }: { children: React.ReactNode }) {
return <Provider store={store}>{children}</Provider>;
}
return { store, ...render(ui, { wrapper: Wrapper, ...options }) };
}
// Usage
test('dispatches action', async () => {
const { store } = renderWithStore(<TodoList />, {
preloadedState: { todos: [] }
});
await userEvent.click(screen.getByRole('button', { name: /add/i }));
expect(store.getState().todos).toHaveLength(1);
});React Router
// test-utils.tsx
import { render, RenderOptions } from '@testing-library/react';
import { BrowserRouter, MemoryRouter } from 'react-router-dom';
interface RouterRenderOptions extends Omit<RenderOptions, 'wrapper'> {
initialEntries?: string[];
}
export function renderWithRouter(
ui: React.ReactElement,
{ initialEntries = ['/'], ...options }: RouterRenderOptions = {}
) {
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<MemoryRouter initialEntries={initialEntries}>
{children}
</MemoryRouter>
);
}
return render(ui, { wrapper: Wrapper, ...options });
}
// Usage
test('navigates to detail page', async () => {
renderWithRouter(<App />, { initialEntries: ['/items/123'] });
expect(await screen.findByRole('heading', { name: /item 123/i })).toBeInTheDocument();
});Multiple Providers
// test-utils.tsx
import { render, RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { ThemeProvider } from './theme';
interface AllProvidersOptions extends Omit<RenderOptions, 'wrapper'> {
initialEntries?: string[];
preloadedState?: RootState;
queryClient?: QueryClient;
theme?: Theme;
}
export function renderWithAllProviders(
ui: React.ReactElement,
{
initialEntries = ['/'],
preloadedState = {},
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
}),
theme = defaultTheme,
...options
}: AllProvidersOptions = {}
) {
const store = configureStore({
reducer: rootReducer,
preloadedState,
});
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<BrowserRouter initialEntries={initialEntries}>
<ThemeProvider theme={theme}>
{children}
</ThemeProvider>
</BrowserRouter>
</Provider>
</QueryClientProvider>
);
}
return { store, queryClient, ...render(ui, { wrapper: Wrapper, ...options }) };
}---
Rule: Export Custom Render with Screen
Principle: Re-export Testing Library utilities from test-utils for consistent imports.
**❌ Incorrect: Mixed imports
// ❌ Mixing custom and standard imports
import { screen } from '@testing-library/react';
import { renderWithProviders } from './test-utils';
test('example', () => {
renderWithProviders(<Component />);
// ...
});**✅ Correct: Single import source
// test-utils.tsx
export { screen, waitFor, within } from '@testing-library/react';
export * from '@testing-library/user-event';
// component.test.tsx
import { renderWithProviders, screen, waitFor } from './test-utils';
test('example', async () => {
renderWithProviders(<Component />);
expect(await screen.findByText('Content')).toBeInTheDocument();
});---
Complete Test Utils Example
// test-utils.tsx
import { render, RenderOptions, RenderResult } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ReactElement } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { ThemeProvider } from './theme';
import { rootReducer, RootState } from './store';
import { defaultTheme, Theme } from './theme';
// Re-export Testing Library utilities
export { screen, waitFor, within, waitForElementToBeRemoved } from '@testing-library/react';
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
preloadedState?: Partial<RootState>;
store?: ReturnType<typeof configureStore>;
queryClient?: QueryClient;
theme?: Theme;
initialEntries?: string[];
}
interface CustomRenderResult extends RenderResult {
store: ReturnType<typeof configureStore>;
queryClient: QueryClient;
user: ReturnType<typeof userEvent.setup>;
}
export function renderWithProviders(
ui: ReactElement,
{
preloadedState = {},
store = configureStore({ reducer: rootReducer, preloadedState }),
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, cacheTime: 0 },
mutations: { retry: false },
},
}),
theme = defaultTheme,
initialEntries = ['/'],
...options
}: CustomRenderOptions = {}
): CustomRenderResult {
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<BrowserRouter initialEntries={initialEntries}>
<ThemeProvider theme={theme}>
{children}
</ThemeProvider>
</BrowserRouter>
</Provider>
</QueryClientProvider>
);
}
return {
store,
queryClient,
user: userEvent.setup(),
...render(ui, { wrapper: Wrapper, ...options }),
};
}
// Export as default render for convenience
export { renderWithProviders as render };Usage
import { render, screen } from './test-utils';
test('full app integration', async () => {
const { user, store } = render(<App />, {
preloadedState: { user: mockUser },
initialEntries: ['/dashboard']
});
await user.click(screen.getByRole('button', { name: /logout/i }));
expect(store.getState().user).toBeNull();
expect(screen.getByText(/login/i)).toBeInTheDocument();
});---
Key Takeaways
- Centralize provider setup in test-utils.tsx
- Accept options for customizing providers per test
- Re-export Testing Library utilities for consistent imports
- Return useful objects (store, queryClient) for assertions
- Include userEvent.setup() in render result for convenience
Query Priority
The query hierarchy reflects how users and assistive technologies interact with your UI. Higher priority queries provide stronger guarantees about accessibility.
Priority Hierarchy
Query by: 1. Role - How assistive technologies identify elements 2. Label - How users understand form fields 3. Placeholder - Less accessible fallback for inputs 4. Text - Visible content users read 5. Display Value - Current form input values 6. Alt Text - Image descriptions 7. Title - Tooltip attribute (limited accessibility) 8. Test ID - No accessibility verification (last resort)
---
Rule: Use getByRole as Default Query
Principle: Role queries verify both presence and accessibility in one assertion.
**❌ Incorrect: Skipping accessible queries
// Using test ID first bypasses accessibility checks
const button = screen.getByTestId('submit-button');
// Using text without role doesn't verify semantic meaning
const heading = screen.getByText('Dashboard');
// Using class names tests implementation
const modal = container.querySelector('.modal-overlay');Problems:
- Test passes even if button lacks accessible name for screen readers
- getByText can match any element - no semantic verification
- querySelector tests CSS implementation, not user experience
**✅ Correct: Role-first approach
// Verify button role AND accessible name
const button = screen.getByRole('button', { name: /submit/i });
// Verify heading role AND level
const heading = screen.getByRole('heading', { name: /dashboard/i, level: 1 });
// Verify dialog role AND label
const modal = screen.getByRole('dialog', { name: /confirm delete/i });Benefits:
- Test fails if element lacks proper ARIA role
- Test fails if accessible name is missing or wrong
- Changing CSS classes doesn't break tests
- Forces components to be accessible by design
---
Rule: Use getByLabelText for Form Fields
Principle: Form fields should always have associated labels for accessibility.
**❌ Incorrect: Querying inputs without labels
// Placeholder is not a label replacement
const input = screen.getByPlaceholderText('Enter email');
// Name attribute is for form submission, not accessibility
const input = screen.getByRole('textbox', { name: '' });
// Test ID bypasses label verification
const input = screen.getByTestId('email-input');Problems:
- Screen readers can't identify unlabeled form fields
- Placeholders disappear when user types
- Missing labels reduce usability for all users
**✅ Correct: Label-first for form fields
// Label via <label> element
const input = screen.getByLabelText('Email address');
// Or query by role if label exists
const input = screen.getByRole('textbox', { name: /email address/i });
// For aria-label
const input = screen.getByRole('searchbox', { name: /search products/i });Benefits:
- Test fails if label is missing or disconnected
- Works with
<label>, aria-label, aria-labelledby - Ensures form is usable by screen reader users
---
Rule: Reserve getByTestId for Truly Dynamic Content
Principle: Test IDs should be last resort when semantic queries are impossible.
**❌ Incorrect: Test IDs as default
// Static button doesn't need test ID
const button = screen.getByTestId('save-button');
// Headings have semantic role
const title = screen.getByTestId('page-title');
// Generated content could use text or role
const item = screen.getByTestId(`item-${id}`);Problems:
- No accessibility verification
- Test IDs add maintenance burden to components
- Doesn't reflect how users interact with UI
**✅ Correct: Semantic queries with test ID fallback
// Use role for interactive elements
const button = screen.getByRole('button', { name: /save/i });
// Use role + level for headings
const title = screen.getByRole('heading', { name: /settings/i, level: 1 });
// Test ID acceptable for dynamic content without stable text
const avatar = screen.getByTestId(`user-avatar-${userId}`);
// But prefer: getByRole('img', { name: userName }) if alt text is setWhen test IDs are acceptable:
- Dynamically generated IDs where text/role isn't stable
- Third-party components you can't modify
- Non-semantic containers (grid layouts, wrappers)
---
Rule: Query by Text for Static Content
Principle: Text queries work well for non-interactive content when role isn't specific.
**❌ Incorrect: Text queries for interactive elements
// Button might have icon, state text, etc.
const button = screen.getByText('Delete');
// Link text might be inside nested spans
const link = screen.getByText('View details');
// Headings need semantic verification
const heading = screen.getByText('Error');Problems:
- Breaks if button text changes slightly
- Doesn't verify semantic meaning
- Fails if text is split across elements
**✅ Correct: Text for paragraphs, role for interactive
// Text query fine for static content
const message = screen.getByText(/your order has been confirmed/i);
// But use role for interactive elements
const button = screen.getByRole('button', { name: /delete/i });
const link = screen.getByRole('link', { name: /view details/i });
// Role for semantic elements
const heading = screen.getByRole('heading', { name: /error/i });Benefits:
- getByText works for paragraphs, list items, labels
- getByRole verifies semantics for interactive elements
- Regex makes text queries flexible
---
Common Roles Reference
Quick reference for most-used ARIA roles:
| Element Type | Role | Example Query |
|---|---|---|
| Button | button | getByRole('button', { name: /submit/i }) |
| Link | link | getByRole('link', { name: /home/i }) |
| Heading | heading | getByRole('heading', { name: /title/i, level: 1 }) |
| Text input | textbox | getByRole('textbox', { name: /email/i }) |
| Checkbox | checkbox | getByRole('checkbox', { name: /agree/i }) |
| Radio | radio | getByRole('radio', { name: /option a/i }) |
| Select | combobox or listbox | getByRole('combobox', { name: /country/i }) |
| Dialog/Modal | dialog | getByRole('dialog', { name: /confirm/i }) |
| Alert | alert | getByRole('alert') |
| List | list | getByRole('list') |
| List item | listitem | getByRole('listitem', { name: /item 1/i }) |
| Navigation | navigation | getByRole('navigation', { name: /main/i }) |
| Main content | main | getByRole('main') |
| Image | img | getByRole('img', { name: /logo/i }) |
---
When to Move Down the Hierarchy
Start with getByRole and move down only when necessary:
// 1st choice: Role (semantic + accessible)
screen.getByRole('button', { name: /submit/i })
// 2nd choice: Label (for form fields)
screen.getByLabelText('Email address')
// 3rd choice: Text (for static content)
screen.getByText(/confirmation message/i)
// Last choice: Test ID (when semantic query impossible)
screen.getByTestId('dynamic-widget-123')Each step down = less confidence in accessibility.
Query Variants
Understanding when to use getBy, findBy, or queryBy* is critical for reliable tests. Each variant has specific use cases based on timing and expected presence.
Query Variant Matrix
| Variant | Timing | Returns | Throws | Use Case |
|---|---|---|---|---|
getBy* | Synchronous | Element | Yes (if not found) | Element should exist now |
findBy* | Async (waits) | Promise\<Element\> | Yes (after timeout) | Element will appear after async operation |
queryBy* | Synchronous | Element \ | null | No |
---
Rule: Use getBy* for Immediate Presence
Principle: When element should already be in DOM, use getBy* for immediate failure feedback.
**❌ Incorrect: Async query for sync content
// Element renders immediately, no need for async
const heading = await screen.findByRole('heading', { name: /welcome/i });
// Static text doesn't need waiting
const button = await screen.findByRole('button', { name: /click me/i });
// queryBy hides missing element error
const title = screen.queryByText('Dashboard');
expect(title).toBeInTheDocument();Problems:
- findBy adds unnecessary timeout delay to fast tests
- queryBy + expect masks helpful error message from getBy
- Tests run slower without gaining reliability
**✅ Correct: getBy for synchronous content
// Element in initial render
const heading = screen.getByRole('heading', { name: /welcome/i });
// Button rendered immediately
const button = screen.getByRole('button', { name: /click me/i });
// Descriptive error if missing
const title = screen.getByText('Dashboard');Benefits:
- Test fails immediately if element missing
- No artificial timeout delay
- Clear error messages pinpoint missing elements
---
Rule: Use findBy* for Async Appearance
Principle: Elements loaded via useEffect, API calls, or timers need async queries.
**❌ Incorrect: getBy for async content
// useEffect loads data - might not be ready
const item = screen.getByText('Loaded item'); // ❌ Throws before data loads
// Timeout not guaranteed to finish
await new Promise(r => setTimeout(r, 100));
const result = screen.getByText('Result'); // ❌ Race condition
// Manual waiting less clear than findBy
await waitFor(() => {
expect(screen.getByText('Loaded')).toBeInTheDocument();
}); // ❌ Verbose, less intention-revealingProblems:
- getBy throws immediately, missing elements loaded async
- setTimeout creates race conditions
- waitFor + getBy more verbose than findBy
**✅ Correct: findBy for async content
// Waits for element loaded in useEffect
const item = await screen.findByText('Loaded item');
// API call returns data
const result = await screen.findByRole('heading', { name: /success/i });
// User action triggers async state update
await userEvent.click(button);
const message = await screen.findByText(/saved successfully/i);Benefits:
- Automatically retries until element appears or timeout
- Clear intention: "this element loads asynchronously"
- Single line instead of waitFor + getBy
---
Rule: Use queryBy* to Assert Absence
Principle: To verify element is NOT present, queryBy returns null instead of throwing.
**❌ Incorrect: Expecting getBy to not find element
// getBy throws, can't catch in expect
expect(() => screen.getByText('Hidden')).toThrow(); // ❌ Awkward
// findBy waits full timeout before failing
await expect(screen.findByText('Hidden')).rejects.toThrow(); // ❌ Slow
// Using try/catch obscures intent
try {
screen.getByText('Error message');
// Not reached if found
} catch {
// Element absent - success?
} // ❌ Confusing control flowProblems:
- getBy throws, not designed for absence testing
- findBy wastes time waiting for element that shouldn't appear
- try/catch makes intent unclear
**✅ Correct: queryBy for absence assertions
// Element not present initially
const error = screen.queryByText('Error message');
expect(error).not.toBeInTheDocument();
// Element removed after action
await userEvent.click(closeButton);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
// Conditional rendering based on props
render(<Component showDetails={false} />);
expect(screen.queryByText('Details')).toBeNull();Benefits:
- Returns null instead of throwing
- Intent clear: "checking element is absent"
- Fast - no retry logic
---
Rule: waitForElementToBeRemoved for Disappearance
Principle: When element should disappear after action, waitForElementToBeRemoved is clearer than queryBy.
**❌ Incorrect: queryBy in waitFor for removal
// Verbose and less clear
await waitFor(() => {
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
// Manual polling
while (screen.queryByRole('progressbar')) {
await new Promise(r => setTimeout(r, 50));
} // ❌ Reimplementing waitFor
// No waiting - might still be present
await userEvent.click(button);
expect(screen.queryByRole('dialog')).toBeNull(); // ❌ Race conditionProblems:
- waitFor + queryBy is verbose for common pattern
- Manual polling error-prone
- No waiting creates race conditions
**✅ Correct: waitForElementToBeRemoved
// Wait for loading spinner to disappear
const spinner = screen.getByRole('progressbar');
await waitForElementToBeRemoved(spinner);
// Wait for modal to close
const modal = screen.getByRole('dialog');
await userEvent.click(closeButton);
await waitForElementToBeRemoved(modal);
// Or get + wait in one line
await waitForElementToBeRemoved(() => screen.getByText('Saving...'));Benefits:
- Single-purpose utility for disappearance
- Self-documenting: element should be removed
- Handles timing automatically
---
Multiple Elements: getAllBy, findAllBy, queryAllBy*
Same patterns apply for multiple elements:
**❌ Incorrect: Wrong variant for multiple elements
// getAll for async list
const items = screen.getAllByRole('listitem'); // ❌ Throws if loading
// findAll for sync list
const items = await screen.findAllByRole('listitem'); // ❌ Unnecessary wait
// queryAll without length check
const errors = screen.queryAllByRole('alert');
// ❌ Empty array if absent - need to check length**✅ Correct: Matching variant to scenario
// getAllBy for synchronous lists
const items = screen.getAllByRole('listitem');
expect(items).toHaveLength(5);
// findAllBy for async loaded lists
const items = await screen.findAllByRole('listitem');
expect(items.length).toBeGreaterThan(0);
// queryAllBy to assert no elements
const errors = screen.queryAllByRole('alert');
expect(errors).toHaveLength(0);---
Rule: Prefer getBy Over queryBy for Better Errors
Principle: getBy throws immediately with helpful suggestions. queryBy returns null with no hints.
**❌ Incorrect: queryBy + expect for presence checks
// ❌ Unhelpful error: "Expected null to be in document"
const button = screen.queryByRole('button', { name: /submitt/i }); // Typo
expect(button).toBeInTheDocument();
// ❌ Extra boilerplate with queryBy
const heading = screen.queryByRole('heading');
expect(heading).toBeInTheDocument();
expect(heading).toHaveTextContent('Welcome');Problems:
- Error message shows "null" without context
- No suggestions about available roles or similar text
- Extra line of code for assertion
**✅ Correct: getBy for presence, queryBy only for absence
// ✅ Helpful error: "Unable to find button with name /submitt/i
// Did you mean 'submit'? Here are the available roles..."
const button = screen.getByRole('button', { name: /submitt/i });
// ✅ Concise and clear
const heading = screen.getByRole('heading');
expect(heading).toHaveTextContent('Welcome');
// ✅ queryBy only when asserting absence
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.queryByText(/error/i)).toBeNull();When to use each:
- getBy: Element should exist now (90% of queries)
- queryBy: Only when asserting element does NOT exist
- findBy: Element will appear asynchronously
---
Rule: Use screen.debug() When Queries Fail
Principle: When you can't figure out the right query, let Testing Library show you what's available.
**❌ Incorrect: Guessing at queries
// ❌ Trying random queries until one works
screen.getByTestId('submit');
screen.getByClassName('submit-button');
screen.getByRole('submit');
container.querySelector('.btn-primary');Problems:
- Wastes time guessing
- Might use suboptimal query that works by accident
- Misses accessibility issues
**✅ Correct: Debug to see available queries
// ✅ See current DOM
screen.debug();
// Prints: <button>Submit form</button>
// ✅ Get interactive query builder
screen.logTestingPlaygroundURL();
// Opens: https://testing-playground.com with your DOM
// Click element → see suggested queries
// Then use the suggested query
const button = screen.getByRole('button', { name: /submit form/i });Benefits:
- See exactly what's rendered
- Get query suggestions from Testing Playground
- Learn which queries are most accessible
---
Decision Matrix
Use this to select the right query variant:
Is element present synchronously?
├─ YES → Use getBy* (throws if missing)
│
└─ NO → Is it loaded asynchronously?
├─ YES → Use findBy* (waits for element)
│
└─ NO (checking absence) → Use queryBy* (returns null)
Does element disappear after action?
└─ Use waitForElementToBeRemoved---
Common Patterns
Initial render check:
render(<Component />);
const heading = screen.getByRole('heading'); // SynchronousAfter async data load:
render(<UserProfile id={123} />);
const name = await screen.findByText('John Doe'); // AsyncConditional rendering:
render(<Alert show={false} />);
expect(screen.queryByRole('alert')).toBeNull(); // AbsenceElement removal:
await userEvent.click(dismissButton);
await waitForElementToBeRemoved(screen.getByRole('alert')); // Disappearance---
Key Takeaways
- getBy* = "element should be here now"
- findBy* = "element will appear after async operation"
- queryBy* = "element might not be here"
- waitForElementToBeRemoved = "element should disappear"
User Events
Using @testing-library/user-event over fireEvent simulates realistic user interactions with proper event sequences, timing, and browser behavior.
Core Principle
fireEvent dispatches single DOM events. userEvent simulates complete user interactions.
Real users trigger sequences of events (focus → mousedown → mouseup → click). Testing with fireEvent misses bugs that occur in the event sequence.
---
Rule: Use userEvent for All User Interactions
Principle: userEvent simulates how real users interact with your application.
**❌ Incorrect: fireEvent for user interactions
import { fireEvent, screen } from '@testing-library/react';
// Single click event, missing focus/mousedown/mouseup
fireEvent.click(screen.getByRole('button'));
// Direct input change without typing simulation
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'test' } });
// Hover without proper mouse events
fireEvent.mouseOver(screen.getByRole('tooltip'));Problems:
- Missing event sequences users actually trigger
- No timing delays between events
- Keyboard navigation not simulated properly
- Pointer events not in correct order
**✅ Correct: userEvent for realistic simulation
import userEvent from '@testing-library/user-event';
// Full click sequence: focus → mousedown → mouseup → click
await userEvent.click(screen.getByRole('button'));
// Realistic typing with per-character events
await userEvent.type(screen.getByRole('textbox'), 'test');
// Proper hover simulation
await userEvent.hover(screen.getByRole('button'));Benefits:
- Catches bugs in event handlers that fireEvent misses
- Simulates timing between events
- Handles focus management automatically
- Tests keyboard and pointer interactions correctly
---
Rule: Always Await userEvent Calls
Principle: userEvent methods are async and return promises. Forgetting await causes race conditions.
**❌ Incorrect: Not awaiting userEvent
// No await - test continues before interaction completes
userEvent.click(button); // ❌ Promise ignored
expect(screen.getByText('Clicked')).toBeInTheDocument(); // ❌ Might not be rendered yet
// Interaction happens after assertion
userEvent.type(input, 'text'); // ❌ No await
expect(input).toHaveValue('text'); // ❌ Input still emptyProblems:
- Assertions run before interaction completes
- State updates happen after test finishes
- Causes "act" warnings
- Creates flaky tests
**✅ Correct: Always await userEvent
// Wait for click to complete
await userEvent.click(button);
expect(screen.getByText('Clicked')).toBeInTheDocument();
// Wait for typing to finish
await userEvent.type(input, 'text');
expect(input).toHaveValue('text');
// Chain interactions with await
await userEvent.click(button1);
await userEvent.click(button2);
expect(screen.getByText('Both clicked')).toBeInTheDocument();Benefits:
- Interactions complete before assertions
- No race conditions
- No act warnings
- Predictable test execution
---
Rule: Use setup() for Test Isolation
Principle: Call userEvent.setup() per test for proper isolation and realistic delays. Always call setup() at the beginning of the test block, before render().
**❌ Incorrect: Importing userEvent directly
// Default import skips setup
import userEvent from '@testing-library/user-event';
test('button click', async () => {
render(<Button />);
await userEvent.click(screen.getByRole('button')); // Works but not isolated
});**❌ Incorrect: Calling setup() after render
test('button click', async () => {
render(<Button />); // ❌ render before setup
const user = userEvent.setup(); // ❌ too late
await user.click(screen.getByRole('button'));
});Problems:
- State might leak between tests
- Can't configure delay or other options
- Less explicit about test setup
- Setup after render may miss initialization events
**✅ Correct: setup() before render in each test
import userEvent from '@testing-library/user-event';
test('button click', async () => {
const user = userEvent.setup();
render(<Button />);
await user.click(screen.getByRole('button'));
expect(screen.getByText('Clicked')).toBeInTheDocument();
});
test('with custom delay', async () => {
const user = userEvent.setup({ delay: 100 }); // 100ms between keystrokes
render(<Input />);
await user.type(screen.getByRole('textbox'), 'slow typing');
});Benefits:
- Explicit test setup with proper ordering
- Configurable delays and options
- Better test isolation
- Can mock clipboard, pointer lock, etc.
- Ensures all user interactions are properly tracked from component mount
---
Common Interactions
Click
const user = userEvent.setup();
// Single click
await user.click(screen.getByRole('button'));
// Double click
await user.dblClick(screen.getByRole('button'));
// Right click
await user.pointer({ keys: '[MouseRight]', target: element });Type and Keyboard
const user = userEvent.setup();
const input = screen.getByRole('textbox');
// Type text (triggers focus, keydown, keypress, input, keyup)
await user.type(input, 'Hello world');
// Clear input
await user.clear(input);
// Type with special keys
await user.type(input, 'Test{Enter}'); // Press Enter
await user.type(input, '{Shift}hello{/Shift}'); // HELLO
// Keyboard shortcuts
await user.keyboard('{Control>}a{/Control}'); // Ctrl+A
await user.keyboard('{Alt>}{Shift>}k{/Shift}{/Alt}'); // Alt+Shift+KSelect/Dropdown
const user = userEvent.setup();
// Select by label text
await user.selectOptions(
screen.getByRole('combobox'),
'Option Label'
);
// Select by value
await user.selectOptions(
screen.getByRole('combobox'),
'option-value'
);
// Multi-select
await user.selectOptions(
screen.getByRole('listbox'),
['Option 1', 'Option 2']
);Checkbox/Radio
const user = userEvent.setup();
// Check checkbox
await user.click(screen.getByRole('checkbox'));
// Or more explicit
const checkbox = screen.getByRole('checkbox');
if (!checkbox.checked) {
await user.click(checkbox);
}
// Radio button
await user.click(screen.getByRole('radio', { name: /option a/i }));Upload File
const user = userEvent.setup();
const file = new File(['content'], 'test.txt', { type: 'text/plain' });
const input = screen.getByLabelText(/upload file/i);
await user.upload(input, file);
// Multiple files
await user.upload(input, [file1, file2]);Hover and Unhover
const user = userEvent.setup();
// Hover over element
await user.hover(screen.getByRole('button'));
expect(screen.getByRole('tooltip')).toBeInTheDocument();
// Unhover
await user.unhover(screen.getByRole('button'));
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();Copy/Paste
const user = userEvent.setup();
// Copy
await user.copy();
// Paste
await user.paste('pasted text');
// Cut
await user.cut();Tab Navigation
const user = userEvent.setup();
// Tab to next focusable element
await user.tab();
// Shift+Tab to previous
await user.tab({ shift: true });
// Tab multiple times
await user.tab();
await user.tab();
await user.tab();---
When fireEvent Is Acceptable
fireEvent is acceptable only for events that have no user interaction equivalent:
✅ Acceptable fireEvent usage
// Scroll events (no userEvent.scroll yet)
fireEvent.scroll(window, { target: { scrollY: 100 } });
// Window resize
fireEvent.resize(window, { innerWidth: 500 });
// Focus/blur when testing programmatic focus
fireEvent.focus(element);
fireEvent.blur(element);
// Animation/transition end events
fireEvent.animationEnd(element);
fireEvent.transitionEnd(element);But even for these, check if userEvent has added support in newer versions.
---
Common Patterns
Form submission
test('submits form', async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<Form onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/username/i), 'john');
await user.type(screen.getByLabelText(/password/i), 'secret123');
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(handleSubmit).toHaveBeenCalledWith({
username: 'john',
password: 'secret123'
});
});Keyboard navigation
test('navigates with keyboard', async () => {
const user = userEvent.setup();
render(<Menu />);
const menu = screen.getByRole('menu');
menu.focus();
// Arrow down to first item
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('menuitem', { name: /first/i })).toHaveFocus();
// Arrow down to second item
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('menuitem', { name: /second/i })).toHaveFocus();
// Enter to select
await user.keyboard('{Enter}');
expect(screen.getByText(/selected: second/i)).toBeInTheDocument();
});Async state updates
test('shows loading then success', async () => {
const user = userEvent.setup();
render(<AsyncButton />);
await user.click(screen.getByRole('button', { name: /save/i }));
// Loading state
expect(screen.getByText(/saving/i)).toBeInTheDocument();
// Wait for success
expect(await screen.findByText(/saved/i)).toBeInTheDocument();
});---
Key Differences: userEvent vs fireEvent
| Feature | userEvent | fireEvent |
|---|---|---|
| Event sequence | Complete (focus → mousedown → mouseup → click) | Single event |
| Async | Yes (returns Promise) | No (synchronous) |
| Timing delays | Simulates real timing | Instant |
| Focus management | Automatic | Manual |
| Keyboard input | Character-by-character with delays | Direct value change |
| Browser behavior | Simulates accurately | Basic event dispatch |
| Use case | All user interactions | Non-user events only |
---
Key Takeaways
- Always use userEvent for user interactions (clicks, typing, selecting)
- Always await userEvent calls - they return promises
- Call userEvent.setup() at the beginning of each test, before render() - ensures proper isolation and trackingises
- Use userEvent.setup() at the start of each test
- fireEvent only for non-user events (scroll, resize, animation end)
- userEvent catches more bugs by simulating complete interaction sequences
#!/usr/bin/env bash
# Check for suboptimal query patterns in React Testing Library tests
# Detects usage of getByTestId before trying accessible queries
set -Eeuo pipefail
# Colors for output
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo "🔍 Checking for suboptimal query patterns in tests..."
echo
# Find test files
mapfile -t test_files < <(find . -type f \( -name "*.test.tsx" -o -name "*.test.ts" -o -name "*.spec.tsx" -o -name "*.spec.ts" \) ! -path "*/node_modules/*" ! -path "*/dist/*" ! -path "*/build/*" || true)
issues_found=0
# Check for getByTestId usage
for file in "${test_files[@]}"; do
if grep -n "getByTestId\|queryByTestId\|findByTestId\|getAllByTestId" "$file" > /dev/null 2>&1; then
echo -e "${YELLOW}⚠️ $file${NC}"
grep -n "getByTestId\|queryByTestId\|findByTestId\|getAllByTestId" "$file" | while read -r line; do
echo " $line"
done
echo " 💡 Consider: Can this use getByRole, getByLabelText, or getByText instead?"
echo
issues_found=$((issues_found + 1))
fi
done
# Check for container.querySelector usage
for file in "${test_files[@]}"; do
if grep -n "container\.querySelector\|container\.querySelectorAll" "$file" > /dev/null 2>&1; then
echo -e "${RED}❌ $file${NC}"
grep -n "container\.querySelector\|container\.querySelectorAll" "$file" | while read -r line; do
echo " $line"
done
echo " ❌ querySelector tests implementation details, not user experience"
echo " 💡 Use screen.getByRole or other accessible queries"
echo
issues_found=$((issues_found + 1))
fi
done
# Check for destructured queries (potential stale queries)
for file in "${test_files[@]}"; do
if grep -n "const { getBy.*} = render" "$file" > /dev/null 2>&1; then
echo -e "${YELLOW}⚠️ $file${NC}"
grep -n "const { getBy.*} = render" "$file" | while read -r line; do
echo " $line"
done
echo " 💡 Consider: Use screen.getBy* instead to avoid stale queries"
echo
issues_found=$((issues_found + 1))
fi
done
if [ $issues_found -eq 0 ]; then
echo "✅ No query priority issues found!"
exit 0
else
echo "Found $issues_found files with potential query improvements"
echo
echo "Query priority recommendation:"
echo " 1. getByRole ← Most accessible"
echo " 2. getByLabelText ← For form fields"
echo " 3. getByText ← For static content"
echo " 4. getByTestId ← Last resort"
exit 1
fi
#!/usr/bin/env bash
# Detect deprecated wrapper/container query patterns
# Tests should use screen queries instead
set -Eeuo pipefail
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo "🔍 Detecting deprecated wrapper and container query patterns..."
echo
mapfile -t test_files < <(find . -type f \( -name "*.test.tsx" -o -name "*.test.ts" -o -name "*.spec.tsx" -o -name "*.spec.ts" \) ! -path "*/node_modules/*" ! -path "*/dist/*" ! -path "*/build/*" || true)
issues_found=0
# Check for wrapper usage
for file in "${test_files[@]}"; do
if grep -n "const { wrapper" "$file" > /dev/null 2>&1; then
if [ $issues_found -eq 0 ]; then
echo -e "${RED}❌ Found deprecated wrapper usage:${NC}"
echo
fi
echo -e "${YELLOW}$file${NC}"
grep -n "const { wrapper" "$file" | while read -r line; do
echo " $line"
done
echo " ❌ wrapper is deprecated"
echo " 💡 Use screen.getBy* or within() for scoped queries"
echo
issues_found=$((issues_found + 1))
fi
done
# Check for wrapper.getBy* calls
for file in "${test_files[@]}"; do
if grep -n "wrapper\.getBy\|wrapper\.findBy\|wrapper\.queryBy" "$file" > /dev/null 2>&1; then
echo -e "${YELLOW}$file${NC}"
grep -n "wrapper\.getBy\|wrapper\.findBy\|wrapper\.queryBy" "$file" | while read -r line; do
echo " $line"
done
echo " ❌ wrapper queries are deprecated"
echo " 💡 Replace with screen.getBy* or within()"
echo
issues_found=$((issues_found + 1))
fi
done
# Check for container.firstChild
for file in "${test_files[@]}"; do
if grep -n "container\.firstChild\|container\.children" "$file" > /dev/null 2>&1; then
echo -e "${YELLOW}$file${NC}"
grep -n "container\.firstChild\|container\.children" "$file" | while read -r line; do
echo " $line"
done
echo " ❌ Testing DOM structure directly"
echo " 💡 Use semantic queries that match user behavior"
echo
issues_found=$((issues_found + 1))
fi
done
# Check for rerender usage (potential anti-pattern)
for file in "${test_files[@]}"; do
if grep -n "const { rerender" "$file" > /dev/null 2>&1; then
echo -e "${YELLOW}⚠️ $file${NC}"
grep -n "const { rerender" "$file" | while read -r line; do
echo " $line"
done
echo " ⚠️ Using rerender - consider testing through user interactions instead"
echo " 💡 Trigger state changes via userEvent.click(), etc."
echo
issues_found=$((issues_found + 1))
fi
done
if [ $issues_found -eq 0 ]; then
echo "✅ No deprecated patterns found!"
exit 0
else
echo "Found $issues_found files with deprecated patterns"
echo
echo "Recommended changes:"
echo " • Replace wrapper.getBy* with screen.getBy*"
echo " • Replace container.querySelector with screen.getByRole"
echo " • Replace rerender() with user interactions"
echo " • Use within() for scoped queries within a region"
exit 1
fi
#!/usr/bin/env bash
# Detect fireEvent usage that should be userEvent
# fireEvent should only be used for non-user events (scroll, resize, etc.)
set -Eeuo pipefail
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
NC='\033[0m'
echo "🔍 Detecting fireEvent usage in tests..."
echo
mapfile -t test_files < <(find . -type f \( -name "*.test.tsx" -o -name "*.test.ts" -o -name "*.spec.tsx" -o -name "*.spec.ts" \) ! -path "*/node_modules/*" ! -path "*/dist/*" ! -path "*/build/*" || true)
issues_found=0
# User interaction events that should use userEvent
user_events=(
"click"
"dblClick"
"change"
"input"
"keyDown"
"keyUp"
"keyPress"
"mouseOver"
"mouseEnter"
"mouseLeave"
"mouseDown"
"mouseUp"
"focus"
"blur"
"submit"
"paste"
"copy"
)
# Non-user events where fireEvent is acceptable
acceptable_events=(
"scroll"
"resize"
"animationEnd"
"transitionEnd"
"load"
"error"
)
for file in "${test_files[@]}"; do
for event in "${user_events[@]}"; do
if grep -n "fireEvent\.$event\|fireEvent\.(['\"]$event" "$file" > /dev/null 2>&1; then
if [ $issues_found -eq 0 ]; then
echo -e "${RED}❌ Found fireEvent usage for user interactions:${NC}"
echo
fi
echo -e "${YELLOW}$file${NC}"
grep -n "fireEvent\.$event" "$file" | while read -r line; do
echo " $line"
done
echo " ❌ Use userEvent.$event() instead of fireEvent.$event()"
echo " 💡 userEvent simulates complete interaction sequences"
echo
issues_found=$((issues_found + 1))
fi
done
done
# Report acceptable fireEvent usage
acceptable_found=0
for file in "${test_files[@]}"; do
for event in "${acceptable_events[@]}"; do
if grep -n "fireEvent\.$event" "$file" > /dev/null 2>&1; then
if [ $acceptable_found -eq 0 ]; then
echo -e "${GREEN}✅ Acceptable fireEvent usage (non-user events):${NC}"
echo
acceptable_found=1
fi
echo -e "${GREEN}$file${NC}"
grep -n "fireEvent\.$event" "$file" | while read -r line; do
echo " $line"
done
echo
fi
done
done
if [ $issues_found -eq 0 ]; then
echo "✅ No problematic fireEvent usage found!"
exit 0
else
echo "Found $issues_found files using fireEvent for user interactions"
echo
echo "Migration guide:"
echo " 1. Import: import userEvent from '@testing-library/user-event';"
echo " 2. Setup: const user = userEvent.setup();"
echo " 3. Replace: await user.click(element);"
echo " 4. Remember: userEvent methods return Promises - always await!"
exit 1
fi