xiaohongshu
xiaohongshu
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.
Full skill instructions
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.
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).
Your UI components have two users:
Kent C. Dodds principle: "The more your tests resemble the way your software is used, the more confidence they can give you."
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), '[email protected]');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(handleSubmit).toHaveBeenCalled();
});
This test:
Most critical Testing Library skill: choosing the right query.
Use queries in this order (accessibility-first):
getByRole - Highest priority
getByLabelText - Form fields
<label>getByPlaceholderText - Fallback for inputs
getByText - Non-interactive content
getByDisplayValue - Current form values
getByAltText - Images
getByTitle - SVG titles, title attributes
getByTestId - Last resort only
Three variants for every query:
getBy* - Element must exist (throws if not found)
// ✅ Use when asserting element EXISTS
const button = screen.getByRole('button', { name: /submit/i });
expect(button).toBeDisabled();
queryBy* - Returns null if not found
// ✅ 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!
findBy* - Async, waits for element to appear
// ✅ Use when element appears after async operation
const message = await screen.findByText(/success/i);
❌ 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 getByTestId when role available
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();
Always use userEvent over fireEvent for realistic interactions.
Why userEvent is superior:
// ❌ 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 fireEvent when:
userEvent doesn't support the event (rare)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), '[email protected]');
});
// ❌ 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.
Clicking:
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /submit/i }));
Typing:
await user.type(screen.getByLabelText(/email/i), '[email protected]');
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));
UI frameworks are async by nature (state updates, API calls, suspense). Testing Library provides utilities for async scenarios.
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:
Configuration:
// Default: 1000ms timeout
const message = await screen.findByText(/success/i);
// Custom timeout
const message = await screen.findByText(/success/i, {}, { timeout: 3000 });
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:
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);
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).
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);
Mock Service Worker for API-level mocking.
Network-level interception:
// ❌ 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: [...] });
});
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' },
],
});
}),
];
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, afterEach resets to default handlers.
Three benefits:
// ❌ 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.
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>
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:
screen object❌ 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.
❌ 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 });
❌ 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();
❌ 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
❌ WRONG - Manual cleanup
afterEach(() => {
cleanup(); // Automatic in modern Testing Library!
});
✅ CORRECT - No cleanup needed
// Cleanup happens automatically
❌ WRONG - Property access
expect(input.value).toBe('test');
expect(checkbox.checked).toBe(true);
✅ CORRECT - jest-dom matchers
expect(input).toHaveValue('test');
expect(checkbox).toBeChecked();
❌ 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.
❌ 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();
❌ 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);
});
❌ WRONG - Fragile exact match
screen.getByText('Welcome, John Doe'); // Breaks on whitespace change
✅ CORRECT - Regex for flexibility
screen.getByText(/welcome.*john doe/i);
❌ WRONG - getBy for non-existence
expect(() => screen.getByText(/error/i)).toThrow();
✅ CORRECT - queryBy
expect(screen.queryByText(/error/i)).not.toBeInTheDocument();
❌ WRONG - Redundant
await waitFor(() => screen.findByText(/success/i));
✅ CORRECT - findBy already waits
await screen.findByText(/success/i);
❌ WRONG - testId
screen.getByTestId('submit-button');
✅ CORRECT - Role
screen.getByRole('button', { name: /submit/i });
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.
Before merging UI tests, verify:
getByRole as first choice for queriesuserEvent with setup() (not fireEvent)screen object for all queries (not destructuring from render)findBy* for async elements (loading, API responses)jest-dom matchers (toBeInTheDocument, toBeDisabled, etc.)eslint-plugin-testing-library, eslint-plugin-jest-dom)cleanup() calls (automatic)tdd skill)testing skill)react-testing skillxiaohongshu
technical spec
product ux expert
database patterns
Conduct multi-agent task orchestration and workflow coordination.
Initialize project with Conductor artifacts (product definition,
Expert in web animations, transitions, and motion design using Framer Motion and CSS
Creates Mermaid and ASCII diagrams for flowcharts, architecture, ERDs, state machines, mindmaps, and more. Use when user mentions diagram, flowchart, mermaid, ASCII diagram, text diagram, terminal diagram, visualize, C4, mindmap, architecture diagram, sequence diagram, ERD, or needs visual docume...
PostgreSQL bindings for H3 hexagonal grid system. Use when working with H3 cells in Postgres, including spatial indexing, geometry/geography integration, and raster analysis.
Context-Driven Development skill for projects using Conductor. Use this skill when you detect a `conductor/` directory in the project, when working on tasks defined in a `plan.md` file, or when the user asks about tracks, specs, or plans. Automatically applies TDD workflow, tracks task completion...
Display project status, active tracks, and next actions
Official Stakpak application containerization standard operating procedure, a step-by-step guidline to properly dockerize applications. This is a rule book curated by the Stakpak Team.
Generate, edit, and beat-sync AI video with leading models in one workspace.
The world's fastest calendar for remote work
Transform Your Design with AI Designer by ImgCreator.ai
Revolutionizing Video Production with AI-Powered Creativity
Extend an image past the frame and let AI fill the new aspect ratio.
Discover your celebrity doppelgänger with StarByFace!
ChainClarity explains 700+ crypto whitepapers in plain English, with layered summaries, comparisons, research tools, alerts, and a $4.99 Pro plan.
Opus.ai: Revolutionize Your Web Experience