
React Testing Library
- 41 installs
- 31 repo stars
- Updated August 2, 2026
- shipshitdev/library
Helps with testing & qa tasks.
About
react-testing-library is a Claude Code skill for testing & qa. It helps solo builders move faster with AI-assisted development.
- react-testing-library
- Testing & QA
- AI-coding skill
React Testing Library by the numbers
- 41 all-time installs (skills.sh)
- +3 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #1,274 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/shipshitdev/library --skill react-testing-libraryAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 41 |
|---|---|
| repo stars | ★ 31 |
| Last updated | August 2, 2026 |
| Repository | shipshitdev/library ↗ |
What it does
Helps with testing & qa tasks.
Files
React Testing Library Best Practices
Comprehensive testing guide for React components using Testing Library, designed for AI agents and LLMs. Contains 43 rules across 9 categories, prioritized by impact to guide test writing and code review.
When to Apply
Reference these guidelines when:
- Writing new component tests with React Testing Library
- Selecting queries (getByRole, getByLabelText, etc.)
- Handling async operations in tests (findBy, waitFor)
- Simulating user interactions (userEvent)
- Reviewing tests for anti-patterns and implementation detail testing
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Query Selection | CRITICAL | query- |
| 2 | Async Handling | CRITICAL | async- |
| 3 | Common Anti-Patterns | CRITICAL | anti- |
| 4 | User Interaction | HIGH | user- |
| 5 | Assertions | HIGH | assert- |
| 6 | Component Setup | MEDIUM | setup- |
| 7 | Test Structure | MEDIUM | struct- |
| 8 | Debugging | LOW-MEDIUM | debug- |
| 9 | Accessibility Testing | LOW | a11y- |
Quick Reference
1. Query Selection (CRITICAL)
- `query-prefer-role` - Prefer getByRole over other queries
- `query-avoid-testid` - Avoid getByTestId as primary query
- `query-use-screen` - Use screen for queries
- `query-label-text-forms` - Use getByLabelText for form fields
- `query-role-name-option` - Use name option with getByRole
- `query-get-vs-query` - Use getBy for present, queryBy for absent
- `query-within-scope` - Use within() to scope queries
2. Async Handling (CRITICAL)
- `async-findby-over-waitfor` - Use findBy instead of waitFor + getBy
- `async-await-findby` - Always await findBy queries
- `async-single-assertion-waitfor` - Single assertion in waitFor
- `async-no-side-effects-waitfor` - Avoid side effects in waitFor
- `async-waitfor-disappear` - Use waitForElementToBeRemoved
3. Common Anti-Patterns (CRITICAL)
- `anti-unnecessary-act` - Avoid unnecessary act() wrapping
- `anti-manual-cleanup` - Remove manual cleanup calls
- `anti-implementation-details` - Avoid testing implementation details
- `anti-empty-waitfor` - Avoid empty waitFor callbacks
- `anti-container-queries` - Avoid using container for queries
- `anti-redundant-roles` - Avoid adding redundant ARIA roles
4. User Interaction (HIGH)
- `user-prefer-userevent` - Use userEvent over fireEvent
- `user-setup-before-render` - Setup userEvent before render
- `user-await-interactions` - Always await userEvent interactions
- `user-keyboard-for-special-keys` - Use keyboard() for special keys
- `user-clear-before-type` - Use clear() before retyping
5. Assertions (HIGH)
- `assert-jest-dom-matchers` - Use jest-dom matchers
- `assert-visible-over-in-document` - Use toBeVisible() for visibility
- `assert-text-content` - Use toHaveTextContent() for text
- `assert-have-value` - Use toHaveValue() for inputs
- `assert-accessible-description` - Use toHaveAccessibleDescription()
6. Component Setup (MEDIUM)
- `setup-wrapper-providers` - Use wrapper option for providers
- `setup-custom-render` - Create custom render with providers
- `setup-mock-modules` - Mock modules at module level
- `setup-fake-timers` - Configure userEvent with fake timers
- `setup-render-hook` - Use renderHook for testing hooks
7. Test Structure (MEDIUM)
- `struct-arrange-act-assert` - Follow Arrange-Act-Assert pattern
- `struct-one-behavior-per-test` - Test one behavior per test
- `struct-descriptive-names` - Use descriptive test names
- `struct-avoid-beforeeach-render` - Avoid render() in beforeEach
8. Debugging (LOW-MEDIUM)
- `debug-screen-debug` - Use screen.debug() to inspect DOM
- `debug-logroles` - Use logRoles to find available roles
- `debug-testing-playground` - Use Testing Playground for queries
9. Accessibility Testing (LOW)
- `a11y-role-queries-verify` - Role queries verify accessibility
- `a11y-verify-focus` - Test focus management
- `a11y-test-aria-states` - Test ARIA states and properties
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
Rule Title Here
Brief explanation (1-3 sentences) of WHY this matters. Focus on testing implications and user confidence.
Incorrect (what's wrong):
// Bad code example - production-realistic, not strawman
// Comment explaining the problem/costCorrect (what's right):
// Good code example - minimal diff from incorrect
// Comment explaining the benefitAlternative (context):
// Alternative approach when applicableWhen NOT to use this pattern:
- Exception 1
- Exception 2
Benefits:
- Benefit 1
- Benefit 2
Reference: Reference Title
{
"name": "react-testing-library",
"version": "1.0.0",
"description": "React Testing Library best practices for writing maintainable, user-centric tests. Use when writing,",
"author": {
"name": "Ship Shit Dev",
"email": "hello@shipshit.dev",
"url": "https://shipshit.dev"
},
"license": "MIT",
"skills": "."
}
React Testing Library Best Practices
A comprehensive skill for writing maintainable, user-centric tests with React Testing Library.
Overview
This skill contains 43 rules across 9 categories, prioritized by impact to guide test writing and code review. It covers query selection, async handling, user interactions, assertions, and common anti-patterns.
Structure
react-testing-library/
├── SKILL.md # Entry point with quick reference
├── metadata.json # Version, org, references
├── README.md # This file
├── references/
│ ├── _sections.md # Category definitions
│ ├── query-*.md # Query selection rules (CRITICAL)
│ ├── async-*.md # Async handling rules (CRITICAL)
│ ├── anti-*.md # Anti-pattern rules (CRITICAL)
│ ├── user-*.md # User interaction rules (HIGH)
│ ├── assert-*.md # Assertion rules (HIGH)
│ ├── setup-*.md # Component setup rules (MEDIUM)
│ ├── struct-*.md # Test structure rules (MEDIUM)
│ ├── debug-*.md # Debugging rules (LOW-MEDIUM)
│ └── a11y-*.md # Accessibility rules (LOW)
└── assets/
└── templates/
└── _template.md # Template for new rulesGetting Started
# Install dependencies (if in a project with validation scripts)
pnpm install
# Build compiled documents
pnpm build
# Validate skill structure
pnpm validateCreating a New Rule
1. Choose the appropriate category prefix based on the rule's focus area 2. Create a new file in references/ with the naming pattern {prefix}-{description}.md 3. Copy the template from assets/templates/_template.md 4. Fill in all required sections
Prefix Reference
| Prefix | Category | Impact |
|---|---|---|
query- | Query Selection | CRITICAL |
async- | Async Handling | CRITICAL |
anti- | Common Anti-Patterns | CRITICAL |
user- | User Interaction | HIGH |
assert- | Assertions | HIGH |
setup- | Component Setup | MEDIUM |
struct- | Test Structure | MEDIUM |
debug- | Debugging | LOW-MEDIUM |
a11y- | Accessibility Testing | LOW |
Rule File Structure
Each rule file must include:
---
title: Rule Title Here
impact: CRITICAL|HIGH|MEDIUM|LOW-MEDIUM|LOW
impactDescription: Quantified impact (e.g., "2-10× improvement")
tags: category-prefix, technique, tool, concept
---
## Rule Title Here
Brief explanation of WHY this matters.
**Incorrect (what's wrong):**
// Bad code example
**Correct (what's right):**
// Good code example
Reference: [Source](URL)
File Naming Convention
Rule files follow the pattern: {prefix}-{description}.md
- prefix: 3-8 character category identifier (e.g.,
query,async,anti) - description: kebab-case description of the rule (e.g.,
prefer-role,await-findby)
Examples:
query-prefer-role.md- Query selection rule about preferring getByRoleasync-await-findby.md- Async handling rule about awaiting findBy queriesanti-container-queries.md- Anti-pattern rule about avoiding container queries
Impact Levels
| Level | Description |
|---|---|
| CRITICAL | Fundamental issues that cause test failures, false positives, or major maintenance burden |
| HIGH | Important patterns that significantly improve test quality and reliability |
| MEDIUM | Good practices that improve maintainability and readability |
| LOW-MEDIUM | Helpful patterns for specific situations |
| LOW | Nice-to-have improvements and advanced techniques |
Scripts
| Command | Description |
|---|---|
pnpm build | Generate AGENTS.md compiled document |
pnpm validate | Check skill structure against guidelines |
pnpm validate --strict | Fail on warnings as well as errors |
Contributing
1. Read existing rules to understand the expected format and quality 2. Follow the template structure exactly 3. Include realistic code examples (avoid foo, bar, generic names) 4. Quantify impact where possible (e.g., "2-10× improvement", "prevents X") 5. Run validation before submitting
Acknowledgments
- Testing Library - Official documentation
- Kent C. Dodds - Creator guidance and best practices
- jest-dom - Custom matchers
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Query Selection (query)
Impact: CRITICAL Description: Choosing the right query determines test reliability. Wrong queries test implementation details instead of user-visible behavior.
2. Async Handling (async)
Impact: CRITICAL Description: Improper async handling causes flaky tests, race conditions, and false positives. Always await async operations correctly.
3. Common Anti-Patterns (anti)
Impact: CRITICAL Description: Avoiding the most common mistakes that break tests or give false confidence. These patterns actively harm test quality.
4. User Interaction (user)
Impact: HIGH Description: Using userEvent over fireEvent ensures realistic browser behavior and accessibility checks during interaction simulation.
5. Assertions (assert)
Impact: HIGH Description: Using the right assertions with jest-dom matchers provides clearer error messages and tests the correct properties.
6. Component Setup (setup)
Impact: MEDIUM Description: Efficient setup patterns reduce test coupling, improve speed, and make tests more maintainable.
7. Test Structure (struct)
Impact: MEDIUM Description: Well-organized tests are easier to understand, maintain, and debug when failures occur.
8. Debugging (debug)
Impact: LOW-MEDIUM Description: Effective debugging tools speed up test development and help diagnose failures quickly.
9. Accessibility Testing (a11y)
Impact: LOW Description: Leveraging RTL's accessibility-first design to verify components meet accessibility requirements.
Role Queries Double as Accessibility Tests
Using getByRole queries inherently verifies accessibility. If an element lacks proper roles or labels, the query fails—catching accessibility bugs.
Query failure reveals accessibility issue:
// Component missing accessible name
render(<button><Icon name="close" /></button>)
screen.getByRole('button', { name: /close/i })
// Error: Unable to find an accessible element with the role "button"
// and name "/close/i"
// Fix: Add aria-label
render(<button aria-label="Close"><Icon name="close" /></button>)
screen.getByRole('button', { name: /close/i })
// Passes - button is now accessibleCommon accessibility issues caught by role queries:
// Missing label association
render(<input type="text" />)
screen.getByRole('textbox', { name: /email/i })
// Fails - no accessible name
// Fix with label
render(
<>
<label htmlFor="email">Email</label>
<input id="email" type="text" />
</>
)
screen.getByRole('textbox', { name: /email/i })
// PassesTesting that elements are accessible:
// If this query works, the element is accessible
const submitButton = screen.getByRole('button', { name: /submit/i })
const emailInput = screen.getByLabelText('Email')
const dialog = screen.getByRole('dialog', { name: /confirm/i })Reference: Testing Library Guiding Principles
Test ARIA States and Properties
Use jest-dom matchers to verify ARIA states like checked, expanded, selected, and pressed update correctly.
Test checkbox state:
test('toggles checkbox state', async () => {
const user = userEvent.setup()
render(<Checkbox label="Remember me" />)
const checkbox = screen.getByRole('checkbox', { name: /remember me/i })
expect(checkbox).not.toBeChecked()
await user.click(checkbox)
expect(checkbox).toBeChecked()
})Test expanded state:
test('toggles accordion panel', async () => {
const user = userEvent.setup()
render(<Accordion title="Details" />)
const button = screen.getByRole('button', { name: /details/i })
expect(button).toHaveAttribute('aria-expanded', 'false')
await user.click(button)
expect(button).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByRole('region')).toBeVisible()
})Test selected state (tabs):
test('switches selected tab', async () => {
const user = userEvent.setup()
render(<Tabs tabs={['Profile', 'Settings']} />)
expect(screen.getByRole('tab', { name: /profile/i })).toHaveAttribute(
'aria-selected', 'true'
)
await user.click(screen.getByRole('tab', { name: /settings/i }))
expect(screen.getByRole('tab', { name: /settings/i })).toHaveAttribute(
'aria-selected', 'true'
)
expect(screen.getByRole('tab', { name: /profile/i })).toHaveAttribute(
'aria-selected', 'false'
)
})Reference: WAI-ARIA States and Properties
Test Focus Management
Verify focus moves correctly during interactions. Proper focus management is essential for keyboard users.
Test focus on modal open:
test('focuses first input when modal opens', async () => {
const user = userEvent.setup()
render(<ModalTrigger />)
await user.click(screen.getByRole('button', { name: /open form/i }))
expect(screen.getByLabelText('Name')).toHaveFocus()
})Test focus trap in dialogs:
test('traps focus within dialog', async () => {
const user = userEvent.setup()
render(<Dialog open>
<input aria-label="First" />
<input aria-label="Last" />
<button>Submit</button>
</Dialog>)
// Focus should cycle within dialog
await user.tab() // First input
await user.tab() // Last input
await user.tab() // Submit button
await user.tab() // Back to first input
expect(screen.getByLabelText('First')).toHaveFocus()
})Test focus return on close:
test('returns focus to trigger when modal closes', async () => {
const user = userEvent.setup()
render(<ModalWithTrigger />)
const trigger = screen.getByRole('button', { name: /open/i })
await user.click(trigger)
await user.keyboard('{Escape}')
expect(trigger).toHaveFocus()
})Reference: WAI-ARIA - Modal Dialog
Avoid Using container for Queries
Using container.querySelector bypasses Testing Library's user-centric queries. Tests become brittle and tied to specific DOM structure.
Incorrect (DOM structure dependency):
const { container } = render(<UserCard user={user} />)
const avatar = container.querySelector('.user-card__avatar img')
const name = container.querySelector('[data-testid="name"]')
// Tied to CSS classes and DOM structureCorrect (user-centric queries):
render(<UserCard user={user} />)
const avatar = screen.getByRole('img', { name: /avatar/i })
const name = screen.getByRole('heading', { name: /john doe/i })
// Tests what users actually seeWhen container access IS acceptable:
- Snapshot testing with
asFragment() - Testing CSS-in-JS styles (rarely needed)
- Integration with non-React DOM libraries
// Snapshot testing
const { asFragment } = render(<Icon name="check" />)
expect(asFragment()).toMatchSnapshot()Reference: Common Mistakes - Using container to Query
Avoid Empty waitFor Callbacks
Passing an empty callback or no callback to waitFor is meaningless. The test passes without waiting for or verifying anything.
Incorrect (empty callback):
render(<AsyncForm />)
await userEvent.click(screen.getByRole('button', { name: /submit/i }))
await waitFor(() => {})
// Waits for nothing, passes immediately
expect(screen.getByText('Submitted!')).toBeInTheDocument()
// May fail if async operation hasn't completedCorrect (wait for specific condition):
render(<AsyncForm />)
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: /submit/i }))
expect(await screen.findByText('Submitted!')).toBeInTheDocument()
// Properly waits for text to appearAlternative - wait for mock to be called:
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: /submit/i }))
await waitFor(() => {
expect(mockSubmit).toHaveBeenCalled()
})
// Waits for actual conditionReference: Common Mistakes - Passing Empty Callback to waitFor
Avoid Testing Implementation Details
Test what users experience, not how it's implemented internally. Implementation detail tests break during refactoring even when behavior is unchanged.
Incorrect (tests internal state and methods):
import { render } from '@testing-library/react'
test('counter increments', () => {
const { container } = render(<Counter />)
const instance = container.firstChild._reactInternals
expect(instance.memoizedState).toBe(0)
instance.memoizedProps.onClick()
expect(instance.memoizedState).toBe(1)
})
// Tests React internals, not user behaviorCorrect (tests user-visible behavior):
test('counter increments', async () => {
render(<Counter />)
const user = userEvent.setup()
expect(screen.getByText('Count: 0')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /increment/i }))
expect(screen.getByText('Count: 1')).toBeInTheDocument()
})
// Tests what user sees and doesSigns you're testing implementation details:
- Accessing component instance or internal state
- Testing specific hook calls or internal methods
- Using container.querySelector with implementation-specific selectors
- Tests break when refactoring without behavior change
Reference: Testing Implementation Details
Remove Manual cleanup Calls
Testing Library automatically calls cleanup after each test when using modern test frameworks (Jest, Vitest, Mocha with globals). Manual calls are unnecessary.
Incorrect (manual cleanup):
import { render, cleanup } from '@testing-library/react'
afterEach(() => {
cleanup()
})
test('renders dashboard', () => {
render(<Dashboard />)
// ...
})
// cleanup() is called twice - once manually, once automaticallyCorrect (rely on automatic cleanup):
import { render } from '@testing-library/react'
test('renders dashboard', () => {
render(<Dashboard />)
// ...
})
// cleanup() runs automatically after testWhen manual cleanup IS needed:
- Using a test framework without global
afterEach(like AVA) - Custom test runners without RTL integration
// Only for frameworks without automatic cleanup
import { cleanup } from '@testing-library/react'
test.afterEach(cleanup)Reference: Testing Library - Cleanup
Avoid Adding Redundant ARIA Roles
Don't add role attributes that elements already have implicitly. This clutters the DOM and can confuse assistive technologies.
Incorrect (redundant roles):
render(
<nav role="navigation">
<button role="button" onClick={handleClick}>
Submit
</button>
<a href="/home" role="link">Home</a>
</nav>
)
screen.getByRole('button', { name: /submit/i })
// Works, but role="button" is redundantCorrect (semantic HTML):
render(
<nav>
<button onClick={handleClick}>Submit</button>
<a href="/home">Home</a>
</nav>
)
screen.getByRole('button', { name: /submit/i })
// Works with implicit role from semantic HTMLImplicit roles to remember:
| Element | Implicit Role |
|---|---|
<button> | button |
<a href> | link |
<nav> | navigation |
<main> | main |
<header> | banner |
<footer> | contentinfo |
<input type="checkbox"> | checkbox |
When explicit roles ARE needed:
<div>acting as a button (but prefer<button>)- Custom components without semantic elements
Reference: Common Mistakes - Adding aria- and role Attributes
Avoid Unnecessary act() Wrapping
render() and fireEvent are already wrapped in act(). Adding extra wrappers adds noise and can mask actual async issues in your component.
Incorrect (redundant act wrapping):
await act(async () => {
render(<UserProfile />)
})
await act(async () => {
fireEvent.click(screen.getByRole('button'))
})
// Unnecessary - these already use act internallyCorrect (let RTL handle act):
render(<UserProfile />)
const user = userEvent.setup()
await user.click(screen.getByRole('button'))
// RTL and userEvent handle act() automaticallyWhen act IS needed:
// Direct state updates outside RTL utilities
act(() => {
result.current.increment()
})
// Manual timer advancement
act(() => {
jest.advanceTimersByTime(1000)
})Note: If you see "not wrapped in act(...)" warnings, the solution is usually to await async operations, not add more act() calls.
Reference: Common Mistakes - Wrapping Things in act Unnecessarily
Use toHaveAccessibleDescription() for Hint Text
Use toHaveAccessibleDescription() to verify error messages, help text, and other descriptions are properly associated with form controls.
Incorrect (tests DOM structure, not accessibility):
render(
<div>
<input aria-describedby="hint" />
<span id="hint">Must be at least 8 characters</span>
</div>
)
expect(screen.getByText('Must be at least 8 characters')).toBeInTheDocument()
// Doesn't verify the association with inputCorrect (tests accessible description):
render(
<div>
<label htmlFor="password">Password</label>
<input id="password" aria-describedby="hint" />
<span id="hint">Must be at least 8 characters</span>
</div>
)
expect(screen.getByLabelText('Password')).toHaveAccessibleDescription(
'Must be at least 8 characters'
)
// Verifies screen readers will announce this descriptionFor error messages:
render(<Input label="Email" error="Invalid email format" />)
expect(screen.getByLabelText('Email')).toHaveAccessibleDescription(
'Invalid email format'
)
expect(screen.getByLabelText('Email')).toBeInvalid()Related matchers:
toHaveAccessibleName()- verifies accessible name (label)toHaveErrorMessage()- verifiesaria-errormessageassociation
Reference: jest-dom - toHaveAccessibleDescription
Use toHaveValue() for Form Input Assertions
Use toHaveValue() to assert form input values. It handles different input types correctly and provides clear error messages.
Incorrect (direct value access):
render(<input type="number" defaultValue={42} />)
const input = screen.getByRole('spinbutton')
expect(input.value).toBe(42)
// Fails! input.value is always a string ("42")Correct (jest-dom matcher):
render(<input type="number" defaultValue={42} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(42)
// Handles number conversion automaticallyWorks with various input types:
// Text input
expect(screen.getByLabelText('Name')).toHaveValue('John')
// Number input
expect(screen.getByLabelText('Age')).toHaveValue(25)
// Select
expect(screen.getByLabelText('Country')).toHaveValue('US')
// Textarea
expect(screen.getByLabelText('Bio')).toHaveValue('My bio...')
// Empty input
expect(screen.getByLabelText('Email')).toHaveValue('')For checkboxes/radio, use toBeChecked():
expect(screen.getByRole('checkbox')).toBeChecked()
expect(screen.getByRole('radio', { name: /yes/i })).toBeChecked()Reference: jest-dom - toHaveValue
Use jest-dom Matchers for DOM Assertions
Use @testing-library/jest-dom matchers instead of generic Jest matchers. They provide clearer error messages and test semantic properties.
Incorrect (generic Jest matchers):
render(<Button disabled>Submit</Button>)
const button = screen.getByRole('button')
expect(button.disabled).toBe(true)
expect(button.textContent).toBe('Submit')
expect(document.body.contains(button)).toBe(true)
// Unclear error messages, tests implementationCorrect (jest-dom matchers):
render(<Button disabled>Submit</Button>)
const button = screen.getByRole('button')
expect(button).toBeDisabled()
expect(button).toHaveTextContent('Submit')
expect(button).toBeInTheDocument()
// Clear error: "Expected element to be disabled but it was enabled"Common jest-dom matchers:
| Matcher | Use Case |
|---|---|
toBeInTheDocument() | Element exists in DOM |
toBeDisabled() | Form element is disabled |
toBeEnabled() | Form element is enabled |
toBeVisible() | Element is visible to user |
toHaveTextContent() | Element contains text |
toHaveValue() | Input has specific value |
toHaveClass() | Element has CSS class |
toHaveFocus() | Element has focus |
Reference: jest-dom Custom Matchers
Use toHaveTextContent() for Text Assertions
Use toHaveTextContent() instead of accessing textContent directly. It handles whitespace normalization and provides clear errors.
Incorrect (direct property access):
render(<Badge> Premium User </Badge>)
const badge = screen.getByRole('status')
expect(badge.textContent).toBe('Premium User')
// Fails due to whitespace differencesCorrect (jest-dom matcher):
render(<Badge> Premium User </Badge>)
const badge = screen.getByRole('status')
expect(badge).toHaveTextContent('Premium User')
// Normalizes whitespace automaticallySupports partial matching and regex:
// Partial match
expect(badge).toHaveTextContent('Premium')
// Regex match
expect(badge).toHaveTextContent(/premium/i)
// Exact match with option
expect(badge).toHaveTextContent('Premium User', { exact: true })For asserting empty text:
expect(emptyElement).toHaveTextContent('')
// or
expect(emptyElement).toBeEmptyDOMElement()Reference: jest-dom - toHaveTextContent
Use toBeVisible() for User-Perceivable Elements
toBeInTheDocument() only checks DOM presence. toBeVisible() verifies the element is actually perceivable by users (not hidden by CSS).
Incorrect (element in DOM but hidden):
render(
<div>
<span style={{ display: 'none' }}>Hidden message</span>
</div>
)
expect(screen.getByText('Hidden message')).toBeInTheDocument()
// Passes! But user can't see this elementCorrect (verify actual visibility):
render(
<div>
<span style={{ display: 'none' }}>Hidden message</span>
<span>Visible message</span>
</div>
)
expect(screen.getByText('Hidden message')).not.toBeVisible()
expect(screen.getByText('Visible message')).toBeVisible()
// Tests what user actually seesWhat toBeVisible() checks:
display: none(not visible)visibility: hidden(not visible)opacity: 0(not visible)- Element or ancestor hidden
hiddenattribute
When to use toBeInTheDocument():
- Checking if element was rendered at all
- Testing conditional rendering logic
Reference: jest-dom - toBeVisible
Always Await findBy Queries
findBy* queries return Promises. Forgetting to await them causes tests to pass before assertions run, leading to false positives.
Incorrect (missing await):
render(<AsyncComponent />)
const element = screen.findByRole('button')
expect(element).toBeInTheDocument()
// element is a Promise, not a DOM node!
// Test passes before element appearsCorrect (properly awaited):
render(<AsyncComponent />)
const element = await screen.findByRole('button')
expect(element).toBeInTheDocument()
// Waits for element, then assertsAlso applies to findAllBy:
const items = await screen.findAllByRole('listitem')
expect(items).toHaveLength(3)Tip: Configure ESLint to catch this:
{
"rules": {
"testing-library/await-async-queries": "error"
}
}Reference: Testing Library - Async Methods
Use findBy Instead of waitFor + getBy
findBy* queries are the preferred way to wait for elements. They combine waitFor with getBy in a single, readable call. Use waitFor only for non-element assertions.
Incorrect (verbose and error-prone):
render(<UserList />)
await waitFor(() => {
expect(screen.getByRole('listitem')).toBeInTheDocument()
})
// Verbose, and assertion inside waitFor is discouragedCorrect (cleaner async query):
render(<UserList />)
const listItem = await screen.findByRole('listitem')
expect(listItem).toBeInTheDocument()
// Single call handles waiting, clear error if not foundWhen to use waitFor:
- Waiting for elements to disappear
- Asserting non-DOM conditions (state, mock calls)
- Multiple conditions that must be true together
// Good use of waitFor - non-element assertion
await waitFor(() => {
expect(mockFn).toHaveBeenCalledTimes(2)
})Reference: Common Mistakes - Using waitFor to wait for elements
Avoid Side Effects in waitFor
Never perform user interactions or other side effects inside waitFor. The callback runs repeatedly until it passes, so side effects execute multiple times.
Incorrect (click runs multiple times):
render(<Counter />)
await waitFor(() => {
userEvent.click(screen.getByRole('button', { name: /increment/i }))
expect(screen.getByText('Count: 1')).toBeInTheDocument()
})
// Button clicked on every retry - count keeps incrementing!Correct (interact first, then wait):
render(<Counter />)
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: /increment/i }))
expect(await screen.findByText('Count: 1')).toBeInTheDocument()
// Click once, wait for resultPattern for multiple interactions:
const user = userEvent.setup()
await user.type(screen.getByLabelText('Name'), 'John')
await user.click(screen.getByRole('button', { name: /submit/i }))
expect(await screen.findByText('Saved!')).toBeInTheDocument()Reference: Common Mistakes - Performing Side Effects in waitFor
Single Assertion in waitFor
Include only one assertion per waitFor callback. Multiple assertions cause the entire callback to retry until all pass or timeout, hiding which specific assertion failed.
Incorrect (multiple assertions delay failure):
render(<UserProfile userId="123" />)
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument()
expect(screen.getByText('john@example.com')).toBeInTheDocument()
expect(screen.getByRole('img')).toHaveAttribute('src', '/avatar.png')
})
// If first assertion fails, waits full timeout before failingCorrect (separate async queries):
render(<UserProfile userId="123" />)
expect(await screen.findByText('John Doe')).toBeInTheDocument()
expect(await screen.findByText('john@example.com')).toBeInTheDocument()
expect(await screen.findByRole('img')).toHaveAttribute('src', '/avatar.png')
// Each query waits independently, fails fastWhen waitFor with single assertion is appropriate:
// Waiting for state change reflected in mock
await waitFor(() => {
expect(mockSave).toHaveBeenCalledWith({ name: 'John' })
})Use waitForElementToBeRemoved for Disappearing Elements
When waiting for elements to disappear (like loading spinners), use waitForElementToBeRemoved. It properly handles the element existing first, then being removed.
Incorrect (queryBy with waitFor):
render(<DataLoader />)
await waitFor(() => {
expect(screen.queryByText('Loading...')).not.toBeInTheDocument()
})
// Passes immediately if loading text never appearedCorrect (waitForElementToBeRemoved):
render(<DataLoader />)
await waitForElementToBeRemoved(() => screen.queryByText('Loading...'))
// Verifies element existed, then waits for removalAlternative with findBy then waitFor:
render(<DataLoader />)
// First confirm loading appears
const loadingText = await screen.findByText('Loading...')
// Then wait for it to disappear
await waitForElementToBeRemoved(loadingText)
expect(screen.getByText('Data loaded')).toBeInTheDocument()Tip: The callback form re-queries on each check, while passing the element directly uses that reference.
Reference: Testing Library - waitForElementToBeRemoved
Use logRoles to Find Available Roles
When unsure which role to use in getByRole, use logRoles() to see all available roles in the rendered output.
Incorrect (guessing roles):
test('finds navigation links', () => {
render(<Navigation />)
// Guessing roles without knowing what's available
screen.getByRole('nav') // Error: no element with role "nav"
screen.getByRole('menuitem') // Error: no element with role "menuitem"
// Trial and error wastes time
})Correct (discover roles first):
import { logRoles } from '@testing-library/dom'
test('finds navigation links', () => {
const { container } = render(<Navigation />)
logRoles(container)
// Console shows:
// navigation: <nav />
// link: <a href="/home" />, <a href="/about" />
// Now we know the correct roles
screen.getByRole('navigation')
screen.getAllByRole('link')
})Common implicit roles to remember:
<button>→ button<input type="text">→ textbox<input type="checkbox">→ checkbox<select>→ combobox<h1>-<h6>→ heading
Reference: Testing Library - logRoles
Use screen.debug() to Inspect DOM
When tests fail unexpectedly, use screen.debug() to see the current DOM state. This reveals what's actually rendered.
Incorrect (guessing why query fails):
test('shows user profile', async () => {
render(<UserProfile userId="123" />)
// Test fails: Unable to find element with text "John Doe"
expect(screen.getByText('John Doe')).toBeInTheDocument()
// Why? Is the text different? Is it loading? Is there an error?
})Correct (inspect DOM state):
test('shows user profile', async () => {
render(<UserProfile userId="123" />)
screen.debug()
// Console shows: <div>Loading...</div>
// Ah! Need to wait for loading to complete
expect(await screen.findByText('John Doe')).toBeInTheDocument()
})Debug specific elements:
const form = screen.getByRole('form')
screen.debug(form)
// Prints only the form element treeLimit output size:
screen.debug(undefined, 500)
// Print only first 500 charactersNote: Remove debug statements before committing tests.
Reference: Testing Library - screen.debug
Use Testing Playground for Query Discovery
Use screen.logTestingPlaygroundURL() or the Testing Playground browser extension to interactively find the best query for elements.
Incorrect (trial and error queries):
test('submits contact form', () => {
render(<ContactForm />)
// Trying different queries until one works
screen.getByTestId('submit-btn') // Works but anti-pattern
screen.getByText('Submit') // Too fragile
screen.querySelector('.btn-primary') // Implementation detail
})Correct (use Testing Playground):
test('submits contact form', () => {
render(<ContactForm />)
screen.logTestingPlaygroundURL()
// Opens: https://testing-playground.com/#markup=...
// Hover over submit button, playground suggests:
// screen.getByRole('button', { name: /submit/i })
screen.getByRole('button', { name: /submit/i })
})Benefits over manual inspection:
- Suggests best query type (role, label, text)
- Shows accessible name for getByRole
- Warns about anti-pattern queries
- Tests queries in real-time
Reference: Testing Playground
Avoid getByTestId as Primary Query
getByTestId should be a last resort. Users cannot see or hear test IDs, so tests using them don't verify the actual user experience.
Incorrect (invisible to users):
render(
<form>
<label htmlFor="email">Email</label>
<input id="email" data-testid="email-input" />
</form>
)
const input = screen.getByTestId('email-input')
// User cannot see data-testid attributeCorrect (matches user experience):
render(
<form>
<label htmlFor="email">Email</label>
<input id="email" data-testid="email-input" />
</form>
)
const input = screen.getByLabelText('Email')
// User navigates by label textWhen testid IS acceptable:
- Dynamic content where text changes frequently
- Elements without semantic meaning (decorative containers)
- As a fallback when no other query works
Reference: Testing Library Queries - ByTestId
Use getBy for Present Elements, queryBy for Absent
getBy* throws when no element is found (good for expected elements). queryBy* returns null (good for asserting absence). Using the wrong variant leads to confusing failures.
Incorrect (queryBy for expected element):
render(<Alert message="Success!" />)
expect(screen.queryByRole('alert')).toBeInTheDocument()
// If element missing, assertion passes with null - confusing!Correct (getBy for expected element):
render(<Alert message="Success!" />)
expect(screen.getByRole('alert')).toBeInTheDocument()
// Throws immediately if element not found - clear failureFor asserting absence, use queryBy:
render(<Dashboard showAlert={false} />)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
// Returns null when absent, assertion passes correctlySummary:
| Variant | No Match | Use Case |
|---|---|---|
| getBy | throws | Element should exist |
| queryBy | null | Element should NOT exist |
| findBy | throws (async) | Element will appear |
Reference: Common Mistakes - Using query* Variants
Use getByLabelText for Form Fields
For form inputs, getByLabelText is preferred because it matches how users (including those using screen readers) interact with forms via labels.
Incorrect (tests placeholder, not label):
render(
<form>
<label htmlFor="username">Username</label>
<input id="username" placeholder="Enter username" />
</form>
)
const input = screen.getByPlaceholderText('Enter username')
// Placeholder is not a substitute for a labelCorrect (tests actual label association):
render(
<form>
<label htmlFor="username">Username</label>
<input id="username" placeholder="Enter username" />
</form>
)
const input = screen.getByLabelText('Username')
// Verifies label-input association worksNote: This also works with aria-label and aria-labelledby:
<input aria-label="Search" />
const input = screen.getByLabelText('Search')Reference: Testing Library Queries - ByLabelText
Prefer getByRole Over Other Queries
getByRole is the primary recommended query because it matches how assistive technologies and users perceive your component. It tests the accessibility tree, not the DOM structure.
Incorrect (tests implementation details):
render(<button className="submit-btn">Submit</button>)
const button = screen.getByTestId('submit-button')
// Tests internal attribute, not user-visible behaviorCorrect (tests accessible behavior):
render(<button className="submit-btn">Submit</button>)
const button = screen.getByRole('button', { name: /submit/i })
// Tests what users actually see and interact withBenefits:
- Catches accessibility issues during testing
- More resilient to implementation changes
- Reflects how real users find elements
Reference: Testing Library Query Priority
Use name Option with getByRole
When multiple elements share the same role, use the name option to filter by accessible name. This prevents brittle tests that break when element order changes.
Incorrect (relies on element order):
render(
<nav>
<button>Cancel</button>
<button>Submit</button>
</nav>
)
const buttons = screen.getAllByRole('button')
const submitButton = buttons[1]
// Breaks if button order changesCorrect (targets by accessible name):
render(
<nav>
<button>Cancel</button>
<button>Submit</button>
</nav>
)
const submitButton = screen.getByRole('button', { name: /submit/i })
// Always finds the right button regardless of orderTip: Use regex with /i flag for case-insensitive matching:
screen.getByRole('heading', { name: /welcome/i })Reference: Testing Library - getByRole name option
Use screen for Queries
Import and use screen instead of destructuring queries from render(). This eliminates the need to update destructuring when query needs change.
Incorrect (requires destructure maintenance):
const { getByRole, getByText, queryByRole } = render(<UserProfile />)
const heading = getByRole('heading')
const name = getByText('John Doe')
const badge = queryByRole('img')
// Must update destructure when adding new queriesCorrect (no destructure needed):
render(<UserProfile />)
const heading = screen.getByRole('heading')
const name = screen.getByText('John Doe')
const badge = screen.queryByRole('img')
// Simply use screen.* for any queryBenefits:
- Less boilerplate
- Easier to add new queries
screen.debug()available automatically- Consistent pattern across all tests
Reference: Common Mistakes - Not Using screen
Use within() to Scope Queries
When testing components with repeated structures, use within() to scope queries to a specific container. This prevents matching elements from other parts of the DOM.
Incorrect (queries entire document):
render(
<div>
<article data-testid="post-1">
<h2>First Post</h2>
<button>Delete</button>
</article>
<article data-testid="post-2">
<h2>Second Post</h2>
<button>Delete</button>
</article>
</div>
)
const deleteButton = screen.getByRole('button', { name: /delete/i })
// Error: Found multiple elements with role "button"Correct (scoped to specific container):
render(
<div>
<article data-testid="post-1">
<h2>First Post</h2>
<button>Delete</button>
</article>
<article data-testid="post-2">
<h2>Second Post</h2>
<button>Delete</button>
</article>
</div>
)
const firstPost = screen.getByRole('article', { name: /first post/i })
const deleteButton = within(firstPost).getByRole('button', { name: /delete/i })
// Finds only the delete button within first postReference: Testing Library - within
Create Custom Render with Providers
Create a custom render function that includes common providers. Export it from a test utilities file to eliminate boilerplate.
Incorrect (repeated provider setup):
// user.test.tsx
test('displays user', () => {
render(
<QueryClientProvider client={queryClient}>
<AuthProvider>
<UserProfile />
</AuthProvider>
</QueryClientProvider>
)
})
// settings.test.tsx - same providers repeated
test('shows settings', () => {
render(
<QueryClientProvider client={queryClient}>
<AuthProvider>
<Settings />
</AuthProvider>
</QueryClientProvider>
)
})Correct (custom render in test-utils):
// test-utils.tsx
import { render } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { AuthProvider } from './auth'
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }
})
return ({ children }) => (
<QueryClientProvider client={queryClient}>
<AuthProvider>
{children}
</AuthProvider>
</QueryClientProvider>
)
}
const customRender = (ui, options) =>
render(ui, { wrapper: createWrapper(), ...options })
export * from '@testing-library/react'
export { customRender as render }// user.test.tsx
import { render, screen } from './test-utils'
test('displays user', () => {
render(<UserProfile />)
// Providers automatically included
})Reference: Testing Library - Custom Render
Configure userEvent with Fake Timers
When using Jest's fake timers, configure userEvent.setup() with advanceTimers to prevent tests from hanging on delays.
Incorrect (test hangs):
jest.useFakeTimers()
test('shows loading then content', async () => {
const user = userEvent.setup()
render(<DelayedContent delay={1000} />)
await user.click(screen.getByRole('button'))
// Test hangs! userEvent waits for real time
})Correct (configure advanceTimers):
jest.useFakeTimers()
test('shows loading then content', async () => {
const user = userEvent.setup({
advanceTimers: jest.advanceTimersByTime
})
render(<DelayedContent delay={1000} />)
await user.click(screen.getByRole('button'))
// userEvent advances fake timers automatically
expect(screen.getByText('Content loaded')).toBeInTheDocument()
})With setup in beforeEach:
let user: ReturnType<typeof userEvent.setup>
beforeEach(() => {
jest.useFakeTimers()
user = userEvent.setup({
advanceTimers: jest.advanceTimersByTime
})
})
afterEach(() => {
jest.useRealTimers()
})Reference: userEvent - advanceTimers option
Mock Modules at Module Level
Call jest.mock() at the top level of your test file, not inside tests. Jest hoists mock calls, but placing them inside tests can cause timing issues.
Incorrect (mock inside test):
test('fetches user data', async () => {
jest.mock('./api', () => ({
fetchUser: jest.fn().mockResolvedValue({ name: 'John' })
}))
render(<UserProfile />)
// Mock may not be applied correctly
})Correct (mock at module level):
import { fetchUser } from './api'
jest.mock('./api')
const mockFetchUser = fetchUser as jest.MockedFunction<typeof fetchUser>
test('fetches user data', async () => {
mockFetchUser.mockResolvedValue({ name: 'John' })
render(<UserProfile />)
expect(await screen.findByText('John')).toBeInTheDocument()
})
test('handles error', async () => {
mockFetchUser.mockRejectedValue(new Error('Network error'))
render(<UserProfile />)
expect(await screen.findByRole('alert')).toHaveTextContent('Network error')
})Reset mocks between tests:
beforeEach(() => {
jest.clearAllMocks()
})Reference: Jest - Manual Mocks
Use renderHook for Testing Custom Hooks
Use renderHook to test custom hooks in isolation. This is useful for hook libraries, though testing hooks through components is often preferred.
Incorrect (manual test component):
test('useCounter increments', () => {
let result
function CounterWrapper() {
result = useCounter()
return null
}
render(<CounterWrapper />)
act(() => result.increment())
// Awkward pattern, result access is unclear
})Correct (renderHook):
import { renderHook, act } from '@testing-library/react'
test('useCounter increments', () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})With providers:
const wrapper = ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
test('useFetchUser returns user', async () => {
const { result } = renderHook(() => useFetchUser('123'), { wrapper })
await waitFor(() => {
expect(result.current.data).toEqual({ name: 'John' })
})
})When to prefer component testing:
- Hook is tightly coupled to specific UI
- Testing user-visible behavior is more valuable
Reference: Testing Library - renderHook
Use Wrapper Option for Context Providers
Use the wrapper option in render to provide context. This keeps providers available during rerender() and simplifies test setup.
Incorrect (manual wrapping each test):
test('shows user name', () => {
render(
<ThemeProvider>
<UserProvider>
<Header />
</UserProvider>
</ThemeProvider>
)
// Must repeat wrapper on every rerender
})Correct (wrapper option):
const AllProviders = ({ children }) => (
<ThemeProvider>
<UserProvider>
{children}
</UserProvider>
</ThemeProvider>
)
test('shows user name', () => {
const { rerender } = render(<Header />, { wrapper: AllProviders })
// Providers persist through rerender
rerender(<Header showAvatar />)
})Create a custom render function:
// test-utils.tsx
const customRender = (ui, options) =>
render(ui, { wrapper: AllProviders, ...options })
export * from '@testing-library/react'
export { customRender as render }Reference: Testing Library - wrapper option
Follow Arrange-Act-Assert Pattern
Structure tests with clear Arrange (setup), Act (action), and Assert (verify) phases. This makes tests easier to read and debug.
Incorrect (mixed phases):
test('submits form', async () => {
const user = userEvent.setup()
render(<ContactForm onSubmit={mockSubmit} />)
expect(screen.getByRole('button')).toBeDisabled()
await user.type(screen.getByLabelText('Name'), 'John')
expect(screen.getByRole('button')).toBeEnabled()
await user.type(screen.getByLabelText('Email'), 'john@example.com')
await user.click(screen.getByRole('button'))
expect(mockSubmit).toHaveBeenCalled()
})
// Interleaved actions and assertions are hard to followCorrect (clear phases):
test('submits form with valid data', async () => {
// Arrange
const mockSubmit = jest.fn()
const user = userEvent.setup()
render(<ContactForm onSubmit={mockSubmit} />)
// Act
await user.type(screen.getByLabelText('Name'), 'John')
await user.type(screen.getByLabelText('Email'), 'john@example.com')
await user.click(screen.getByRole('button', { name: /submit/i }))
// Assert
expect(mockSubmit).toHaveBeenCalledWith({
name: 'John',
email: 'john@example.com'
})
})For complex tests, use separate tests for different behaviors:
test('disables submit button when form is empty', () => { /* ... */ })
test('enables submit button when form is valid', () => { /* ... */ })
test('submits form data on button click', () => { /* ... */ })Reference: Arrange-Act-Assert Pattern
Avoid render() in beforeEach
Don't call render() in beforeEach. Each test should explicitly render with its specific props, making test intent clear.
Incorrect (shared render):
describe('Button', () => {
beforeEach(() => {
render(<Button onClick={mockClick}>Click me</Button>)
})
test('renders button text', () => {
expect(screen.getByRole('button')).toHaveTextContent('Click me')
})
test('handles disabled state', () => {
// Can't test disabled - already rendered without disabled prop!
})
})Correct (explicit render per test):
describe('Button', () => {
test('renders button text', () => {
render(<Button onClick={mockClick}>Click me</Button>)
expect(screen.getByRole('button')).toHaveTextContent('Click me')
})
test('is disabled when disabled prop is true', () => {
render(<Button onClick={mockClick} disabled>Click me</Button>)
expect(screen.getByRole('button')).toBeDisabled()
})
test('calls onClick when clicked', async () => {
const handleClick = jest.fn()
const user = userEvent.setup()
render(<Button onClick={handleClick}>Click me</Button>)
await user.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
})Use beforeEach for non-render setup:
beforeEach(() => {
jest.clearAllMocks()
// Setup mocks, not component renders
})Use Descriptive Test Names
Test names should describe the behavior being tested, not the implementation. Good names serve as documentation.
Incorrect (vague or implementation-focused):
test('renders', () => { /* ... */ })
test('handleClick', () => { /* ... */ })
test('should work', () => { /* ... */ })
test('useState', () => { /* ... */ })
// What behavior do these verify?Correct (behavior-focused):
test('displays user name when loaded', () => { /* ... */ })
test('increments counter when plus button clicked', () => { /* ... */ })
test('shows error message for invalid email', () => { /* ... */ })
test('disables submit button while form is submitting', () => { /* ... */ })Pattern: describes what happens under what conditions:
describe('LoginForm', () => {
test('shows validation error when email is empty', () => {})
test('shows validation error when password is too short', () => {})
test('calls onSubmit with credentials when form is valid', () => {})
test('disables submit button during authentication', () => {})
test('redirects to dashboard on successful login', () => {})
})Avoid "should" prefix - it's redundant:
// Unnecessary
test('should display error message', () => {})
// Cleaner
test('displays error message when validation fails', () => {})Test One Behavior Per Test
Each test should verify one specific behavior. Multiple behaviors in one test obscure which behavior failed.
Incorrect (multiple behaviors):
test('user profile', async () => {
render(<UserProfile userId="123" />)
// Behavior 1: Loading state
expect(screen.getByText('Loading...')).toBeInTheDocument()
// Behavior 2: Data display
expect(await screen.findByText('John Doe')).toBeInTheDocument()
expect(screen.getByText('john@example.com')).toBeInTheDocument()
// Behavior 3: Edit mode
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: /edit/i }))
expect(screen.getByLabelText('Name')).toHaveValue('John Doe')
})
// If test fails, which behavior broke?Correct (focused tests):
test('shows loading state initially', () => {
render(<UserProfile userId="123" />)
expect(screen.getByText('Loading...')).toBeInTheDocument()
})
test('displays user data after loading', async () => {
render(<UserProfile userId="123" />)
expect(await screen.findByText('John Doe')).toBeInTheDocument()
expect(screen.getByText('john@example.com')).toBeInTheDocument()
})
test('enters edit mode when edit button clicked', async () => {
const user = userEvent.setup()
render(<UserProfile userId="123" />)
await screen.findByText('John Doe')
await user.click(screen.getByRole('button', { name: /edit/i }))
expect(screen.getByLabelText('Name')).toHaveValue('John Doe')
})Reference: Testing Trophy
Always Await userEvent Interactions
All userEvent methods return Promises. Forgetting to await them causes tests to proceed before interactions complete.
Incorrect (missing await):
const user = userEvent.setup()
render(<Form />)
user.type(screen.getByLabelText('Name'), 'John')
user.click(screen.getByRole('button', { name: /submit/i }))
expect(screen.getByText('Submitted!')).toBeInTheDocument()
// Assertion runs before typing/clicking completes!Correct (properly awaited):
const user = userEvent.setup()
render(<Form />)
await user.type(screen.getByLabelText('Name'), 'John')
await user.click(screen.getByRole('button', { name: /submit/i }))
expect(await screen.findByText('Submitted!')).toBeInTheDocument()
// Interactions complete before assertionFor sequential interactions, await each:
await user.click(screen.getByRole('button', { name: /add item/i }))
await user.type(screen.getByLabelText('Item name'), 'New item')
await user.keyboard('{Enter}')Reference: userEvent - Async Methods
Use clear() Before Retyping Input Values
user.type() appends to existing input values. To replace the value entirely, first call user.clear().
Incorrect (appends instead of replaces):
const user = userEvent.setup()
render(<EditableField defaultValue="Hello" />)
const input = screen.getByRole('textbox')
await user.type(input, 'World')
expect(input).toHaveValue('HelloWorld')
// Oops - wanted "World", got "HelloWorld"Correct (clear then type):
const user = userEvent.setup()
render(<EditableField defaultValue="Hello" />)
const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, 'World')
expect(input).toHaveValue('World')
// Input value is exactly "World"Alternative - select all and type:
const input = screen.getByRole('textbox')
await user.tripleClick(input) // Select all text
await user.type(input, 'New value')
// Replaces selected textReference: userEvent - clear
Use keyboard() for Special Keys
Use user.keyboard() for special keys like Enter, Escape, Tab, and keyboard shortcuts. This properly simulates keyboard navigation.
Incorrect (fireEvent for keyboard):
render(<SearchInput />)
const input = screen.getByRole('searchbox')
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' })
// Missing keyup, may not trigger submit handlersCorrect (userEvent keyboard):
const user = userEvent.setup()
render(<SearchInput />)
const input = screen.getByRole('searchbox')
await user.type(input, 'search term')
await user.keyboard('{Enter}')
// Complete Enter key simulationCommon key syntaxes:
await user.keyboard('{Enter}') // Enter key
await user.keyboard('{Escape}') // Escape key
await user.keyboard('{Tab}') // Tab navigation
await user.keyboard('{Backspace}') // Delete character
await user.keyboard('{Control>}a') // Ctrl+A (select all)
await user.keyboard('{Shift>}{Tab}') // Shift+TabFor form submission:
await user.type(screen.getByLabelText('Search'), 'query{Enter}')
// Types "query" then presses EnterReference: userEvent - keyboard
Use userEvent Over fireEvent
userEvent simulates complete user interactions with multiple events, while fireEvent only dispatches single DOM events. userEvent catches more bugs.
Incorrect (single event, incomplete simulation):
render(<LoginForm />)
fireEvent.change(screen.getByLabelText('Email'), {
target: { value: 'user@example.com' }
})
fireEvent.click(screen.getByRole('button', { name: /submit/i }))
// Missing focus, keydown, keyup events a real user triggersCorrect (realistic user interaction):
render(<LoginForm />)
const user = userEvent.setup()
await user.type(screen.getByLabelText('Email'), 'user@example.com')
await user.click(screen.getByRole('button', { name: /submit/i }))
// Fires focus, keydown, keypress, input, keyup eventsKey differences:
| Action | fireEvent | userEvent |
|---|---|---|
| click | 1 event | focus + mousedown + mouseup + click |
| type | change event only | keydown + keypress + input + keyup per character |
| tab | N/A | full focus management |
Reference: Testing Library - userEvent
Setup userEvent Before Render
Always call userEvent.setup() before render() to ensure proper initialization. This configures event timing and keyboard state correctly.
Incorrect (setup after render or direct API):
render(<Modal />)
const user = userEvent.setup()
await user.click(screen.getByRole('button'))
// May miss events that fire during render
// Or using direct API (deprecated pattern):
await userEvent.click(screen.getByRole('button'))
// Less control over timing and optionsCorrect (setup before render):
const user = userEvent.setup()
render(<Modal />)
await user.click(screen.getByRole('button', { name: /open/i }))
expect(screen.getByRole('dialog')).toBeInTheDocument()
// Properly configured event simulationConfiguring setup options:
const user = userEvent.setup({
delay: null, // Speed up tests by removing typing delay
advanceTimers: jest.advanceTimersByTime, // For fake timers
})Reference: userEvent - Setup
Related skills
Forks & variants (1)
React Testing Library has 1 known copy in the catalog totaling 19 installs. They canonicalize to this original listing.
- shipshitdev - 19 installs