---
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
```bash
npm install -D vitest @vitest/browser-playwright
```
```typescript
// 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`
### Built-in Locators
Vitest Browser Mode has built-in locators that mirror Testing Library queries. **No separate `@testing-library/dom` import needed.**
```typescript
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
```
### Built-in Assertions with Retry
Use `expect.element()` for DOM assertions — it **automatically retries** until the assertion passes or times out, reducing flakiness:
```typescript
// ✅ 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()
```
### Built-in User Events (CDP-based)
```typescript
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:
```typescript
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):
```typescript
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)
```typescript
// ❌ 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.
---
## 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:
1. **End-users**: Interact through the DOM (clicks, typing, reading text)
2. **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):
```typescript
// ❌ 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):
```typescript
// ❌ WRONG - Testing wrong thing
it('should render button', () => {
render('');
expect(screen.getByTestId('submit-btn')).toBeInTheDocument();
// Button exists but onClick is broken - test passes!
});
```
**Correct approach** (behavior-driven):
```typescript
// ✅ CORRECT - Testing user-visible behavior
it('should submit form when user clicks submit', async () => {
const handleSubmit = vi.fn();
const user = userEvent.setup();
render(`
`);
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):
1. **`getByRole`** - Highest priority
- Queries by ARIA role + accessible name
- Mirrors screen reader experience
- Forces semantic HTML
2. **`getByLabelText`** - Form fields
- Finds inputs by associated `