Expert in Testing Library for user-centric component testing across React, Vue, Angular, and more. Use when writing, running, or improving tests with testing library.
Scanned 9/8/2026
Install to Claude Code
npx -y skills add anubhavg-icpl/vibe --skill testing-library-expert --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Testing Library Expert?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/anubhavg-icpl-testing-library-expert)More formats (shields.io, HTML) on the badges page.
---
name: testing-library-expert
description: Expert in Testing Library for user-centric component testing across React, Vue, Angular, and more. Use when writing, running, or improving tests with testing library.
license: CC-BY-NC-SA-4.0
metadata:
risk: unknown
source: community
kind: mode
category: testing
tags: [testing-library, react-testing-library, testing, accessibility, user-events]
---
# Testing Library Expert Mode
You are an expert in Testing Library, the family of testing utilities that encourage testing components the way users interact with them. You help teams write maintainable, accessible tests.
## Core Philosophy
Testing Library follows the guiding principle: "The more your tests resemble the way your software is used, the more confidence they can give you."
## Setup
### React Testing Library Setup
```typescript
// src/setupTests.ts
import "@testing-library/jest-dom";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
// Cleanup after each test
afterEach(() => {
cleanup();
});
// Mock matchMedia
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
```
### Custom Render
```typescript
// src/test/test-utils.tsx
import React, { ReactElement, ReactNode } from 'react';
import { render, RenderOptions, RenderResult } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { ThemeProvider } from '../contexts/ThemeContext';
import { AuthProvider } from '../contexts/AuthContext';
interface WrapperProps {
children: ReactNode;
}
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
initialRoute?: string;
routes?: ReactElement;
}
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
}
function AllTheProviders({ children }: WrapperProps): ReactElement {
const queryClient = createTestQueryClient();
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<AuthProvider>
{children}
</AuthProvider>
</ThemeProvider>
</QueryClientProvider>
);
}
function customRender(
ui: ReactElement,
options: CustomRenderOptions = {}
): RenderResult {
const { initialRoute = '/', routes, ...renderOptions } = options;
function Wrapper({ children }: WrapperProps): ReactElement {
return (
<AllTheProviders>
<MemoryRouter initialEntries={[initialRoute]}>
{routes || children}
</MemoryRouter>
</AllTheProviders>
);
}
return render(ui, { wrapper: Wrapper, ...renderOptions });
}
// Re-export everything
export * from '@testing-library/react';
export { customRender as render };
export { default as userEvent } from '@testing-library/user-event';
```
## Query Priority
Use queries in this order of priority:
1. **Accessible by Everyone** (most preferred)
- `getByRole` - reflects experience of visual/assistive users
- `getByLabelText` - navigating forms
- `getByPlaceholderText` - when no label exists
- `getByText` - non-interactive elements
- `getByDisplayValue` - current input value
2. **Semantic Queries**
- `getByAltText` - images
- `getByTitle` - title attribute
3. **Test IDs** (least preferred)
- `getByTestId` - only when nothing else works
## Query Types
```typescript
import { screen } from "@testing-library/react";
// getBy* - Throws if no match or multiple matches (synchronous)
const button = screen.getByRole("button", { name: /submit/i });
// queryBy* - Returns null if no match, throws on multiple (for asserting absence)
const errorMessage = screen.queryByText(/error/i);
expect(errorMessage).not.toBeInTheDocument();
// findBy* - Returns promise, waits for element (async)
const loadedContent = await screen.findByText(/loaded/i);
// getAllBy* - Returns array, throws if empty
const listItems = screen.getAllByRole("listitem");
expect(listItems).toHaveLength(5);
// queryAllBy* - Returns array (can be empty)
const optionalItems = screen.queryAllByTestId("optional");
// findAllBy* - Returns promise of array
const asyncItems = await screen.findAllByRole("row");
```
## Queries Deep Dive
### getByRole
```typescript
// Most versatile query - supports ARIA roles
import { render, screen } from './test-utils';
describe('Role queries', () => {
it('finds elements by role', () => {
render(
<div>
<button>Submit</button>
<a href="/home">Home</a>
<input type="checkbox" />
<h1>Title</h1>
<nav>Navigation</nav>
<main>Content</main>
<aside>Sidebar</aside>
<dialog open>Modal</dialog>
</div>
);
// Common roles
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /home/i })).toBeInTheDocument();
expect(screen.getByRole('checkbox')).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
expect(screen.getByRole('navigation')).toBeInTheDocument();
expect(screen.getByRole('main')).toBeInTheDocument();
expect(screen.getByRole('complementary')).toBeInTheDocument(); // aside
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('uses accessible name options', () => {
render(
<>
<button aria-label="Close modal">X</button>
<input aria-label="Search" />
<img src="logo.png" alt="Company Logo" />
</>
);
screen.getByRole('button', { name: /close modal/i });
screen.getByRole('textbox', { name: /search/i });
screen.getByRole('img', { name: /company logo/i });
});
it('uses state options', () => {
render(
<>
<button disabled>Disabled</button>
<input type="checkbox" checked readOnly />
<details open><summary>Expand</summary>Content</details>
</>
);
screen.getByRole('button', { name: /disabled/i, hidden: false });
screen.getByRole('checkbox', { checked: true });
screen.getByRole('group', { expanded: true });
});
});
```
### getByLabelText
```typescript
describe('Label queries', () => {
it('finds form controls by label', () => {
render(
<form>
{/* Explicit label */}
<label htmlFor="email">Email Address</label>
<input id="email" type="email" />
{/* Wrapping label */}
<label>
Password
<input type="password" />
</label>
{/* aria-labelledby */}
<span id="phone-label">Phone Number</span>
<input aria-labelledby="phone-label" type="tel" />
{/* aria-label */}
<input aria-label="Search products" type="search" />
</form>
);
expect(screen.getByLabelText(/email address/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
expect(screen.getByLabelText(/phone number/i)).toBeInTheDocument();
expect(screen.getByLabelText(/search products/i)).toBeInTheDocument();
});
it('handles multiple labels', () => {
render(
<label>
Username
<input name="username" />
<span>(required)</span>
</label>
);
// Use selector option for complex scenarios
screen.getByLabelText(/username/i, { selector: 'input' });
});
});
```
### getByText
```typescript
describe('Text queries', () => {
it('finds elements by text content', () => {
render(
<div>
<p>Welcome to our website</p>
<span>Item count: 5</span>
<div>Some text with <strong>nested</strong> elements</div>
</div>
);
// Exact match
screen.getByText('Welcome to our website');
// Substring match with regex
screen.getByText(/welcome/i);
// Partial match
screen.getByText(/item count/i);
// Nested text (uses normalize)
screen.getByText(/some text with nested elements/i);
});
it('uses text match options', () => {
render(<p>Hello World</p>);
// Case insensitive
screen.getByText(/hello world/i);
// Custom text matcher
screen.getByText((content, element) => {
return content.startsWith('Hello') && element?.tagName === 'P';
});
// Ignore whitespace
screen.getByText('Hello World', { normalizer: (str) => str.trim() });
});
});
```
## User Events
### userEvent API
```typescript
import { render, screen } from './test-utils';
import userEvent from '@testing-library/user-event';
describe('User interactions', () => {
it('handles keyboard input', async () => {
const user = userEvent.setup();
render(<input data-testid="input" />);
const input = screen.getByTestId('input');
await user.type(input, 'Hello World');
expect(input).toHaveValue('Hello World');
await user.clear(input);
expect(input).toHaveValue('');
await user.type(input, 'Test{Enter}');
expect(input).toHaveValue('Test');
});
it('handles special keys', async () => {
const user = userEvent.setup();
const onKeyDown = vi.fn();
render(<input onKeyDown={onKeyDown} />);
const input = screen.getByRole('textbox');
await user.type(input, '{Shift>}ABC{/Shift}');
await user.type(input, '{Control>}a{/Control}');
await user.keyboard('{Escape}');
expect(input).toHaveValue('ABC');
});
it('handles click events', async () => {
const user = userEvent.setup();
const onClick = vi.fn();
const onDoubleClick = vi.fn();
render(
<button onClick={onClick} onDoubleClick={onDoubleClick}>
Click me
</button>
);
const button = screen.getByRole('button');
await user.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
await user.dblClick(button);
expect(onDoubleClick).toHaveBeenCalledTimes(1);
});
it('handles hover events', async () => {
const user = userEvent.setup();
const onMouseEnter = vi.fn();
const onMouseLeave = vi.fn();
render(
<button onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
Hover me
</button>
);
const button = screen.getByRole('button');
await user.hover(button);
expect(onMouseEnter).toHaveBeenCalled();
await user.unhover(button);
expect(onMouseLeave).toHaveBeenCalled();
});
it('handles form selection', async () => {
const user = userEvent.setup();
render(
<select>
<option value="">Select...</option>
<option value="1">Option 1</option>
<option value="2">Option 2</option>
</select>
);
const select = screen.getByRole('combobox');
await user.selectOptions(select, '1');
expect(select).toHaveValue('1');
await user.selectOptions(select, 'Option 2');
expect(select).toHaveValue('2');
});
it('handles checkbox and radio', async () => {
const user = userEvent.setup();
render(
<>
<input type="checkbox" id="agree" />
<label htmlFor="agree">I agree</label>
<input type="radio" name="choice" value="a" id="a" />
<label htmlFor="a">Choice A</label>
<input type="radio" name="choice" value="b" id="b" />
<label htmlFor="b">Choice B</label>
</>
);
const checkbox = screen.getByLabelText(/i agree/i);
await user.click(checkbox);
expect(checkbox).toBeChecked();
await user.click(screen.getByLabelText(/choice a/i));
expect(screen.getByLabelText(/choice a/i)).toBeChecked();
expect(screen.getByLabelText(/choice b/i)).not.toBeChecked();
});
it('handles file upload', async () => {
const user = userEvent.setup();
const file = new File(['hello'], 'hello.txt', { type: 'text/plain' });
render(<input type="file" data-testid="upload" />);
const input = screen.getByTestId('upload') as HTMLInputElement;
await user.upload(input, file);
expect(input.files?.[0]).toBe(file);
expect(input.files?.length).toBe(1);
});
it('handles clipboard operations', async () => {
const user = userEvent.setup();
render(
<>
<input data-testid="source" defaultValue="Copy this" />
<input data-testid="target" />
</>
);
const source = screen.getByTestId('source');
const target = screen.getByTestId('target');
await user.tripleClick(source); // Select all
await user.copy();
await user.click(target);
await user.paste();
expect(target).toHaveValue('Copy this');
});
});
```
## Async Utilities
### waitFor
```typescript
import { render, screen, waitFor } from './test-utils';
describe('Async utilities', () => {
it('waits for element to appear', async () => {
render(<AsyncComponent />);
// waitFor retries until condition passes or timeout
await waitFor(() => {
expect(screen.getByText(/loaded/i)).toBeInTheDocument();
});
});
it('waits with custom options', async () => {
render(<SlowComponent />);
await waitFor(
() => {
expect(screen.getByRole('status')).toHaveTextContent('Complete');
},
{
timeout: 5000,
interval: 100,
onTimeout: (error) => {
console.error('Timeout waiting for status');
return error;
},
}
);
});
it('waits for element to disappear', async () => {
render(<LoadingComponent />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
await waitFor(() => {
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
});
});
```
### waitForElementToBeRemoved
```typescript
import { render, screen, waitForElementToBeRemoved } from './test-utils';
describe('Element removal', () => {
it('waits for loading to be removed', async () => {
render(<DataLoader />);
const loading = screen.getByText(/loading/i);
await waitForElementToBeRemoved(loading);
expect(screen.getByText(/data loaded/i)).toBeInTheDocument();
});
it('uses callback form', async () => {
render(<Spinner />);
await waitForElementToBeRemoved(() => screen.queryByRole('progressbar'));
expect(screen.getByText(/content/i)).toBeInTheDocument();
});
});
```
### findBy Queries
```typescript
describe('findBy queries', () => {
it('finds async elements', async () => {
render(<AsyncList />);
// findBy is equivalent to waitFor + getBy
const items = await screen.findAllByRole('listitem');
expect(items).toHaveLength(5);
});
it('finds with timeout', async () => {
render(<SlowLoader />);
const element = await screen.findByText(/finally loaded/i, {}, { timeout: 5000 });
expect(element).toBeInTheDocument();
});
});
```
## Testing Patterns
### Forms
```typescript
describe('Form testing', () => {
it('submits form with valid data', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<ContactForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/name/i), 'John Doe');
await user.type(screen.getByLabelText(/email/i), 'john@example.com');
await user.type(screen.getByLabelText(/message/i), 'Hello!');
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(onSubmit).toHaveBeenCalledWith({
name: 'John Doe',
email: 'john@example.com',
message: 'Hello!',
});
});
it('shows validation errors', async () => {
const user = userEvent.setup();
render(<ContactForm onSubmit={vi.fn()} />);
// Submit empty form
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(screen.getByText(/name is required/i)).toBeInTheDocument();
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
});
it('validates email format', async () => {
const user = userEvent.setup();
render(<ContactForm onSubmit={vi.fn()} />);
await user.type(screen.getByLabelText(/email/i), 'invalid-email');
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(screen.getByText(/invalid email format/i)).toBeInTheDocument();
});
});
```
### Modal Dialog
```typescript
describe('Modal', () => {
it('opens and closes modal', async () => {
const user = userEvent.setup();
render(<ModalExample />);
// Open modal
await user.click(screen.getByRole('button', { name: /open modal/i }));
const dialog = screen.getByRole('dialog');
expect(dialog).toBeInTheDocument();
expect(dialog).toHaveAttribute('aria-modal', 'true');
// Close with button
await user.click(screen.getByRole('button', { name: /close/i }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('closes on escape key', async () => {
const user = userEvent.setup();
render(<ModalExample />);
await user.click(screen.getByRole('button', { name: /open modal/i }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('traps focus within modal', async () => {
const user = userEvent.setup();
render(<ModalExample />);
await user.click(screen.getByRole('button', { name: /open modal/i }));
const modal = screen.getByRole('dialog');
const closeButton = screen.getByRole('button', { name: /close/i });
const confirmButton = screen.getByRole('button', { name: /confirm/i });
// First focusable element
expect(closeButton).toHaveFocus();
// Tab through
await user.tab();
expect(confirmButton).toHaveFocus();
// Tab wraps to first element
await user.tab();
expect(closeButton).toHaveFocus();
});
});
```
### Data Tables
```typescript
describe('Data Table', () => {
const mockData = [
{ id: 1, name: 'Alice', email: 'alice@example.com', role: 'Admin' },
{ id: 2, name: 'Bob', email: 'bob@example.com', role: 'User' },
{ id: 3, name: 'Charlie', email: 'charlie@example.com', role: 'User' },
];
it('renders table with data', () => {
render(<DataTable data={mockData} />);
expect(screen.getByRole('table')).toBeInTheDocument();
// Check headers
expect(screen.getByRole('columnheader', { name: /name/i })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: /email/i })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: /role/i })).toBeInTheDocument();
// Check rows
expect(screen.getAllByRole('row')).toHaveLength(4); // header + 3 data rows
});
it('sorts by column', async () => {
const user = userEvent.setup();
render(<DataTable data={mockData} />);
const nameHeader = screen.getByRole('columnheader', { name: /name/i });
await user.click(nameHeader);
const cells = screen.getAllByRole('cell');
const names = cells.filter((_, i) => i % 3 === 0).map((c) => c.textContent);
expect(names).toEqual(['Alice', 'Bob', 'Charlie']);
});
it('filters data', async () => {
const user = userEvent.setup();
render(<DataTable data={mockData} />);
await user.type(screen.getByPlaceholderText(/search/i), 'alice');
expect(screen.getAllByRole('row')).toHaveLength(2); // header + 1 match
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
});
});
```
### Accessibility Testing
```typescript
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
describe('Accessibility', () => {
it('has no accessibility violations', async () => {
const { container } = render(<App />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('tests specific component', async () => {
const { container } = render(<NavigationMenu />);
const results = await axe(container, {
rules: {
'color-contrast': { enabled: true },
'keyboard-navigation': { enabled: true },
},
});
expect(results).toHaveNoViolations();
});
it('verifies ARIA attributes', () => {
render(<Dropdown label="Options" />);
const button = screen.getByRole('button', { name: /options/i });
expect(button).toHaveAttribute('aria-haspopup', 'true');
expect(button).toHaveAttribute('aria-expanded', 'false');
});
});
```
## Custom Matchers (jest-dom)
```typescript
describe('jest-dom matchers', () => {
it('uses visibility matchers', () => {
render(
<>
<div>Visible</div>
<div style={{ display: 'none' }}>Hidden</div>
<div style={{ visibility: 'hidden' }}>Invisible</div>
</>
);
expect(screen.getByText('Visible')).toBeVisible();
expect(screen.getByText('Hidden')).not.toBeVisible();
expect(screen.getByText('Invisible')).not.toBeVisible();
});
it('uses form matchers', () => {
render(
<form>
<input type="text" required />
<input type="checkbox" checked readOnly />
<input type="text" disabled />
<input type="text" value="test" readOnly />
</form>
);
expect(screen.getAllByRole('textbox')[0]).toBeRequired();
expect(screen.getByRole('checkbox')).toBeChecked();
expect(screen.getAllByRole('textbox')[1]).toBeDisabled();
expect(screen.getAllByRole('textbox')[2]).toHaveValue('test');
});
it('uses class and style matchers', () => {
render(
<div
className="container primary"
style={{ backgroundColor: 'red', padding: '10px' }}
>
Styled
</div>
);
const element = screen.getByText('Styled');
expect(element).toHaveClass('container', 'primary');
expect(element).toHaveStyle({
backgroundColor: 'red',
padding: '10px',
});
});
it('uses content matchers', () => {
render(
<>
<p>Hello World</p>
<div data-testid="empty"></div>
</>
);
expect(screen.getByText(/hello/i)).toHaveTextContent('Hello World');
expect(screen.getByTestId('empty')).toBeEmptyDOMElement();
});
it('uses attribute matchers', () => {
render(<a href="/home" target="_blank">Link</a>);
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/home');
expect(link).toHaveAttribute('target', '_blank');
});
it('uses focus matchers', async () => {
const user = userEvent.setup();
render(<input data-testid="input" />);
const input = screen.getByTestId('input');
expect(input).not.toHaveFocus();
await user.click(input);
expect(input).toHaveFocus();
});
});
```
## Best Practices
1. **Query Selection**
- Prefer accessible queries (getByRole, getByLabelText)
- Avoid getByTestId unless necessary
- Use regex for flexible matching
2. **User Events**
- Always use userEvent.setup()
- Await all user interactions
- Test realistic user flows
3. **Async Testing**
- Use findBy for async elements
- Use waitFor for complex assertions
- Set appropriate timeouts
4. **Accessibility**
- Test with axe-core
- Verify ARIA attributes
- Test keyboard navigation
5. **Maintainability**
- Create custom render with providers
- Use data-testid sparingly
- Write tests that resemble user behavior
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!