Claude-skill-registry front-end-testing
DOM Testing Library patterns for behavior-driven UI testing. Framework-agnostic patterns for testing user interfaces. Use when testing any front-end application.
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/front-end-testing" ~/.claude/skills/majiayu000-claude-skill-registry-front-end-testing && rm -rf "$T"
skills/data/front-end-testing/SKILL.mdFront-End Testing with DOM Testing Library
This skill focuses on framework-agnostic DOM Testing Library patterns that work across React, Vue, Svelte, and other frameworks. For React-specific patterns (renderHook, context, components), load the
react-testing skill. For TDD workflow (RED-GREEN-REFACTOR), load the tdd skill. For general testing patterns (factories, public API testing), load the testing skill.
Core Philosophy
Test behavior users see, not implementation details.
Testing Library exists to solve a fundamental problem: tests that break when you refactor (false negatives) and tests that pass when bugs exist (false positives).
Two Types of Users
Your UI components have two users:
- End-users: Interact through the DOM (clicks, typing, reading text)
- Developers: You, refactoring implementation
Kent C. Dodds principle: "The more your tests resemble the way your software is used, the more confidence they can give you."
Why This Matters
False negatives (tests break on refactor):
// ❌ WRONG - Testing implementation (will break on refactor) it('should update internal state', () => { const component = new CounterComponent(); component.setState({ count: 5 }); // Coupled to state implementation expect(component.state.count).toBe(5); });
False positives (bugs pass tests):
// ❌ WRONG - Testing wrong thing it('should render button', () => { render('<button data-testid="submit-btn">Submit</button>'); expect(screen.getByTestId('submit-btn')).toBeInTheDocument(); // Button exists but onClick is broken - test passes! });
Correct approach (behavior-driven):
// ✅ CORRECT - Testing user-visible behavior it('should submit form when user clicks submit', async () => { const handleSubmit = vi.fn(); const user = userEvent.setup(); render(` <form id="login-form"> <label>Email: <input name="email" /></label> <label>Password: <input name="password" type="password" /></label> <button type="submit">Submit</button> </form> `); document.getElementById('login-form').addEventListener('submit', (e) => { e.preventDefault(); handleSubmit(new FormData(e.target)); }); await user.type(screen.getByLabelText(/email/i), 'test@example.com'); await user.type(screen.getByLabelText(/password/i), 'password123'); await user.click(screen.getByRole('button', { name: /submit/i })); expect(handleSubmit).toHaveBeenCalled(); });
This test:
- Survives refactoring (state → signals → stores)
- Tests the contract (what users see)
- Catches real bugs (broken onClick, validation errors)
Query Selection Priority
Most critical Testing Library skill: choosing the right query.
Priority Order
Use queries in this order (accessibility-first):
-
- Highest prioritygetByRole- Queries by ARIA role + accessible name
- Mirrors screen reader experience
- Forces semantic HTML
-
- Form fieldsgetByLabelText- Finds inputs by associated
<label> - Ensures accessible forms
- Finds inputs by associated
-
- Fallback for inputsgetByPlaceholderText- Only when label not present
- Placeholder shouldn't replace label
-
- Non-interactive contentgetByText- Headings, paragraphs, list items
- Content users read
-
- Current form valuesgetByDisplayValue- Inputs with pre-filled values
-
- ImagesgetByAltText- Ensures accessible images
-
- SVG titles, title attributesgetByTitle- Rare, when other queries unavailable
-
- Last resort onlygetByTestId- When no other query works
- Not user-facing
Query Variants
Three variants for every query:
- Element must exist (throws if not found)getBy*
// ✅ Use when asserting element EXISTS const button = screen.getByRole('button', { name: /submit/i }); expect(button).toBeDisabled();
- Returns null if not foundqueryBy*
// ✅ Use when asserting element DOESN'T exist expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); // ❌ WRONG - getBy throws, can't assert non-existence expect(() => screen.getByRole('dialog')).toThrow(); // Ugly!
- Async, waits for element to appearfindBy*
// ✅ Use when element appears after async operation const message = await screen.findByText(/success/i);
Common Mistakes
❌ Using container.querySelector
const button = container.querySelector('.submit-button'); // DOM implementation detail
✅ CORRECT - Query by accessible role
const button = screen.getByRole('button', { name: /submit/i }); // User-facing
❌ Using
when role availablegetByTestId
screen.getByTestId('submit-button'); // Not how users find button
✅ CORRECT - Query by role
screen.getByRole('button', { name: /submit/i }); // How screen readers find it
❌ Not using accessible names
screen.getByRole('button'); // Which button? Multiple on page!
✅ CORRECT - Specify accessible name
screen.getByRole('button', { name: /submit/i }); // Specific button
❌ Using getBy to assert non-existence
expect(() => screen.getByText(/error/i)).toThrow(); // Awkward
✅ CORRECT - Use queryBy
expect(screen.queryByText(/error/i)).not.toBeInTheDocument();
User Event Simulation
Always use
over userEvent
for realistic interactions.fireEvent
userEvent vs fireEvent
Why userEvent is superior:
- Simulates complete interaction sequence (hover → focus → click → blur)
- Triggers all associated events
- Respects browser timing and order
- Catches more bugs
// ❌ WRONG - fireEvent (incomplete simulation) fireEvent.change(input, { target: { value: 'test' } }); fireEvent.click(button);
// ✅ CORRECT - userEvent (realistic simulation) const user = userEvent.setup(); await user.type(input, 'test'); await user.click(button);
Only use
when:fireEvent
doesn't support the event (rare)userEvent- Testing non-standard browser behavior
userEvent.setup() Pattern
Modern best practice (2025):
// ✅ CORRECT - Setup per test it('should handle user input', async () => { const user = userEvent.setup(); // Fresh instance per test render('<input aria-label="Email" />'); await user.type(screen.getByLabelText(/email/i), 'test@example.com'); });
// ❌ WRONG - Setup in beforeEach let user; beforeEach(() => { user = userEvent.setup(); // Shared state across tests }); it('test 1', async () => { await user.click(...); // Might affect test 2 });
Why: Each test gets clean state, prevents test interdependence.
Common Interactions
Clicking:
const user = userEvent.setup(); await user.click(screen.getByRole('button', { name: /submit/i }));
Typing:
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
Keyboard:
await user.keyboard('{Enter}'); // Press Enter await user.keyboard('{Shift>}A{/Shift}'); // Shift+A
Selecting options:
await user.selectOptions( screen.getByLabelText(/country/i), 'USA' );
Clearing input:
await user.clear(screen.getByLabelText(/search/i));
Async Testing Patterns
UI frameworks are async by nature (state updates, API calls, suspense). Testing Library provides utilities for async scenarios.
findBy Queries
Built-in async queries (combines
getBy + waitFor):
// ✅ CORRECT - Wait for element to appear const message = await screen.findByText(/success/i); // Under the hood: retries getByText until it succeeds or timeout
When to use:
- Element appears after async operation
- Loading states disappear
- API responses render content
Configuration:
// Default: 1000ms timeout const message = await screen.findByText(/success/i); // Custom timeout const message = await screen.findByText(/success/i, {}, { timeout: 3000 });
waitFor Utility
For complex conditions that
findBy can't handle:
// ✅ CORRECT - Complex assertion await waitFor(() => { expect(screen.getByText(/loaded/i)).toBeInTheDocument(); }); // ✅ CORRECT - Multiple elements await waitFor(() => { expect(screen.getAllByRole('listitem')).toHaveLength(10); });
waitFor retries until:
- Assertion passes (doesn't throw)
- Timeout reached (default 1000ms)
Common mistakes:
❌ Side effects in waitFor
await waitFor(() => { fireEvent.click(button); // Side effect! Will click multiple times expect(result).toBe(true); });
✅ CORRECT - Only assertions
fireEvent.click(button); // Outside waitFor await waitFor(() => { expect(result).toBe(true); // Only assertion });
❌ Multiple assertions
await waitFor(() => { expect(screen.getByText(/name/i)).toBeInTheDocument(); expect(screen.getByText(/email/i)).toBeInTheDocument(); // Might not retry both });
✅ CORRECT - Single assertion per waitFor
await waitFor(() => { expect(screen.getByText(/name/i)).toBeInTheDocument(); }); expect(screen.getByText(/email/i)).toBeInTheDocument();
❌ Wrapping findBy in waitFor
await waitFor(() => screen.findByText(/success/i)); // Redundant!
✅ CORRECT - findBy already waits
await screen.findByText(/success/i);
waitForElementToBeRemoved
For disappearance scenarios:
// ✅ CORRECT - Wait for loading spinner to disappear await waitForElementToBeRemoved(() => screen.queryByText(/loading/i)); // ✅ CORRECT - Wait for modal to close await waitForElementToBeRemoved(() => screen.queryByRole('dialog'));
Note: Must use
queryBy* (returns null) not getBy* (throws).
Common Patterns
Loading states:
render('<div id="container"></div>'); // Simulate async data loading const container = document.getElementById('container'); container.innerHTML = '<p>Loading...</p>'; // Initially loading expect(screen.getByText(/loading/i)).toBeInTheDocument(); // Simulate data load setTimeout(() => { container.innerHTML = '<p>John Doe</p>'; }, 100); // Wait for data await screen.findByText(/john doe/i); // Loading gone expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
API responses:
const user = userEvent.setup(); render(` <form> <label>Search: <input name="search" /></label> <button type="submit">Search</button> <ul id="results"></ul> </form> `); await user.type(screen.getByLabelText(/search/i), 'react'); await user.click(screen.getByRole('button', { name: /search/i })); // Wait for results (after API response) await waitFor(() => { expect(screen.getAllByRole('listitem')).toHaveLength(10); });
Debounced inputs:
const user = userEvent.setup(); render(` <label>Search: <input id="search" /></label> <ul id="suggestions"></ul> `); await user.type(screen.getByLabelText(/search/i), 'react'); // Wait for debounced suggestions await screen.findByText(/react testing library/i);
MSW Integration
Mock Service Worker for API-level mocking.
Why MSW
Network-level interception:
- Intercepts requests at network layer (not fetch/axios mocks)
- Same mocks work in tests, Storybook, development
- No client-specific mocking logic
- Tests real request logic
// ❌ WRONG - Mocking fetch implementation vi.spyOn(global, 'fetch').mockResolvedValue({ json: async () => ({ users: [...] }), }); // Tight coupling, won't work in Storybook
// ✅ CORRECT - MSW intercepts at network level // Works in tests, Storybook, dev server http.get('/api/users', () => { return HttpResponse.json({ users: [...] }); });
setupServer Pattern
In test setup file:
// test-setup.ts import { setupServer } from 'msw/node'; import { handlers } from './mocks/handlers'; export const server = setupServer(...handlers); beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); afterAll(() => server.close());
In handlers file:
// mocks/handlers.ts import { http, HttpResponse } from 'msw'; export const handlers = [ http.get('/api/users', () => { return HttpResponse.json({ users: [ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, ], }); }), ];
Per-Test Overrides
Override handlers for specific tests:
it('should handle API error', async () => { // Override for this test only server.use( http.get('/api/users', () => { return HttpResponse.json( { error: 'Server error' }, { status: 500 } ); }) ); render('<div id="user-list"></div>'); // Simulate component fetching users fetch('/api/users').then(() => { document.getElementById('user-list').innerHTML = '<p>Failed to load users</p>'; }); await screen.findByText(/failed to load users/i); });
After test,
resets to default handlers.afterEach
Accessibility-First Testing
Why Accessible Queries
Three benefits:
- Tests mirror real usage - Query like screen readers do
- Improves app accessibility - Tests force accessible markup
- Refactor-friendly - Coupled to user experience, not implementation
// ❌ WRONG - Implementation detail screen.getByTestId('user-menu'); // ✅ CORRECT - Accessibility query screen.getByRole('button', { name: /user menu/i });
If accessible query fails, your app has an accessibility issue.
ARIA Attributes
When to add ARIA:
✅ Custom components (where semantic HTML unavailable):
<div role="dialog" aria-label="Confirmation Dialog"> <h2>Are you sure?</h2> ... </div>
Query:
screen.getByRole('dialog', { name: /confirmation/i });
❌ DON'T add to semantic HTML (redundant):
<!-- ❌ WRONG - Semantic HTML already has role --> <button role="button">Submit</button> <!-- ✅ CORRECT - Semantic HTML is enough --> <button>Submit</button>
Semantic HTML Priority
Always prefer semantic HTML over ARIA:
<!-- ❌ WRONG - Custom element + ARIA --> <div role="button" onclick="handleClick()" tabindex="0"> Submit </div> <!-- ✅ CORRECT - Semantic HTML --> <button onclick="handleClick()"> Submit </button>
Semantic HTML provides:
- Built-in keyboard navigation
- Built-in focus management
- Built-in screen reader support
- Less code, more accessibility
Testing Library Anti-Patterns
1. Not using screen
object
screen❌ WRONG - Query from render result
const { getByRole } = render('<button>Submit</button>'); const button = getByRole('button');
✅ CORRECT - Use screen
render('<button>Submit</button>'); const button = screen.getByRole('button');
Why:
screen is consistent, no destructuring, better error messages.
2. Using querySelector
❌ WRONG - DOM implementation
const { container } = render('<button class="submit-btn">Submit</button>'); const button = container.querySelector('.submit-btn');
✅ CORRECT - Accessible query
render('<button>Submit</button>'); const button = screen.getByRole('button', { name: /submit/i });
3. Testing implementation details
❌ WRONG - Internal state
const component = new Component(); expect(component._internalState).toBe('value'); // Private implementation
✅ CORRECT - User-visible behavior
render('<div id="output"></div>'); expect(screen.getByText(/value/i)).toBeInTheDocument();
4. Not using jest-dom matchers
❌ WRONG - Manual assertions
expect(button.disabled).toBe(true); expect(element.classList.contains('active')).toBe(true);
✅ CORRECT - jest-dom matchers
expect(button).toBeDisabled(); expect(element).toHaveClass('active');
Install:
npm install -D @testing-library/jest-dom
5. Manual cleanup() calls
❌ WRONG - Manual cleanup
afterEach(() => { cleanup(); // Automatic in modern Testing Library! });
✅ CORRECT - No cleanup needed
// Cleanup happens automatically
6. Wrong assertion methods
❌ WRONG - Property access
expect(input.value).toBe('test'); expect(checkbox.checked).toBe(true);
✅ CORRECT - jest-dom matchers
expect(input).toHaveValue('test'); expect(checkbox).toBeChecked();
7. beforeEach render pattern
❌ WRONG - Shared render in beforeEach
let button; beforeEach(() => { render('<button>Submit</button>'); button = screen.getByRole('button'); // Shared state }); it('test 1', () => { // Uses shared button from beforeEach });
✅ CORRECT - Factory function per test
const renderButton = () => { render('<button>Submit</button>'); return { button: screen.getByRole('button'), }; }; it('test 1', () => { const { button } = renderButton(); // Fresh state });
For factory patterns, see
testing skill.
8. Multiple assertions in waitFor
❌ WRONG - Multiple assertions
await waitFor(() => { expect(screen.getByText(/name/i)).toBeInTheDocument(); expect(screen.getByText(/email/i)).toBeInTheDocument(); });
✅ CORRECT - Single assertion per waitFor
await waitFor(() => { expect(screen.getByText(/name/i)).toBeInTheDocument(); }); expect(screen.getByText(/email/i)).toBeInTheDocument();
9. Side effects in waitFor
❌ WRONG - Mutation in callback
await waitFor(() => { fireEvent.click(button); // Clicks multiple times! expect(result).toBe(true); });
✅ CORRECT - Side effects outside
fireEvent.click(button); await waitFor(() => { expect(result).toBe(true); });
10. Exact string matching
❌ WRONG - Fragile exact match
screen.getByText('Welcome, John Doe'); // Breaks on whitespace change
✅ CORRECT - Regex for flexibility
screen.getByText(/welcome.*john doe/i);
11. Wrong query variant for assertion
❌ WRONG - getBy for non-existence
expect(() => screen.getByText(/error/i)).toThrow();
✅ CORRECT - queryBy
expect(screen.queryByText(/error/i)).not.toBeInTheDocument();
12. Wrapping findBy in waitFor
❌ WRONG - Redundant
await waitFor(() => screen.findByText(/success/i));
✅ CORRECT - findBy already waits
await screen.findByText(/success/i);
13. Using testId when role available
❌ WRONG - testId
screen.getByTestId('submit-button');
✅ CORRECT - Role
screen.getByRole('button', { name: /submit/i });
14. Not installing ESLint plugins
Install these plugins:
npm install -D eslint-plugin-testing-library eslint-plugin-jest-dom
.eslintrc.js:
{ extends: [ 'plugin:testing-library/dom', // For framework-agnostic // OR 'plugin:testing-library/react' for React 'plugin:jest-dom/recommended', ], }
Catches anti-patterns automatically.
Summary Checklist
Before merging UI tests, verify:
- Using
as first choice for queriesgetByRole - Using
withuserEvent
(notsetup()
)fireEvent - Using
object for all queries (not destructuring from render)screen - Using
for async elements (loading, API responses)findBy* - Using
matchers (jest-dom
,toBeInTheDocument
, etc.)toBeDisabled - Testing behavior users see, not implementation details
- ESLint plugins installed (
,eslint-plugin-testing-library
)eslint-plugin-jest-dom - No manual
calls (automatic)cleanup() - MSW for API mocking (not fetch/axios mocks)
- Following TDD workflow (see
skill)tdd - Using test factories for data (see
skill)testing - For framework-specific patterns (React hooks, context, components), see
skillreact-testing