DOM Testing Library patterns for behavior-driven UI testing. Framework-agnostic patterns for testing user interfaces. Use when testing any front-end application.
For React-specific patterns (components, hooks, context), load the react-testing skill. For TDD workflow, load the tdd skill. For general testing patterns (factories, public API testing), load the testing skill.
Always prefer Vitest Browser Mode over jsdom/happy-dom. Tests run in a real browser (via Playwright), giving production-accurate behavior for CSS, events, focus management, and accessibility.
| Aspect | jsdom/happy-dom | Browser Mode |
|---|---|---|
| Environment | Simulated DOM in Node.js | Real browser (Chromium/Firefox/WebKit) |
| CSS | Not rendered | Real CSS rendering, layout, computed styles |
| Events | Synthetic JS events | CDP-based real browser events |
| APIs | Subset of Web APIs | Full browser API surface |
| Focus/a11y | Approximate | Real focus management, accessibility tree |
| Debugging | Console only | Full browser DevTools |
npm install -D vitest @vitest/browser-playwright
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser-playwright'
export default defineConfig({
test: {
browser: {
enabled: true,
provider: playwright(),
headless: true,
instances: [{ browser: 'chromium' }],
},
},
})
Quick setup wizard: npx vitest init browser
Vitest Browser Mode has built-in locators that mirror Testing Library queries. No separate @testing-library/dom import needed.
import { page } from 'vitest/browser'
// These work exactly like Testing Library queries
page.getByRole('button', { name: /submit/i })
page.getByText(/welcome/i)
page.getByLabelText(/email/i)
page.getByPlaceholder(/search/i)
page.getByAltText(/logo/i)
page.getByTestId('my-element') // Last resort only
Use expect.element() for DOM assertions — it automatically retries until the assertion passes or times out, reducing flakiness:
// ✅ CORRECT - Auto-retrying assertion
await expect.element(page.getByText(/success/i)).toBeVisible()
await expect.element(page.getByRole('button')).toBeDisabled()
// Available matchers (no @testing-library/jest-dom needed):
await expect.element(el).toBeVisible()
await expect.element(el).toBeDisabled()
await expect.element(el).toHaveTextContent(/text/i)
await expect.element(el).toHaveValue('input value')
await expect.element(el).toHaveAttribute('aria-label', 'Close')
await expect.element(el).toBeChecked()
import { userEvent } from 'vitest/browser'
// Real browser events via Chrome DevTools Protocol
await userEvent.click(page.getByRole('button', { name: /submit/i }))
await userEvent.fill(page.getByLabelText(/email/i), 'test@example.com')
await userEvent.keyboard('{Enter}')
await userEvent.selectOptions(page.getByLabelText(/country/i), 'USA')
await userEvent.clear(page.getByLabelText(/search/i))
Or use locator methods directly:
await page.getByRole('button', { name: /submit/i }).click()
await page.getByLabelText(/email/i).fill('test@example.com')
When you need both unit tests (Node) and UI tests (browser):
export default defineConfig({
test: {
projects: [
{
test: {
include: ['tests/unit/**/*.test.ts'],
name: 'unit',
environment: 'node',
},
},
{
test: {
include: ['tests/browser/**/*.test.ts'],
name: 'browser',
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium' }],
},
},
},
],
},
})
vi.spyOn on imports: ES module namespaces are sealed in real browsers. Use vi.mock('./module', { spy: true }) instead.alert()/confirm(): Thread-blocking dialogs halt browser execution. Mock them with vi.spyOn(window, 'alert').mockImplementation(() => {}).act() not needed: CDP events + expect.element() retry handle timing automatically.All Playwright-style tests MUST be idempotent. Every test must produce the same result regardless of execution order, how many times it runs, or what other tests ran before it.
Rules:
// ❌ WRONG - Tests depend on shared state
it('creates a user', async () => {
await page.getByRole('button', { name: /create/i }).click()
// Creates user "Alice" in the database
})
it('lists users', async () => {
// Assumes "Alice" exists from previous test!
await expect.element(page.getByText('Alice')).toBeVisible()
})
// ✅ CORRECT - Each test is self-contained
it('creates and displays a user', async () => {
const uniqueName = `User-${Date.now()}`
await page.getByLabelText(/name/i).fill(uniqueName)
await page.getByRole('button', { name: /create/i }).click()
await expect.element(page.getByText(uniqueName)).toBeVisible()
})
Why this matters: Browser Mode can run tests in parallel across multiple browser instances. Non-idempotent tests will produce flaky failures that are nearly impossible to debug.
The patterns below apply when using @testing-library/dom directly (e.g., with jsdom). Prefer Vitest Browser Mode for new projects — the query patterns are identical but built-in.
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), '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:
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), '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.
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));
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 queries (built-in or Testing Library)expect.element() for auto-retrying assertions (Browser Mode)userEvent for interactions (CDP-based in Browser Mode, or @testing-library/user-event)cleanup() calls (automatic)act() calls (Browser Mode handles timing)tdd skill)testing skill)react-testing skill