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-interface testing), load the testing skill.
Every hand-back states the harness and where its evidence stops. Whenever you report finished UI test work — the reply, a PR body, a CI step label — name the runner and environment that produced the evidence (Playwright against the served app, Vitest in jsdom, Browser Mode in a real browser) and, in the same breath, the nearest thing it does not prove. A jsdom suite does not prove real rendering, CSS, focus, or the browser's own event dispatch; a component-level suite does not prove the served app, its routing, or the real server; one journey does not prove the paths it never walks. "All tests pass" with the boundary left unsaid reads as a stronger claim than the tests support.
Deep-dive resources are in the resources/ directory. Load them on demand:
| Resource | Load when... |
|----------|-------------|
| resources/playwright-e2e.md | Writing or auditing Playwright Test E2E/user-journey suites against a running app — who may initiate requests, observing network without performing it, the direct-transport audit, auth/lifecycle evidence |
| resources/async-patterns.md | Using findBy/waitFor/waitForElementToBeRemoved, testing loading states, debounce, or reviewing waitFor usage |
| resources/msw.md | Mocking APIs — full setupWorker (Browser Mode) and setupServer (Node/jsdom) setup, per-test overrides |
| resources/dom-testing-library-legacy.md | Working in a jsdom/@testing-library/dom codebase — screen object, fireEvent vs userEvent, jest-dom matchers, ESLint plugins |
Test behavior users see, not implementation details. This applies in every environment — Browser Mode, jsdom, anything.
Your UI has 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 - Coupled to state implementation; breaks when state → signals → stores
it('should update internal state', () => {
const component = new CounterComponent();
component.setState({ count: 5 });
expect(component.state.count).toBe(5);
});
False positives (bugs pass tests):
// ❌ WRONG - Button exists but onClick is broken; test still passes
it('should render button', () => {
render('<button data-testid="submit-btn">Submit</button>');
expect(screen.getByTestId('submit-btn')).toBeInTheDocument();
});
✅ CORRECT - Drive the UI the way a user would, assert what the user sees: type into labelled fields, click the submit button, assert the submit handler received the form data. This survives refactors, tests the contract, and catches real bugs (broken onClick, validation errors).
Prefer Vitest Browser Mode when the claim depends on real rendering, CSS, events, focus management, accessibility, or browser APIs and the repository already supports it or the added harness cost is justified. Keep an existing stable jsdom/happy-dom harness, or use a lighter environment, when it proves pure logic or component contracts without browser-specific behaviour.
| 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 |
Inspect the repository's package manager, lockfile, existing test harness, and installed versions before changing dependencies or configuration. If a new harness is justified and the user has authorized setup, select exact mutually compatible versions from the official compatibility/peer-dependency evidence, install them with the repository package manager, and invoke only the repository-local binaries (no implicit download). For example:
<repo-pm> add --save-dev vitest@<reviewed-version> @vitest/browser-playwright@<reviewed-version>
<repo-pm> exec playwright install chromium # inspect this binary download before authorizing it
// 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' }],
},
},
})
If the reviewed installed version provides it, run the local setup wizard with
<repo-pm> exec vitest init browser; inspect its planned config writes first.
Apply the tdd skill's fast-feedback policy to browser tests too:
tdd skill and its Vitest watch reference. The Node/SSR 4.1.10 clean-start proof does not prove Browser Mode; verify its installed version/configuration independently. Do not assume --changed --watch reloads VCS impact after startup.watchTriggerPatterns, forceRerunTriggers, and root monorepo project/task graphs.playwright test --only-changed[=<real-base>] for GREEN/REFACTOR when the installed version supports it. It selects changed test files and tests that import changed files, but Playwright documents this as a heuristic. Browser journeys often exercise application code at runtime rather than importing it, and dynamic/non-import dependencies can remain invisible. For those changes, use the repository-mapped affected journey/project set, including every consumer of shared fixtures, auth state, setup, routing, styling, and UI packages; widen when uncertain.--grep, --last-failed, or hand-picked project filter only to prove RED or debug a known failure. For GREEN/REFACTOR, project filters are valid only when the runner/workspace graph mechanically derived the complete affected project set, including transitive consumers. Confirm --only-changed actually executes expected tests; an empty selection is not GREEN evidence.--passWithNoTests, or Playwright --pass-with-no-tests as evidence.--grep, or UI-only evidence is insufficient.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')
In jsdom codebases, use @testing-library/user-event instead — prefer it over
fireEvent for user interactions (see
resources/dom-testing-library-legacy.md). Create a fresh
userEvent.setup() per test by default. An isolated beforeEach is also
valid when it creates a new instance for each non-concurrent test; never reuse
one suite-global user instance.
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. vi.mock('./module', { spy: true }) works, but treat module mocking as temporary scaffolding — prefer parameter injection so the dependency is an explicit seam (load the finding-seams skill). It is never the answer for a module that makes network requests: mock that at the network with MSW, in every environment and on error paths as well as happy paths.alert()/confirm(): Thread-blocking dialogs halt browser execution. Mock them with vi.spyOn(window, 'alert').mockImplementation(() => {}).act(): Not needed for component interactions via locators — CDP events + expect.element() retry handle timing. renderHook state updates still need act (see react-testing).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:
crypto.randomUUID()); timestamps alone can collide// ❌ 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-${crypto.randomUUID()}`
try {
await page.getByLabelText(/name/i).fill(uniqueName)
await page.getByRole('button', { name: /create/i }).click()
await expect.element(page.getByText(uniqueName)).toBeVisible()
} finally {
await testData.deleteUserByName(uniqueName) // repository-owned idempotent cleanup fixture
}
})
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.
Vitest Browser Mode tests a component in isolation; Playwright Test against a running application tests whatever the test's claim names — a user journey, the frontend's own network behavior, cookie/CSRF posture, redirects, rendering. Same browser engines, different subject and harness: never assume guidance transfers between them.
One claim, one harness. Prove a claim with the lightest harness that can fail when the claim is false, and stop there. Behaviour inside one mounted component — a bug fix, an error path, a disabled button that must recover — is proved by the component-level harness; adding an E2E spec that re-walks it buys no evidence, only a second suite to maintain and a slower gate. Reach for Playwright when the claim itself is the served application: navigation, several screens in sequence, the real server, cookies, redirects. If you have already written the component test, adding the journey needs a reason you can state.
The one rule that governs E2E suites: a browser or user-journey claim must be proved by a browser initiator — an accessible locator action or a navigation — never by a direct HTTP call standing in for the user or the frontend. page.request.post('/api/...') in a test named "user creates ..." proves an HTTP contract, not a journey; it stays green when the button, cookie policy, CSRF check, redirect, or rendering breaks. Load resources/playwright-e2e.md before writing or reviewing any E2E/journey suite — it carries the decision rule, the evidence-boundary table, safe request observation, the direct-transport audit procedure, and the auth/lifecycle evidence contract.
Most critical skill: choosing the right query. Near-identical for Browser Mode locators and Testing Library queries — the two naming differences are flagged below.
Use queries in this order (accessibility-first):
getByRole - Highest priority. Queries by ARIA role + accessible name; mirrors screen reader experience; forces semantic HTMLgetByLabelText - Form fields, via associated <label>getByPlaceholder - Fallback for inputs when no label (placeholder shouldn't replace a label). Testing Library's name is getByPlaceholderTextgetByText - Non-interactive content users readgetByDisplayValue - Inputs with pre-filled values. Testing Library only — Browser Mode has no such locator; use getByRole + a value assertion insteadgetByAltText - ImagesgetByTitle - Rare, when other queries unavailablegetByTestId - Last resort only; not user-facinggetBy* - Element must exist (throws if not found). Use when asserting existence.queryBy* - Returns null if not found. Use when asserting non-existence.findBy* - Async, waits for element to appear. See resources/async-patterns.md.(Browser Mode locators are lazy and retried by expect.element(), so the get/query/find split mostly disappears — use .not.toBeInTheDocument() via expect.element for absence.)
// ❌ WRONG - querySelector (DOM implementation detail)
const button = container.querySelector('.submit-button');
// ❌ WRONG - testId when a role is available (not how users find the button)
screen.getByTestId('submit-button');
// ❌ WRONG - role without accessible name (which button? pages have many)
screen.getByRole('button');
// ✅ CORRECT - role + accessible name (how screen readers find it)
screen.getByRole('button', { name: /submit/i });
// ❌ WRONG - getBy to assert non-existence (awkward throw-based check)
expect(() => screen.getByText(/error/i)).toThrow();
// ✅ CORRECT - queryBy returns null
expect(screen.queryByText(/error/i)).not.toBeInTheDocument();
// Less specific: finds matching text without proving the element's role
screen.getByText(/welcome,\s+john doe/i);
// Preferred when this is a heading: query the accessible role and name
screen.getByRole('heading', { name: /welcome,\s+john doe/i });
Three benefits of accessible queries:
If an accessible query fails, investigate the accessible name and role first. The failure may reveal an accessibility issue, but it can also mean the query or test setup is wrong.
A query that matches more than one element is the same signal, not a licence to change query style. Resolve the ambiguity accessibly: a more specific role plus accessible name, the user-visible text of the outcome itself, filter({ hasText: /…/i }), or a container found by an accessible query (getByRole('region', { name: /…/i }), within(screen.getByRole('form', { name: /…/i }))). If nothing accessible distinguishes them, the page is missing an accessible name — add it. Never escape an ambiguous match by scoping to a class or id (page.locator('#panel').getByRole(...), within(container.querySelector('.panel'))): that re-couples the test to markup no user can perceive and buries the accessibility gap that caused the ambiguity.
Always prefer semantic HTML over ARIA:
<!-- ❌ WRONG - Custom element + ARIA -->
<div role="button" onclick="handleClick()" tabindex="0">Submit</div>
<!-- ✅ CORRECT - Semantic HTML: built-in keyboard nav, focus, screen reader support -->
<button onclick="handleClick()">Submit</button>
Add ARIA only where semantic HTML is unavailable (e.g., role="dialog" on a custom modal), never redundantly on semantic elements.
In Browser Mode, await expect.element(...) handles most waiting automatically. In jsdom codebases, use findBy* for appearance, waitFor for complex conditions, and waitForElementToBeRemoved for disappearance.
Key rules: no side effects inside waitFor; prefer one assertion per waitFor for clearer failures; never wrap findBy in waitFor.
For full patterns and anti-patterns, see resources/async-patterns.md.
Use MSW, not fetch/axios mocks — it intercepts at the network level, so the same handlers work in tests, Storybook, and dev.
This rule covers the app's own request module, not just fetch itself. Most apps wrap the transport in one module (an api/client/service file, a generated SDK). Replacing that module — vi.mock('./api'), or handing the subject a hand-written fake client — is the same anti-pattern one layer up: it deletes the real URL building, serialization, status handling and error mapping from the test, and proves nothing about the request the app actually makes. Mock the response, never the module that asks for it. This holds on failure paths too: to make a request fail, time out, or fail once and then succeed, add a per-test handler (server.use() / worker.use() with HttpResponse.error() or a non-2xx status) — never mockRejectedValueOnce on a module of yours.
Environment determines the API:
setupWorker from msw/browser (start the worker in a setup file; per-test overrides via worker.use())setupServer from msw/node (per-test overrides via server.use())Using setupServer in Browser Mode silently does nothing — tests run in a real browser. See resources/msw.md for full setup of both.
querySelector/testId when an accessible query exists — see Query Selection Priority above.// ❌ WRONG - Shared state across tests
let button;
beforeEach(() => {
render('<button>Submit</button>');
button = screen.getByRole('button');
});
// ✅ CORRECT - Factory function per test
const renderButton = () => {
render('<button>Submit</button>');
return { button: screen.getByRole('button') };
};
For factory patterns, see the testing skill.
fetch/axios, or vi.mock-ing the app's own request/api/client module (including making it reject to test an error path) — see resources/msw.md.resources/async-patterns.md.screen, fireEvent, redundant cleanup when the harness already provides it, property assertions instead of jest-dom matchers, missing ESLint plugins) — see resources/dom-testing-library-legacy.md.page.request/page.evaluate(fetch) performing work a "journey"/"browser"/"E2E" test claims the user or frontend did, or forged browser headers (Sec-Fetch-*, Origin) admitting a non-browser client — see resources/playwright-e2e.md.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)act() calls for component interactions (Browser Mode handles timing)setupWorker in Browser Mode, setupServer in Node/jsdom; no vi.mock or hand-written fake of the app's own request module, on success or failure pathstdd skill)Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer