| name | front-end-testing |
| description | Behavior-driven UI testing patterns. Covers Vitest Browser Mode (preferred) and DOM Testing Library. Use when testing any front-end application, writing UI tests, querying DOM elements, or simulating user interactions. For React-specific patterns, see the react-testing skill. |
Front-End Testing
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.
Vitest Browser Mode (Preferred)
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.
Why Browser Mode Over jsdom
| 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 |
Setup
npm install -D vitest @vitest/browser-playwright
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
Built-in Locators
Vitest Browser Mode has built-in locators that mirror Testing Library queries. No separate @testing-library/dom import needed.
import { page } from 'vitest/browser'
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')
Built-in Assertions with Retry
Use expect.element() for DOM assertions — it automatically retries until the assertion passes or times out, reducing flakiness:
await expect.element(page.getByText(/success/i)).toBeVisible()
await expect.element(page.getByRole('button')).toBeDisabled()
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()
Built-in User Events (CDP-based)
import { userEvent } from 'vitest/browser'
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')
Multi-Project Setup (Node + Browser)
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' }],
},
},
},
],
},
})
Browser Mode Gotchas
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.
Playwright / Browser Mode Test Idempotency
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:
- Each test creates its own state from scratch — never depend on another test's side effects
- Clean up any persistent state (database rows, localStorage, cookies) created during the test
- Use unique identifiers (e.g., timestamp-based) to avoid collisions when tests run in parallel
- Never assume the DOM is in a particular state at the start of a test — render fresh
- If tests share a server or database, use isolation strategies (transactions, test-specific data)
it('creates a user', async () => {
await page.getByRole('button', { name: /create/i }).click()
})
it('lists users', async () => {
await expect.element(page.getByText('Alice')).toBeVisible()
})
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.
Legacy: DOM Testing Library Patterns
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.
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):
it('should update internal state', () => {
const component = new CounterComponent();
component.setState({ count: 5 });
expect(component.state.count).toBe(5);
});
False positives (bugs pass tests):
it('should render button', () => {
render('<button data-testid="submit-btn">Submit</button>');
expect(screen.getByTestId('submit-btn')).toBeInTheDocument();
});
Correct approach (behavior-driven):
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):
-
getByRole - Highest priority
- Queries by ARIA role + accessible name
- Mirrors screen reader experience
- Forces semantic HTML
-
getByLabelText - Form fields
- Finds inputs by associated
<label>
- Ensures accessible forms
-
getByPlaceholderText - Fallback for inputs
- Only when label not present
- Placeholder shouldn't replace label
-
getByText - Non-interactive content
- Headings, paragraphs, list items
- Content users read
-
getByDisplayValue - Current form values
- Inputs with pre-filled values
-
getByAltText - Images
- Ensures accessible images
-
getByTitle - SVG titles, title attributes
- Rare, when other queries unavailable
-
getByTestId - Last resort only
- When no other query works
- Not user-facing
Query Variants
Three variants for every query:
getBy* - Element must exist (throws if not found)
const button = screen.getByRole('button', { name: /submit/i });
expect(button).toBeDisabled();
queryBy* - Returns null if not found
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(() => screen.getByRole('dialog')).toThrow();
findBy* - Async, waits for element to appear
const message = await screen.findByText(/success/i);
Common Mistakes
❌ Using container.querySelector
const button = container.querySelector('.submit-button');
✅ CORRECT - Query by accessible role
const button = screen.getByRole('button', { name: /submit/i });
❌ Using getByTestId when role available
screen.getByTestId('submit-button');
✅ CORRECT - Query by role
screen.getByRole('button', { name: /submit/i });
❌ Not using accessible names
screen.getByRole('button');
✅ CORRECT - Specify accessible name
screen.getByRole('button', { name: /submit/i });
❌ Using getBy to assert non-existence
expect(() => screen.getByText(/error/i)).toThrow();
✅ CORRECT - Use queryBy
expect(screen.queryByText(/error/i)).not.toBeInTheDocument();
User Event Simulation
Always use userEvent over fireEvent for realistic interactions.
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
fireEvent.change(input, { target: { value: 'test' } });
fireEvent.click(button);
const user = userEvent.setup();
await user.type(input, 'test');
await user.click(button);
Only use fireEvent when:
userEvent doesn't support the event (rare)
- Testing non-standard browser behavior
userEvent.setup() Pattern
Modern best practice (2025):
it('should handle user input', async () => {
const user = userEvent.setup();
render('<input aria-label="Email" />');
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
});
let user;
beforeEach(() => {
user = userEvent.setup();
});
it('test 1', async () => {
await user.click(...);
});
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}');
await user.keyboard('{Shift>}A{/Shift}');
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):
const message = await screen.findByText(/success/i);
When to use:
- Element appears after async operation
- Loading states disappear
- API responses render content
Configuration:
const message = await screen.findByText(/success/i);
const message = await screen.findByText(/success/i, {}, { timeout: 3000 });
waitFor Utility
For complex conditions that findBy can't handle:
await waitFor(() => {
expect(screen.getByText(/loaded/i)).toBeInTheDocument();
});
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);
expect(result).toBe(true);
});
✅ CORRECT - Only assertions
fireEvent.click(button);
await waitFor(() => {
expect(result).toBe(true);
});
❌ 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();
❌ Wrapping findBy in waitFor
await waitFor(() => screen.findByText(/success/i));
✅ CORRECT - findBy already waits
await screen.findByText(/success/i);
waitForElementToBeRemoved
For disappearance scenarios:
await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));
await waitForElementToBeRemoved(() => screen.queryByRole('dialog'));
Note: Must use queryBy* (returns null) not getBy* (throws).
Common Patterns
Loading states:
render('<div id="container"></div>');
const container = document.getElementById('container');
container.innerHTML = '<p>Loading...</p>';
expect(screen.getByText(/loading/i)).toBeInTheDocument();
setTimeout(() => {
container.innerHTML = '<p>John Doe</p>';
}, 100);
await screen.findByText(/john doe/i);
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 }));
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');
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
vi.spyOn(global, 'fetch').mockResolvedValue({
json: async () => ({ users: [...] }),
});
http.get('/api/users', () => {
return HttpResponse.json({ users: [...] });
});
setupServer Pattern
In test setup file:
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:
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 () => {
server.use(
http.get('/api/users', () => {
return HttpResponse.json(
{ error: 'Server error' },
{ status: 500 }
);
})
);
render('<div id="user-list"></div>');
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.
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
screen.getByTestId('user-menu');
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):
<button role="button">Submit</button>
<button>Submit</button>
Semantic HTML Priority
Always prefer semantic HTML over ARIA:
<div role="button" onclick="handleClick()" tabindex="0">
Submit
</div>
<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
❌ 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');
✅ 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();
});
✅ CORRECT - No cleanup needed
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');
});
it('test 1', () => {
});
✅ CORRECT - Factory function per test
const renderButton = () => {
render('<button>Submit</button>');
return {
button: screen.getByRole('button'),
};
};
it('test 1', () => {
const { button } = renderButton();
});
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);
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');
✅ 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',
'plugin:jest-dom/recommended',
],
}
Catches anti-patterns automatically.
Summary Checklist
Before merging UI tests, verify: