An exhaustive, production-grade masterclass from absolute zero to staff-level Testing Library architecture.
Master user-centric queries, user-event, async utilities, accessible selectors, network mocking with MSW, and testing React, Vue and Angular components the way real people use them.
Getting Started β’ Queries & Selectors β’ Simulating Users β’ Async & Mocking β’ Framework Adapters β’ Production Architecture β’ Staff Handbook
flowchart LR
S1["Stage 1: Guiding Principle & First Test"] --> S2["Stage 2: Queries & Accessible Selectors"]
S2 --> S3["Stage 3: user-event & fireEvent"]
S3 --> S4["Stage 4: Async Utilities, act & MSW"]
S4 --> S5["Stage 5: React, Vue & Angular Adapters"]
S5 --> S6["Stage 6: Test Architecture, Debugging & CI"]
S6 --> S7["Stage 7: Staff Quality Architect Handbook & Cheatsheet"]
| Stage | Domain Focus | Core Concepts & Production Outcomes |
|---|---|---|
| Stage 1 | Guiding Principle & Toolchain | Behaviour over implementation details, the package family (@testing-library/dom, /react, /vue, /angular, /user-event, /jest-dom), Vitest + jsdom setup, render and screen. |
| Stage 2 | Queries & Accessible Selectors | getBy / queryBy / findBy semantics, query priority, ByRole with name, level, checked, expanded, TextMatch, within, jest-dom matchers. |
| Stage 3 | Simulating Real Users | userEvent.setup(), pointer and keyboard sessions, type, clear, tab, selectOptions, upload, clipboard, and when fireEvent is still the right tool. |
| Stage 4 | Async Utilities & Network Mocking | findBy, waitFor, waitForElementToBeRemoved, act() warnings explained, fake timers with advanceTimers, MSW http handlers and setupServer. |
| Stage 5 | Framework Adapters | React render options, wrapper, rerender, renderHook; Vue props/slots/global.plugins; Angular inputs, providers and standalone components. |
| Stage 6 | Production Test Architecture | Custom render with providers, screen.debug, logRoles, configure, eslint-plugin-testing-library, coverage gates, Docker CI, browser mode and E2E boundaries. |
| Stage 7 | Staff Quality Architect Handbook | 40 staff-level scenario Q&As, flake root causes, enterprise cheatsheet, and the trade-offs behind each recommendation. |
- 1. Stage 1: Absolute Beginner Foundations & The Guiding Principle
- 2. Stage 2: Queries Deep Dive & Accessible Selectors
- 3. Stage 3: Simulating Users: user-event & fireEvent
- 4. Stage 4: Async Utilities, act & Network Mocking
- 5. Stage 5: Framework Adapters: React, Vue & Angular
- 6. Stage 6: Production Test Architecture, Debugging & CI
- 7. Stage 7: Staff Frontend Quality Architect Interview Handbook & Cheatsheet
- π€ Contributing
- π License
Most UI test suites fail for the wrong reasons. A developer renames a CSS class, splits a component in two, or swaps a useState for a reducer, and dozens of tests go red even though nothing a user can see has changed. The opposite also happens: a button loses its label, a form stops submitting on Enter, and every test stays green because the tests only poked at component internals.
Testing Library is built around one idea: the more a test resembles the way the software is used, the more confidence it gives you. Users do not know about props, state or component instances. They find things by what they read on screen and what assistive technology announces, then they click, type and wait. So the library gives you:
- Queries that mirror human perception: find an element by its role and accessible name, its label, or its visible text, not by class names or component tree position.
- Interaction helpers that mirror real input:
user-eventdispatches the full sequence of events a browser fires for a click or a keystroke. - Async helpers that mirror waiting:
findByandwaitForretry until the UI reaches the expected state, the same way a person waits for a spinner to go away. - No access to internals: there is no
instance(), nostate(), no shallow rendering. If you need those to write a test, the test is probably coupled to implementation details.
Implementation-detail test Testing Library test
ββββββββββββββββββββββββββ ββββββββββββββββββββ
wrapper.find('.btn-primary') screen.getByRole('button', { name: 'Save' })
wrapper.setState({ open: true }) await user.click(toggle)
expect(wrapper.state('items')).toHaveLength(3)
expect(screen.getAllByRole('listitem')).toHaveLength(3)
Breaks on: class rename, refactor Breaks on: label removed, role wrong,
Passes when: label is missing behaviour actually broken
A useful side effect is that tests written this way double as a lightweight accessibility check. If getByRole('button', { name: 'Save' }) cannot find your button, a screen reader user probably cannot either.
Testing Library is a set of small packages layered on a shared core.
graph TD
subgraph Core
DOM["@testing-library/dom<br/>queries, waitFor, fireEvent, configure"]
end
subgraph Adapters
R["@testing-library/react"]
V["@testing-library/vue"]
A["@testing-library/angular"]
S["@testing-library/svelte, /preact<br/>and @solidjs/testing-library"]
end
subgraph Companions
UE["@testing-library/user-event<br/>realistic interaction"]
JD["@testing-library/jest-dom<br/>DOM matchers"]
end
DOM --> R
DOM --> V
DOM --> A
DOM --> S
UE --> DOM
JD -.-> Runner["Vitest / Jest expect"]
| Package | Responsibility |
|---|---|
@testing-library/dom |
Framework-agnostic queries, waitFor, fireEvent, within, prettyDOM, configure. Every adapter re-exports it. |
@testing-library/react |
render, renderHook, cleanup, act integration. Since v16, @testing-library/dom is a peer dependency you install yourself. |
@testing-library/vue |
render for Vue 3 single-file components, with props, slots and global mounting options. |
@testing-library/angular |
render that builds a TestBed for a component, with inputs, providers and imports. |
@testing-library/user-event |
Simulates full interactions (pointer, keyboard, clipboard) on top of the DOM. |
@testing-library/jest-dom |
Custom matchers such as toBeInTheDocument, toBeDisabled, toHaveAccessibleName. Works with Jest and Vitest. |
The runner (Vitest or Jest) provides describe, it, expect and mocking. The DOM itself comes from jsdom or happy-dom, which are JavaScript implementations of browser APIs that run in Node.
The companion project in this repository uses Vite, Vitest and React 19. Here is the minimal setup from scratch.
# 1. Runtime dependencies
npm install react react-dom
# 2. Test toolchain
npm install -D vitest jsdom @vitejs/plugin-react vite typescript
# 3. Testing Library packages (dom is a peer dependency of the React adapter)
npm install -D @testing-library/react @testing-library/dom \
@testing-library/user-event @testing-library/jest-dom
# 4. Types
npm install -D @types/react @types/react-dom// vite.config.ts
/// <reference types="vitest/config" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
// 1. Give every test file a browser-like document
environment: 'jsdom',
// 2. Expose describe/it/expect globally (also enables RTL auto-cleanup)
globals: true,
// 3. Register jest-dom matchers once for the whole suite
setupFiles: ['./tests/setup.ts'],
include: ['tests/**/*.test.tsx'],
// 4. Reset vi.spyOn / vi.fn implementations between tests
restoreMocks: true,
},
});// tests/setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup(); // unmount everything rendered in the previous test
});Add "types": ["vitest/globals", "@testing-library/jest-dom/vitest"] to tsconfig.json so the editor knows about the global test functions and the extra matchers.
Jest users: the same packages work. Install
jest,jest-environment-jsdomand a transformer (babel-jestorts-jest), settestEnvironment: 'jsdom', and import@testing-library/jest-domin a file listed undersetupFilesAfterEnv, which runs once the test framework is installed in the environment.
Take a disclosure widget: a button that shows and hides a panel.
// src/components/Disclosure.tsx
import { useId, useState, type ReactNode } from 'react';
export function Disclosure({ summary, children }: { summary: string; children: ReactNode }) {
const [open, setOpen] = useState(false);
const panelId = useId();
return (
<div>
<button type="button" aria-expanded={open} aria-controls={panelId} onClick={() => setOpen((v) => !v)}>
{summary}
</button>
<div id={panelId} role="region" aria-label={summary} hidden={!open}>
{children}
</div>
</div>
);
}// tests/disclosure.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Disclosure } from '../src/components/Disclosure';
test('reveals the panel when the button is pressed', async () => {
// 1. Arrange: render into a fresh container attached to document.body
const user = userEvent.setup();
render(
<Disclosure summary="Shipping details">
<p>Ships in two days</p>
</Disclosure>,
);
// 2. Find the control the way a screen reader user would
const button = screen.getByRole('button', { name: 'Shipping details' });
expect(button).toHaveAttribute('aria-expanded', 'false');
// 3. Act like a user
await user.click(button);
// 4. Assert on what the user can now perceive
expect(button).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('region', { name: 'Shipping details' })).toHaveTextContent('Ships in two days');
});Three things to notice:
screenis bound todocument.body. Using it instead of destructuring queries fromrender()means you never have to update the destructuring when you add a query, and the test reads top to bottom.hiddenelements are invisible to role queries. Before the click,queryByRole('region')returnsnullbecause thehiddenattribute removes the panel from the accessibility tree. That mirrors what assistive technology sees.- Every
usercall is awaited.user-eventv14 is fully asynchronous.
Run it:
npx vitest run # single pass, CI style
npx vitest # watch mode with instant re-runs
npx vitest run -t "reveals the panel" # filter by test nameEvery query comes in six forms. The suffix (ByRole, ByLabelText, ...) decides how to match; the prefix decides what happens when the match count is wrong.
| Prefix | 0 matches | 1 match | 2+ matches | Async | Use it for |
|---|---|---|---|---|---|
getBy |
throws | returns element | throws | no | Elements that must be present right now |
queryBy |
returns null |
returns element | throws | no | Asserting something is absent |
findBy |
rejects after timeout | resolves element | rejects | yes | Elements that will appear after async work |
getAllBy |
throws | array | array | no | Lists that must have at least one item |
queryAllBy |
[] |
array | array | no | Counting items that may be zero |
findAllBy |
rejects after timeout | array | array | yes | Lists that load asynchronously |
// Present now
const heading = screen.getByRole('heading', { name: 'Team members' });
// Must NOT be present: getBy would throw before the assertion runs
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
// Will appear after a fetch resolves (default timeout 1000 ms)
const firstRow = await screen.findByRole('row', { name: /ada lovelace/i });
// Lists
expect(screen.getAllByRole('listitem')).toHaveLength(3);
expect(screen.queryAllByRole('listitem')).toHaveLength(0);The error messages are a feature. When getByRole fails it prints the accessible roles currently on the page and a formatted DOM snapshot, which is usually enough to see why the match failed.
Testing Library recommends queries in this order. Higher entries reflect how all users experience the page; lower entries are escape hatches.
flowchart TD
A["1. getByRole<br/>role + accessible name"] --> B["2. getByLabelText<br/>form fields"]
B --> C["3. getByPlaceholderText<br/>only if no label exists"]
C --> D["4. getByText<br/>non-interactive content"]
D --> E["5. getByDisplayValue<br/>current value of filled inputs"]
E --> F["6. getByAltText / getByTitle<br/>images, tooltips"]
F --> G["7. getByTestId<br/>last resort"]
ByRolecovers nearly everything interactive: buttons, links, checkboxes, textboxes, headings, dialogs, tabs. Thenameoption matches the accessible name, which is computed from the label,aria-label,aria-labelledbyor content.ByLabelTextfinds form controls through their<label>,aria-labelledbyoraria-label. If a field is only findable by placeholder, it has an accessibility bug.ByPlaceholderTextexists, but a placeholder is not a label: it disappears as soon as someone types.ByTextsuits paragraphs, list items and other static content.ByDisplayValuefinds aninput,selectortextareaby its current value, handy for pre-filled edit forms.ByAltTextandByTitlematch thealtandtitleattributes.ByTestIdmatchesdata-testid. Use it for elements with no semantic hook, like a canvas chart wrapper, or when text is dynamic and translated.
A rough rule: if more than about 5% of your queries are ByTestId, the markup is probably missing labels and roles that real users also need.
ByRole matches both explicit roles (role="tab") and implicit roles that HTML elements carry by default.
| Element | Implicit role |
|---|---|
<button>, <input type="submit"> |
button |
<a href="..."> |
link (no href means no role) |
<input type="text">, <textarea> |
textbox |
<input type="search"> |
searchbox |
<input type="checkbox"> |
checkbox |
<input type="radio"> |
radio |
<select> |
combobox (single) or listbox (multiple or with size > 1) |
<h1> to <h6> |
heading with level 1 to 6 |
<ul>, <ol> / <li> |
list / listitem |
<table>, <tr>, <td>, <th> |
table, row, cell, columnheader / rowheader |
<nav>, <main> |
navigation, main |
<form> |
form only when it has an accessible name |
<section> |
region only when it has an accessible name |
<dialog> |
dialog |
<img alt="..."> |
img (alt="" makes it presentation) |
The options let you assert state the way assistive technology reports it:
// Accessible name, exact string or regex
screen.getByRole('button', { name: 'Delete invoice' });
screen.getByRole('link', { name: /privacy policy/i });
// Headings by level
screen.getByRole('heading', { level: 1 });
// Widget state
screen.getByRole('checkbox', { name: 'Remember me', checked: true });
screen.getByRole('button', { name: 'Bold', pressed: true });
screen.getByRole('button', { name: 'Filters', expanded: false });
screen.getByRole('tab', { name: 'Billing', selected: true });
screen.getByRole('link', { name: 'Dashboard', current: 'page' });
// Accessible description (aria-describedby)
screen.getByRole('textbox', { description: /at least 8 characters/i });
// Include elements removed from the accessibility tree (display:none, hidden, aria-hidden)
screen.getAllByRole('listitem', { hidden: true });How the accessible name is computed matters when a query unexpectedly fails:
<button aria-label="Close dialog"><svg>...</svg></button> name = "Close dialog"
<button><span aria-hidden="true">Γ</span> Close</button> name = "Close"
<label for="e">Email</label><input id="e"> name = "Email"
<input aria-labelledby="h1 h2"> name = text of #h1 + " " + text of #h2
<input placeholder="Search"> name = "Search" (fallback, but avoid)
ByRole walks the DOM and computes styles to decide what is inaccessible, so it is the slowest query. On very large trees, scope it with within(container) rather than replacing it with test IDs.
Every text-based query accepts a TextMatch: a string, a regular expression or a function.
// 1. String: exact, full-content match after whitespace normalisation
screen.getByText('Ships in two days');
// 2. Substring, case-insensitive
screen.getByText('two days', { exact: false });
// 3. Regex gives precise control
screen.getByText(/^order #\d+ confirmed$/i);
// 4. Function: (content, element) => boolean, for text split across nodes
screen.getByText((_content, element) =>
element?.tagName === 'P' && element.textContent === 'Total: $42.00',
);
// 5. Custom normaliser: strip zero-width spaces a CMS inserts
import { getDefaultNormalizer } from '@testing-library/react';
screen.getByText('Welcome back', {
normalizer: (text) => getDefaultNormalizer({ trim: true })(text.replace(/β/g, '')),
});The default normaliser trims and collapses runs of whitespace into a single space, so indentation in JSX does not break matches.
within scopes queries to a subtree. It is how you assert "the Delete button in the row for Ada" without relying on DOM order.
import { screen, within } from '@testing-library/react';
const row = screen.getByRole('row', { name: /ada lovelace/i });
await user.click(within(row).getByRole('button', { name: 'Delete' }));
const dialog = await screen.findByRole('dialog', { name: 'Confirm deletion' });
await user.click(within(dialog).getByRole('button', { name: 'Delete' }));@testing-library/jest-dom adds matchers that read like requirements and give clear failure messages.
| Matcher | Asserts |
|---|---|
toBeInTheDocument() |
Element is attached to the document |
toBeVisible() |
Not hidden by display, visibility, opacity: 0, hidden or a hidden ancestor |
toBeDisabled() / toBeEnabled() |
Disabled state, including inherited from <fieldset disabled> |
toBeRequired(), toBeInvalid(), toBeValid() |
Form validity and aria-invalid |
toBeChecked(), toBePartiallyChecked() |
Checkbox, radio and aria-checked state |
toHaveFocus() |
document.activeElement is this element |
toHaveTextContent(text | regex) |
Text content contains a match |
toHaveValue(v), toHaveDisplayValue(v) |
Form control value |
toHaveFormValues({...}) |
Values of every named field in a form |
toHaveAttribute(name, value?) |
Attribute presence and value |
toHaveClass(...), toHaveStyle({...}) |
Classes and computed style (use sparingly) |
toHaveAccessibleName(n), toHaveAccessibleDescription(d) |
Computed accessible name and description |
toHaveAccessibleErrorMessage(m) |
Message linked by aria-errormessage |
toBeEmptyDOMElement() |
No child nodes besides comments |
const email = screen.getByLabelText('Email');
expect(email).toBeInvalid();
expect(email).toHaveAccessibleDescription('Enter a valid email address');
expect(screen.getByRole('form', { name: 'Sign in' })).toHaveFormValues({ email: 'ada@example.com' });
toHaveFormValuesreads controls by theirnameattribute, so it only sees fields that have one.
fireEvent dispatches exactly one DOM event. A real click is not one event.
fireEvent.click(button) user.click(button)
βββββββββββββββββββββββ ββββββββββββββββββ
click pointerover β pointerenter β mouseover β mouseenter
pointermove β mousemove
pointerdown β mousedown β focus (moves focus)
pointerup β mouseup β click
user-event also enforces browser rules:
- Disabled elements ignore input. Clicking a disabled button does nothing, as in a browser.
fireEvent.clickdispatches the event regardless, so whether a handler runs depends on the framework rather than on browser rules. pointer-events: noneblocks clicks. It checks computed styles and throws, which catches overlays that cover a button.- Typing respects focus, selection and
maxLength.user.typeclicks the field, then types one character at a time, firingkeydown,keypress,beforeinput,inputandkeyupfor each. - Tab order follows the DOM and
tabindex.user.tab()moves focus the way the Tab key does.
Reach for fireEvent only when no user gesture produces the event: a scroll on a virtualised list, a transitionend, a custom element event, or a resize.
Since v14, call userEvent.setup() once per test, before rendering, and use the returned instance. The session shares keyboard and pointer state across calls, so held modifier keys and pointer position carry over just as they would for a person.
import type { ReactElement } from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
// 1. A setup helper keeps every test consistent
function setup(ui: ReactElement, options?: Parameters<typeof userEvent.setup>[0]) {
return { user: userEvent.setup(options), ...render(ui) };
}
test('selects a plan and confirms', async () => {
const { user } = setup(<PlanPicker />);
// 2. Pointer
await user.click(screen.getByRole('radio', { name: 'Pro' }));
await user.dblClick(screen.getByText('Plan details'));
await user.hover(screen.getByRole('button', { name: 'Help' }));
// 3. Select boxes accept values, labels or option elements
await user.selectOptions(screen.getByRole('combobox', { name: 'Billing cycle' }), 'Yearly');
// 4. Text
await user.type(screen.getByRole('textbox', { name: 'Coupon' }), 'SPRING26');
await user.clear(screen.getByRole('textbox', { name: 'Coupon' }));
await user.click(screen.getByRole('button', { name: 'Confirm' }));
expect(await screen.findByRole('status')).toHaveTextContent('Pro plan active');
});Useful setup() options:
| Option | Default | Purpose |
|---|---|---|
delay |
0 |
Milliseconds between actions. null skips the setTimeout entirely. |
advanceTimers |
undefined |
Function to advance fake timers, e.g. vi.advanceTimersByTime. |
pointerEventsCheck |
PointerEventsCheckLevel.EachApiCall |
How often to verify pointer-events. Lower it for speed in huge trees. |
skipHover |
false |
Skip the hover events that precede a click. |
applyAccept |
true |
Have upload respect the input's accept attribute. |
writeToClipboard |
true with setup() (false for the direct API) |
Write copied or cut data into the stubbed navigator.clipboard. |
The top-level shortcuts (userEvent.click(el)) still exist and call setup() internally each time, which loses state between calls. Prefer the session.
user.keyboard takes a compact descriptor language.
// Plain characters
await user.keyboard('hello');
// Named keys in braces (KeyboardEvent.key)
await user.keyboard('{Enter}');
await user.keyboard('{ArrowDown}{ArrowDown}{Enter}');
// Physical keys in brackets (KeyboardEvent.code)
await user.keyboard('[ShiftLeft]');
// Hold and release: ">" keeps it pressed, "/" releases it
await user.keyboard('{Shift>}{ArrowRight}{ArrowRight}{/Shift}'); // extend selection by 2
await user.keyboard('{Control>}a{/Control}'); // select allFocus management and keyboard-only flows are first-class:
test('menu is fully operable with the keyboard', async () => {
const user = userEvent.setup();
render(<ActionsMenu />);
await user.tab();
expect(screen.getByRole('button', { name: 'Actions' })).toHaveFocus();
await user.keyboard('{Enter}');
const menu = screen.getByRole('menu', { name: 'Actions' });
expect(within(menu).getByRole('menuitem', { name: 'Rename' })).toHaveFocus();
await user.keyboard('{ArrowDown}');
expect(within(menu).getByRole('menuitem', { name: 'Duplicate' })).toHaveFocus();
await user.keyboard('{Escape}');
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Actions' })).toHaveFocus(); // focus returns
});Clipboard and file inputs:
test('pastes a tracking number and uploads a receipt', async () => {
const user = userEvent.setup();
render(<ReturnForm />);
// Clipboard: user-event stubs navigator.clipboard for the session
await user.click(screen.getByLabelText('Tracking number'));
await user.paste('1Z999AA10123456784');
expect(screen.getByLabelText('Tracking number')).toHaveValue('1Z999AA10123456784');
// Upload: File objects built in memory
const receipt = new File(['%PDF-1.7'], 'receipt.pdf', { type: 'application/pdf' });
const input = screen.getByLabelText('Receipt');
await user.upload(input, receipt);
expect((input as HTMLInputElement).files?.[0]).toBe(receipt);
expect(screen.getByText('receipt.pdf')).toBeInTheDocument();
});For drag gestures and precise pointer sequences, user.pointer accepts an array of actions such as [{ keys: '[MouseLeft>]', target: handle }, { coords: { x: 200 } }, { keys: '[/MouseLeft]' }]. Keep in mind that jsdom performs no layout, so coordinate-based logic needs a real browser (see 6.5).
The companion project's LoginForm validates input, shows an announced error, disables the button while submitting and recovers on failure. The test covers each user-visible behaviour without touching state.
// tests/components.test.tsx (excerpt)
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from '../src/components/LoginForm';
describe('LoginForm', () => {
it('announces a validation error and wires it to the field', async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText('Email'), 'not-an-email');
await user.click(screen.getByRole('button', { name: 'Sign in' }));
expect(screen.getByRole('alert')).toHaveTextContent('Enter a valid email address');
expect(screen.getByLabelText('Email')).toHaveAttribute('aria-invalid', 'true');
expect(screen.getByLabelText('Email')).toHaveAccessibleDescription('Enter a valid email address');
expect(onSubmit).not.toHaveBeenCalled();
});
it('disables the button while pending and reports failure', async () => {
let reject!: (reason: Error) => void;
const onSubmit = vi.fn(() => new Promise<void>((_, r) => (reject = r)));
const user = userEvent.setup();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText('Email'), 'ada@example.com');
await user.type(screen.getByLabelText('Password'), 'correct-horse');
await user.click(screen.getByRole('button', { name: 'Sign in' }));
// The promise is still pending: the UI should show progress
expect(screen.getByRole('button', { name: /signing in/i })).toBeDisabled();
reject(new Error('401'));
// findBy waits for React to process the rejection
expect(await screen.findByRole('alert')).toHaveTextContent('Sign in failed');
expect(screen.getByRole('button', { name: 'Sign in' })).toBeEnabled();
});
it('can be completed with the keyboard alone', async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={onSubmit} />);
await user.tab();
await user.keyboard('ada@example.com');
await user.tab();
await user.keyboard('correct-horse{Enter}');
expect(onSubmit).toHaveBeenCalledOnce();
});
});The deferred promise pattern (let reject!: ...) is the cleanest way to hold the UI in its pending state and assert on it, then release it on your schedule.
Most UIs update asynchronously: data arrives, a debounce fires, a transition finishes. Testing Library's async utilities poll until a condition holds or a timeout expires.
sequenceDiagram
participant T as Test
participant W as waitFor
participant D as DOM
T->>W: waitFor(callback)
loop every 50 ms and on each DOM mutation
W->>D: run callback
alt callback throws
D-->>W: not ready yet
else callback returns
D-->>W: success
W-->>T: resolve
end
end
W-->>T: reject with last error after 1000 ms
| Utility | Resolves when | Defaults |
|---|---|---|
findBy* |
The query finds exactly one match (findAllBy*: at least one) |
timeout: 1000, interval: 50 |
waitFor(cb) |
cb stops throwing (or its returned promise resolves) |
timeout: 1000, interval: 50, also re-runs on DOM mutations |
waitForElementToBeRemoved(el | cb) |
The element is gone. Throws immediately if it is not present at the start. | Same as waitFor |
test('loads, then lists users', async () => {
render(<UserList loadUsers={vi.fn().mockResolvedValue(users)} />);
// 1. The loading state is synchronous: assert it immediately
expect(screen.getByRole('status')).toHaveTextContent('Loading users');
// 2. Wait for it to disappear
await waitForElementToBeRemoved(() => screen.queryByRole('status'));
// 3. Now the data is in
expect(screen.getAllByRole('listitem')).toHaveLength(3);
});
test('a non-DOM side effect eventually happens', async () => {
const user = userEvent.setup();
const track = vi.fn();
render(<Checkout onTrack={track} />);
await user.click(screen.getByRole('button', { name: 'Pay' }));
// waitFor is for assertions that have no element to find
await waitFor(() => expect(track).toHaveBeenCalledWith('payment_submitted'));
});Rules that prevent most flaky async tests:
- Prefer
findByoverwaitFor(() => getBy...). It is shorter and produces better errors. - One assertion per
waitFor. If you put threeexpects inside, a failure on the first hides the state of the others, and the callback reruns all of them on every tick. - No side effects inside
waitFor. The callback may run dozens of times. Clicking or typing inside it repeats the action unpredictably. - Do not wrap
findByorusercalls inwaitFor. They already wait. - Raise the timeout per call, not globally, when a specific flow is slow:
findByText('Report ready', {}, { timeout: 3000 }).
React batches state updates and flushes effects asynchronously. act() tells React "run everything pending, then let me assert". React Testing Library already wraps render, fireEvent, user-event interactions (through the DOM library's event wrapper) and the async utilities in act, so you should almost never call it yourself.
The warning "An update to X inside a test was not wrapped in act(...)" means a state update happened after your test stopped watching. The fix is almost always to wait for the outcome, not to add act.
// β The fetch resolves after the test ends; React warns
test('renders users', () => {
render(<UserList loadUsers={api.load} />);
expect(screen.getByRole('status')).toBeInTheDocument();
});
// β
Wait for the UI the update produces
test('renders users', async () => {
render(<UserList loadUsers={api.load} />);
expect(await screen.findByRole('list')).toBeInTheDocument();
});When you do need act directly, for example to resolve a promise that drives a component from outside, import it from React (import { act } from 'react') and await the async form:
await act(async () => {
deferred.resolve(users);
});Since React 18.3, act from react-dom/test-utils is deprecated in favour of the act exported from react, and React 19 removed the rest of react-dom/test-utils. RTL re-exports a compatible act, so imports from @testing-library/react keep working.
Debounced inputs, toasts that auto-dismiss and polling loops depend on time. Fake timers make them fast and deterministic, but user-event waits between actions with setTimeout, so it must know how to move your fake clock forward.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { SearchBox } from '../src/components/SearchBox';
beforeEach(() => {
// shouldAdvanceTime lets waitFor/findBy polling progress with real time too
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
test('queries the API once, 300 ms after the user stops typing', async () => {
const search = vi.fn().mockResolvedValue([]);
// 1. Wire user-event to the fake clock
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<SearchBox onSearch={search} debounceMs={300} />);
await user.type(screen.getByRole('searchbox', { name: 'Search' }), 'kafka');
expect(search).not.toHaveBeenCalled();
// 2. Jump past the debounce window
await vi.advanceTimersByTimeAsync(300);
expect(search).toHaveBeenCalledTimes(1);
expect(search).toHaveBeenCalledWith('kafka');
});With Jest the equivalent is jest.useFakeTimers() and userEvent.setup({ advanceTimers: jest.advanceTimersByTime }). DOM Testing Library detects Jest's fake timers automatically and advances them inside waitFor; with Vitest, shouldAdvanceTime: true (or advancing manually) gives the same effect.
Mocking fetch or an API module directly couples tests to how the code calls the network. Mock Service Worker (MSW) intercepts requests at the network layer instead, so the component code is exactly what ships.
flowchart LR
C["Component"] -->|fetch /api/users| I["MSW interceptor<br/>(msw/node in tests)"]
I -->|matches handler| H["http.get('/api/users')"]
H -->|HttpResponse.json| C
I -.->|no handler: error| X["onUnhandledRequest: 'error'"]
// tests/msw/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users', () =>
HttpResponse.json([
{ id: 1, name: 'Ada Lovelace', email: 'ada@example.com' },
{ id: 2, name: 'Grace Hopper', email: 'grace@example.com' },
]),
),
http.post('/api/login', async ({ request }) => {
const body = (await request.json()) as { email: string };
return body.email.endsWith('@example.com')
? HttpResponse.json({ token: 'test-token' })
: HttpResponse.json({ message: 'Unknown account' }, { status: 401 });
}),
];// tests/msw/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);// tests/setup.ts (additions)
import { afterAll, afterEach, beforeAll } from 'vitest';
import { server } from './msw/server';
// 1. Fail loudly on any request without a handler
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
// 2. Drop per-test overrides so tests stay isolated
afterEach(() => server.resetHandlers());
// 3. Release the interceptor
afterAll(() => server.close());// Per-test override for the error path
import { http, HttpResponse } from 'msw';
import { server } from './msw/server';
test('shows an alert when the API fails', async () => {
server.use(http.get('/api/users', () => HttpResponse.json(null, { status: 500 })));
render(<UserList loadUsers={fetchUsers} />);
expect(await screen.findByRole('alert')).toHaveTextContent('status 500');
});Node's own fetch rejects relative URLs, but MSW's fetch interceptor resolves them against location.href when a location exists. Vitest's jsdom environment sets it to http://localhost:3000/ by default, so fetch('/api/users') reaches the /api/users handler. If your client builds absolute URLs from an environment variable, match that host in the handler.
render returns helpers bound to the rendered container plus every query bound to document.body.
const {
container, // the div your component was rendered into
baseElement, // document.body by default; portals render here
rerender, // render new props into the same root
unmount, // trigger cleanup effects
asFragment, // DocumentFragment snapshot of the current output
debug, // pretty-print the DOM
} = render(<Price amount={10} currency="EUR" />, {
wrapper: Providers, // wrap with context providers
container: undefined, // custom container element
baseElement: undefined, // custom root for queries and portals
});Providers via wrapper. Components that read context need the same providers they get in the app.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router';
function Providers({ children }: { children: React.ReactNode }) {
// A fresh client per render keeps cache state from leaking between tests
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return (
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={['/team']}>{children}</MemoryRouter>
</QueryClientProvider>
);
}
render(<TeamPage />, { wrapper: Providers });rerender for prop changes.
const { rerender } = render(<Price amount={10} currency="EUR" />);
expect(screen.getByText('β¬10.00')).toBeInTheDocument();
rerender(<Price amount={12.5} currency="USD" />);
expect(screen.getByText('$12.50')).toBeInTheDocument();renderHook for reusable hooks. Test hooks through a component when you can; use renderHook for hooks that are a library's public API.
import { act, renderHook } from '@testing-library/react';
import { useCounter } from '../src/hooks/useCounter';
test('useCounter respects the max', () => {
const { result, rerender } = renderHook(({ max }) => useCounter({ max }), {
initialProps: { max: 2 },
});
act(() => result.current.increment());
act(() => result.current.increment());
act(() => result.current.increment());
expect(result.current.count).toBe(2);
rerender({ max: 5 });
act(() => result.current.increment());
expect(result.current.count).toBe(3);
});Here act is needed because nothing else (no event, no async utility) is driving the update.
Unmount behaviour. The companion project checks that UserList aborts its request when unmounted, which prevents state updates on dead components:
it('aborts the request when unmounted', () => {
const loadUsers = vi.fn((_signal?: AbortSignal) => new Promise<User[]>(() => {}));
const { unmount } = render(<UserList loadUsers={loadUsers} />);
const signal = loadUsers.mock.calls[0][0];
expect(signal?.aborted).toBe(false);
unmount();
expect(signal?.aborted).toBe(true);
});@testing-library/vue (v8 targets Vue 3) wraps @vue/test-utils and exposes the same queries. Mounting options are passed straight through.
npm install -D @testing-library/vue @testing-library/user-event @testing-library/jest-dom \
@vitejs/plugin-vue vitest jsdom<!-- src/components/TodoList.vue -->
<script setup lang="ts">
import { ref } from 'vue';
const props = defineProps<{ initial?: string[] }>();
const emit = defineEmits<{ added: [title: string] }>();
const todos = ref<string[]>([...(props.initial ?? [])]);
const draft = ref('');
function add() {
const title = draft.value.trim();
if (!title) return;
todos.value.push(title);
emit('added', title);
draft.value = '';
}
</script>
<template>
<form aria-label="Add todo" @submit.prevent="add">
<label for="todo">New todo</label>
<input id="todo" v-model="draft" />
<button type="submit">Add</button>
</form>
<ul aria-label="Todos">
<li v-for="todo in todos" :key="todo">{{ todo }}</li>
</ul>
</template>// tests/TodoList.test.ts
import { render, screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import TodoList from '../src/components/TodoList.vue';
test('adds a todo and emits the title', async () => {
const user = userEvent.setup();
const { emitted } = render(TodoList, {
props: { initial: ['Write tests'] },
// global: { plugins: [router, pinia], stubs: { RouterLink: true } },
});
await user.type(screen.getByLabelText('New todo'), 'Ship release');
await user.click(screen.getByRole('button', { name: 'Add' }));
const list = screen.getByRole('list', { name: 'Todos' });
expect(list).toHaveTextContent('Ship release');
expect(emitted().added).toEqual([['Ship release']]);
});Vue specifics worth knowing:
- DOM updates are flushed on the next tick. Vue Testing Library's
fireEventreturns a promise that waits for it, so alwaysawait fireEvent.click(...).user-eventcalls are awaited anyway. emitted()returns emitted events by name, each an array of argument arrays. Asserting on emits is fine because events are the component's public output.- Plugins, stubs and provide go under
global, exactly as in@vue/test-utils. rerender(newProps)updates props on the mounted instance.
@testing-library/angular creates the TestBed configuration for you and returns the familiar queries. render is async because Angular compiles and runs change detection first.
npm install -D @testing-library/angular @testing-library/dom @testing-library/user-event @testing-library/jest-dom// src/app/rating.component.ts
import { Component, computed, input, output, signal } from '@angular/core';
@Component({
selector: 'app-rating',
template: `
<fieldset>
<legend>{{ label() }}</legend>
@for (star of stars; track star) {
<button
type="button"
[attr.aria-pressed]="star <= value()"
[attr.aria-label]="star + ' of ' + stars.length + ' stars'"
(click)="select(star)"
>β
</button>
}
</fieldset>
<p role="status">{{ summary() }}</p>
`,
})
export class RatingComponent {
label = input.required<string>();
rated = output<number>();
protected readonly stars = [1, 2, 3, 4, 5];
protected readonly value = signal(0);
protected readonly summary = computed(() =>
this.value() === 0 ? 'Not rated' : `Rated ${this.value()} of 5`,
);
select(star: number) {
this.value.set(star);
this.rated.emit(star);
}
}// src/app/rating.component.spec.ts
import { render, screen } from '@testing-library/angular';
import userEvent from '@testing-library/user-event';
import { RatingComponent } from './rating.component';
describe('RatingComponent', () => {
it('marks stars as pressed and emits the rating', async () => {
const user = userEvent.setup();
const rated = vi.fn(); // jest.fn() under Jest
await render(RatingComponent, {
inputs: { label: 'Rate this guide' },
on: { rated },
// providers: [{ provide: RatingsApi, useValue: fakeApi }],
});
const group = screen.getByRole('group', { name: 'Rate this guide' });
expect(group).toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent('Not rated');
await user.click(screen.getByRole('button', { name: '4 of 5 stars' }));
// findBy waits for change detection to render the new state
expect(await screen.findByText('Rated 4 of 5')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '4 of 5 stars' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: '5 of 5 stars' })).toHaveAttribute('aria-pressed', 'false');
expect(rated).toHaveBeenCalledWith(4);
});
});Angular specifics worth knowing:
- Standalone components (the default in current Angular) need no
importsfor themselves. Addimportsonly for extra modules the test host needs. inputssets signal and decorator inputs;onsubscribes to outputs. Older code may usecomponentInputs,componentOutputsorcomponentProperties; check your adapter version's changelog.providersandcomponentProvidersoverride services at the root or component injector.- Change detection runs after
renderand after eachfireEvent. When state changes outside those paths (a resolved observable you control, a signal set from the test),findBywaits for the resulting DOM, or calldetectChanges()from the render result. - Routing: pass
routesto render a component inside the router and test navigation by clicking links.
The queries, user-event calls and assertions are identical across adapters. Only render differs.
| Concern | React | Vue | Angular |
|---|---|---|---|
| Import | @testing-library/react |
@testing-library/vue |
@testing-library/angular |
| Render | render(<C p={1} />) |
render(C, { props: { p: 1 } }) |
await render(C, { inputs: { p: 1 } }) |
| Providers / DI | wrapper |
global.plugins, global.provide |
providers, imports |
| Outputs | callback props (vi.fn()) |
emitted() |
on: { output: vi.fn() } |
| Update props | rerender(<C p={2} />) |
await rerender({ p: 2 }) |
await rerender({ inputs: { p: 2 } }) |
| Sync after events | automatic via act |
await fireEvent / next tick |
automatic change detection |
This symmetry is what makes Testing Library valuable in a multi-framework organisation: a design-system test written against roles and labels can be ported by changing a few lines.
Once several test files need the same providers, centralise them in a test-utils module and import from it everywhere instead of @testing-library/react.
// tests/test-utils.tsx
import { render, type RenderOptions } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router';
import type { ReactElement, ReactNode } from 'react';
import { ThemeProvider } from '../src/theme';
interface AppRenderOptions extends Omit<RenderOptions, 'wrapper'> {
route?: string;
theme?: 'light' | 'dark';
}
// 1. One function that mirrors the app's provider tree
export function renderWithApp(ui: ReactElement, { route = '/', theme = 'light', ...options }: AppRenderOptions = {}) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: Infinity } },
});
function Wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider value={theme}>
<MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>
</ThemeProvider>
</QueryClientProvider>
);
}
// 2. Return a user session alongside the render result
return {
user: userEvent.setup(),
queryClient,
...render(ui, { wrapper: Wrapper, ...options }),
};
}
// 3. Re-export everything so tests have a single import
export * from '@testing-library/react';// tests/team-page.test.tsx
import { renderWithApp, screen } from './test-utils';
import { TeamPage } from '../src/pages/TeamPage';
test('opens a member profile from the team list', async () => {
const { user } = renderWithApp(<TeamPage />, { route: '/team' });
await user.click(await screen.findByRole('link', { name: 'Grace Hopper' }));
expect(await screen.findByRole('heading', { level: 1, name: 'Grace Hopper' })).toBeInTheDocument();
});Test data belongs in small factory functions (buildUser({ name: 'Ada' })) rather than giant shared fixtures, so each test shows the values it depends on.
import { logRoles, prettyDOM, render, screen } from '@testing-library/react';
import { LoginForm } from '../src/components/LoginForm';
const { container } = render(<LoginForm onSubmit={() => {}} />);
// 1. Print the whole document, or one element, with syntax highlighting
screen.debug();
screen.debug(screen.getByRole('form', { name: 'Sign in' }), 20_000); // second arg: max characters
// 2. List every role and accessible name: the fastest way to fix a ByRole query
logRoles(document.body);
// 3. Get the DOM as a string for custom logging
console.log(prettyDOM(container.firstChild as Element));
// 4. Open the page in testing-playground.com to experiment with queries
screen.logTestingPlaygroundURL();configure adjusts the library globally. Put it in the setup file.
// tests/setup.ts
import { configure } from '@testing-library/react';
configure({
// Fail when a better query exists (e.g. getByTestId where getByRole works)
throwSuggestions: true,
// Some teams standardise on data-test or data-qa
testIdAttribute: 'data-test',
// Global async timeout; prefer per-call overrides
asyncUtilTimeout: 2000,
// Shorter, less noisy errors in CI logs
getElementError: (message) => {
const error = new Error(message ?? 'Element not found');
error.name = 'TestingLibraryElementError';
return error;
},
});The default DOM print limit is 7000 characters. Set DEBUG_PRINT_LIMIT=20000 in the environment to see more of large pages in error output.
A short triage list for "unable to find role":
1. logRoles(document.body) β is the role there at all? hidden ancestor? wrong implicit role?
2. Check the accessible name β icon button without aria-label? label not linked with for/id?
3. Is it async? β switch getBy to findBy
4. Is it in a portal? β still in document.body, so screen finds it; container-scoped queries do not
5. Is it really hidden? β { hidden: true } to confirm, then fix the markup, not the test
Two ESLint plugins catch most misuse at review time.
npm install -D eslint typescript-eslint eslint-plugin-testing-library eslint-plugin-jest-dom// eslint.config.js
import tseslint from 'typescript-eslint';
import testingLibrary from 'eslint-plugin-testing-library';
import jestDom from 'eslint-plugin-jest-dom';
const testFiles = ['tests/**/*.{ts,tsx}', '**/*.test.{ts,tsx}'];
export default [
// 0. ESLint's default parser cannot read type annotations or JSX in .tsx files
{ files: testFiles, languageOptions: { parser: tseslint.parser } },
// 1. Each plugin preset is its own config object so their `plugins` keys do not collide
{ files: testFiles, ...testingLibrary.configs['flat/react'] }, // flat/vue, flat/angular, flat/dom also exist
{ files: testFiles, ...jestDom.configs['flat/recommended'] },
// 2. Team-specific tightening on top of the presets
{
files: testFiles,
rules: {
'testing-library/prefer-user-event': 'error',
'testing-library/no-node-access': 'error',
'testing-library/prefer-explicit-assert': 'warn',
},
},
];Rules that pay for themselves:
| Rule | Catches |
|---|---|
testing-library/await-async-queries |
findBy without await, which makes the assertion run against a promise |
testing-library/await-async-events |
Un-awaited user-event calls |
testing-library/no-wait-for-side-effects |
Clicks or typing inside waitFor |
testing-library/no-wait-for-multiple-assertions |
Several expects in one waitFor |
testing-library/prefer-find-by |
waitFor(() => getBy...) that should be findBy |
testing-library/prefer-screen-queries |
Destructured queries instead of screen |
testing-library/no-container / no-node-access |
container.querySelector and .parentElement traversal |
testing-library/no-unnecessary-act |
act around calls that already use it |
jest-dom/prefer-in-document |
expect(x).not.toBeNull() instead of toBeInTheDocument() |
jest-dom/prefer-enabled-disabled |
toHaveAttribute('disabled') instead of toBeDisabled() |
// vite.config.ts (coverage additions)
test: {
coverage: {
provider: 'v8',
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/main.tsx', 'src/**/*.d.ts'],
reporter: ['text', 'html', 'lcov'],
thresholds: { lines: 85, branches: 80, functions: 85, statements: 85 },
},
},# Install the coverage provider once
npm install -D @vitest/coverage-v8
# CI command: one pass, coverage gate, JUnit report for the CI dashboard
npx vitest run --coverage --reporter=default --reporter=junit --outputFile=reports/junit.xmlCoverage is a floor, not a goal. A component test that clicks every button but asserts nothing gives 100% line coverage and zero confidence. Review assertions, not percentages.
Sharding splits a large suite across CI machines. Each shard runs roughly the number of test files divided by the number of shards, so 1,200 files over 6 shards is about 200 files per machine:
npx vitest run --shard=1/6 --reporter=blob
npx vitest run --shard=2/6 --reporter=blob
# ... one job per shard writing blob reports, then a final job runs: npx vitest --merge-reportsThe repository's Dockerfile runs type-checking and the full suite in the build stage, so an image cannot be produced from a failing commit:
# Production Dockerfile for the Testing Library Playground
# Stage 1 installs, type-checks and runs the Vitest suite; the image only builds if every test passes.
FROM node:24-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json vite.config.ts index.html ./
COPY src ./src
COPY tests ./tests
ENV CI=true
RUN npm test && npm run build
# Stage 2 serves the static bundle with nginx running as a non-root user on port 8080.
FROM nginxinc/nginx-unprivileged:stable-alpine AS runner
COPY --from=builder --chown=nginx:nginx /app/dist /usr/share/nginx/html
USER nginx
EXPOSE 8080
HEALTHCHECK CMD wget -qO- http://127.0.0.1:8080/ >/dev/null || exit 1# .github/workflows/test.yml (example for your own projects)
name: test
on: [push, pull_request]
jobs:
vitest:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 24
cache: npm
- run: npm ci
- run: npx vitest run --shard=${{ matrix.shard }}/3jsdom is fast but it is not a browser. It has no layout engine, no real rendering, partial CSS support and no IntersectionObserver or ResizeObserver without polyfills.
flowchart TD
Q{"What are you testing?"} -->|Logic, rendering, a11y semantics, interaction| J["Vitest + jsdom + Testing Library<br/>milliseconds per test"]
Q -->|Layout, CSS, focus traps, real events, canvas| B["Vitest Browser Mode or Playwright component tests<br/>real Chromium / Firefox / WebKit"]
Q -->|Full journeys across pages and services| E["Playwright / Cypress E2E<br/>against a deployed stack"]
| Layer | Tooling | Speed | Use for |
|---|---|---|---|
| Component (simulated DOM) | Vitest or Jest + jsdom + Testing Library | ~ms | The bulk of UI behaviour |
| Component (real browser) | Vitest Browser Mode with a Playwright provider | ~10-100 ms | Layout, scroll, drag, matchMedia, focus visibility |
| End to end | Playwright, Cypress | seconds | Critical paths: sign-up, checkout, auth |
The query philosophy carries over. Playwright's page.getByRole('button', { name: 'Save' }) and Cypress's @testing-library/cypress commands use the same role and name semantics, so the habits from this guide apply at every layer. A healthy ratio for a product team might be roughly 70/20/10 across the three layers, adjusted for how much of the product's risk lives in layout.
| Concern | API / Tool | Usage |
|---|---|---|
| Find interactive element | getByRole |
screen.getByRole('button', { name: 'Save' }) |
| Find form field | getByLabelText |
screen.getByLabelText('Email') |
| Assert absence | queryBy* |
expect(screen.queryByRole('alert')).not.toBeInTheDocument() |
| Wait for appearance | findBy* |
await screen.findByRole('dialog', { name: 'Confirm' }) |
| Wait for removal | waitForElementToBeRemoved |
await waitForElementToBeRemoved(() => screen.queryByRole('status')) |
| Wait for side effect | waitFor |
await waitFor(() => expect(spy).toHaveBeenCalled()) |
| Scope to subtree | within |
within(row).getByRole('button', { name: 'Delete' }) |
| Realistic interaction | userEvent.setup() |
const user = userEvent.setup(); await user.click(el) |
| Keyboard | user.keyboard / user.tab |
await user.keyboard('{Shift>}{Tab}{/Shift}') |
| Fake timers | advanceTimers |
userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) |
| Network | MSW | server.use(http.get('/api/x', () => HttpResponse.json(data))) |
| Providers | wrapper |
render(ui, { wrapper: Providers }) |
| Hooks | renderHook |
renderHook(() => useThing(), { initialProps }) |
| Debug DOM | screen.debug |
screen.debug(undefined, 20000) |
| Debug roles | logRoles |
logRoles(document.body) |
| Global config | configure |
configure({ throwSuggestions: true, asyncUtilTimeout: 2000 }) |
| Lint | eslint-plugin-testing-library |
testingLibrary.configs['flat/react'] |
| Run once | Vitest CLI | npx vitest run |
| Coverage | @vitest/coverage-v8 |
npx vitest run --coverage |
Answer: Tests should resemble how the software is used. Instead of reaching into component state, props or instance methods, a test renders the component, finds elements the way a person or assistive technology would (role, label, text), interacts through realistic events and asserts on observable output. The practical result is that refactors which preserve behaviour do not break tests, while regressions users would notice do.
Answer:
getByis synchronous and throws on zero or multiple matches, so it is the default for elements that must exist now.queryByreturnsnullon zero matches (still throwing on multiple), which makes it the only correct choice for asserting absence.findByreturns a promise that retries thegetBylogic until it succeeds or the async timeout (1000 ms by default) elapses, so it is used for content that appears after async work. UsinggetBywith.not.toBeInTheDocument()is a bug because the query throws before the assertion runs.
Answer:
ByRolematches the accessibility tree, which is what screen readers and other assistive tech consume, so a passing role query proves the element is both present and semantically exposed with the right name. It replaces a class of manual accessibility checks. The cost is performance: it computes accessible names and checks computed styles of ancestors to exclude hidden nodes. On very large DOMs, scope it withwithin, keep rendered trees small, or reduce thepointerEventsChecklevel in user-event, rather than swapping to test IDs.
Answer: When there is no user-perceivable hook: a chart canvas wrapper, a decorative animation container, or text that is translated and dynamic where matching on copy would make tests locale-dependent. It should be a deliberate exception. If test IDs dominate a suite, it usually signals missing labels and roles, which is an accessibility defect, not a testing convenience.
configure({ throwSuggestions: true })can enforce this by failing when a better query exists.
Q5: A test using getByText('Submit') fails with "multiple elements found". How do you fix it properly?
Answer: The ambiguity is real: two elements share that text. Narrow the query semantically, for example
getByRole('button', { name: 'Submit' })to exclude a heading or paragraph, or scope withwithin(form)to the specific region. Reaching forgetAllByText(...)[1]couples the test to DOM order, which breaks on harmless layout changes.
Answer:
fireEventdispatches one synthetic event. A real click involves pointer, mouse and focus events in a specific order, and typing fires keydown, keypress, beforeinput, input and keyup per character while respecting selection andmaxLength.user-eventreproduces those sequences and enforces browser constraints like disabled elements andpointer-events: none. Bugs that depend on focus or event order, such as a blur handler that clears a field before the click registers, only show up withuser-event.
Answer:
setup()creates a session that holds keyboard and pointer state, so a modifier held in one call ({Shift>}) is still held in the next and the pointer remembers what it is hovering over. It also attaches the clipboard stub and applies options likeadvanceTimersanddelayconsistently. The direct API callssetup()internally on every call, discarding that state. Callingsetup()beforerenderavoids subtle issues with document-level listeners.
Answer: First, whether the content depends on real time: a debounce, animation or retry with backoff that is slower on a loaded CI runner. Second, whether a network request is unmocked and actually hitting (or hanging on) a host;
onUnhandledRequest: 'error'in MSW surfaces this immediately. Third, test isolation: shared state (a module-level cache, a QueryClient reused across tests) that differs with CI's test ordering or parallelism. Fixes are fake timers for time, explicit handlers for network, fresh providers per test, and only then a per-call timeout increase.
Answer: React performed a state update while no
actscope was active, typically because an async operation resolved after the test's last awaited step. RTL already wraps render, events and async utilities inact, so the fix is to await the UI outcome of that update withfindByorwaitFor, or to make sure effects are cleaned up on unmount. Wrapping arbitrary code inactto silence the warning hides a real race and leaves the test asserting on stale UI.
Answer: Side effects (clicks, typing) inside the callback, because it reruns on every interval and mutation; multiple assertions, because the first failure masks the rest and slows each retry; wrapping
findByorusercalls, which already wait; an empty callback used as a sleep; and usingwaitForfor something synchronous. The right shape is one assertion about a state that will eventually become true, or afindByquery when the target is an element.
Answer: Use fake timers (
vi.useFakeTimers({ shouldAdvanceTime: true })) and passadvanceTimers: vi.advanceTimersByTimetouserEvent.setup()so its internal delays move the fake clock. Type the query, assert the handler has not run, advance the clock past the debounce window withvi.advanceTimersByTimeAsync, then assert one call with the final value. Restore real timers inafterEachand flush pending timers first so nothing leaks into the next test.
Answer: Module mocks replace the code under test with a fake and couple the test to the import graph and call shape. MSW intercepts at the request level, so the real client code, serialisation, headers and error handling all run. The same handlers can back Storybook, local development and integration tests, and
onUnhandledRequest: 'error'turns forgotten mocks into explicit failures. Per-testserver.useoverrides make error and edge cases cheap to cover.
Answer: Call
server.listen()once inbeforeAll,server.resetHandlers()inafterEachto drop anyserver.useoverrides, andserver.close()inafterAll. Keep default handlers stateless, or reset any in-memory store they use inafterEach. For a stateful fake backend, expose aresetDb()helper and call it alongsideresetHandlers.
Answer: Build a custom render (or a
renderWithApphelper) that composes the same providers the app uses, with test-friendly configuration: a newQueryClientper render with retries off, aMemoryRouterwith an initial route, and theme or feature-flag providers whose values can be set per test. Re-export Testing Library from that module so tests have one import. This avoids every test hand-assembling providers and keeps them aligned with production composition.
Answer: It suits hooks that are a public API in their own right, such as those in a shared library, where consumers depend on the hook's contract. For hooks used by a single component, testing through that component is more faithful because it covers the wiring and the rendered result. Heavy
renderHookuse for app-internal hooks often reflects testing implementation details, and those tests break when logic is moved between hooks.
Answer: A query like
getByRole('button', { name: 'Close' })fails if the button becomes an icon withoutaria-label, if adivreplaces thebutton, or ifaria-hiddenis applied to a parent. Those are exactly the regressions that break screen reader use. It is not a complete audit (contrast, focus visibility and reading order need other tools such as axe and manual testing), but it makes the semantic layer a tested contract.
Answer: No. They complement each other. Role-based queries verify that specific interactions are semantically exposed, while a scanner like axe-core checks rule coverage broadly: duplicate IDs, missing form labels across the page, ARIA misuse, some contrast issues in real browsers. A common pattern is running
vitest-axeorjest-axeon key rendered states inside component tests, then running axe in Playwright for layout-dependent rules.
Answer: Open the dialog with
user.click, assert focus moved into it (often the first focusable element or the dialog itself) withtoHaveFocus,user.tab()through the elements and confirm focus wraps at the end, press{Escape}and assert the dialog is gone and focus returned to the trigger. jsdom supports focus andtabindexbut not visual focus rings or layout-based trapping, so focus-visible styling belongs in a browser-mode or E2E test.
Answer: Large DOM snapshots assert everything and nothing: they fail on every markup change, reviewers rubber-stamp updates, and they rarely express intent. They also encode implementation details like class names. Targeted assertions on roles, text and state describe the requirement and fail with a meaningful message. Small inline snapshots of a formatted string or a serialised error are fine; whole-tree snapshots are a maintenance cost with little confidence in return.
Answer: Portals usually mount into
document.body, andscreenqueries are bound todocument.body, soscreen.findByRole('dialog')finds the content without special handling. Queries destructured fromrender()are also bound tobaseElement, which defaults todocument.body. Onlycontainer-scoped queries miss portal content, which is another reason to avoidcontainer.
Answer: Shared mutable state: a module-level cache or singleton store, a reused
QueryClient, MSW handlers not reset, fake timers left enabled, mocks not restored, or DOM nodes appended outside RTL's cleanup (for example a tooltip root created manually). Other causes are order-dependentlocalStorageand real timers firing after a test ends. EnablingrestoreMocks, creating providers per test, resetting handlers and stores inafterEach, and running with--sequence.shuffleto surface order dependence will flush these out.
Answer: Both run Testing Library equally well. Vitest reuses the Vite transform pipeline, handles ESM and TypeScript natively, starts fast and shares configuration with the app, which matters for Vite projects. Jest has a long history, a large plugin ecosystem and is still the default in some frameworks and monorepos. Migration cost is mostly
jest.fntovi.fn, timer APIs and module mocking syntax; the Testing Library code itself does not change.
Answer: Both implement browser APIs in Node. jsdom aims for spec fidelity and is the most widely used, while happy-dom trades some completeness for speed. If a suite relies on less common APIs (certain form validation details,
Rangeand selection behaviour, accurate event semantics), jsdom is the safer default. Benchmark the actual suite before switching, and keep anything layout-dependent in a real browser regardless.
Answer: jsdom returns zeros for
getBoundingClientRectandoffsetHeight, so virtualisation libraries render nothing or everything. Options: mock the measuring hooks to give fixed dimensions (fast but couples to the library), configure the library with a fixed item size and an overscan large enough for the test, or move the test to Vitest Browser Mode or Playwright component testing where layout is real. Behaviour like filtering and selection can still be tested in jsdom with a small data set.
Answer: Write one test per state, each driven by the network or the injected dependency: a pending promise to observe loading (
role="status"), a rejected one or a 500 handler for error (role="alert"), an empty array for the empty message, and data for success. Assert both what is present and what is absent in each state, for example no list during loading. The companionUserListtests follow exactly this pattern.
Answer: With
configure({ throwSuggestions: true }), a query that succeeds but has a better alternative (for examplegetByTestIdwhen the element has an accessible role and name) throws an error recommending the better query. It is useful when adopting Testing Library across a team or migrating from Enzyme-era tests, because it teaches query priority in the feedback loop. Individual queries can opt out with{ suggest: false }where the lower-priority query is intentional.
Answer: Pass them through
global.pluginsin the render options: a fresh Pinia instance per test (orcreateTestingPiniawith initial state and stubbed actions) and a router created with memory history, pushed to the starting route and awaited withrouter.isReady()before asserting. Then interact through the DOM. Assert store effects through the UI where possible and through the store only when the store is the contract.
Answer: Either provide a fake implementation through
providers({ provide: UsersApi, useValue: { list: () => of(users) } }), or keep the real service and back it withprovideHttpClient()plusprovideHttpClientTesting()andHttpTestingControllerto flush responses. For a closer-to-production test, the realHttpClientwith MSW interceptingfetchis also possible when the app is configured withwithFetch(). Assert the rendered outcome withfindByafter the response.
Answer: Render with the real i18n provider and a fixed test locale so queries can use real copy, or, if copy changes frequently, query by role and use translation keys only for the accessible name through a test helper. Avoid test IDs as the default fix: if translations break the accessible name, users are affected too. For multi-locale coverage, run a small parameterised test per locale on the most critical screens rather than the whole suite.
Answer: Keep rendered trees small by testing at component or feature level, avoid unnecessary providers, lower
pointerEventsCheckor setdelay: nullin user-event where safe, scope heavyByRolequeries withwithin, mock expensive modules (charts, editors) at their boundary, and use fake timers instead of real waits. At the CI level, run Vitest with sharding and cache dependencies. Profile with the reporter's duration output before guessing; one slow setup file often dominates.
Answer: Assert the error has an appropriate live role (
role="alert"or a polite live region) and is linked to the field:toHaveAccessibleDescriptionforaria-describedby, ortoHaveAccessibleErrorMessageforaria-errormessagewitharia-invalid="true". Also assertaria-invalidon the field and that focus moves to the first invalid field if that is the design. The companionLoginFormtest checks the alert,aria-invalidand the accessible description together.
Answer: Test each component's accessibility contract and interaction model: roles, names, states (
expanded,selected,pressed), keyboard patterns from the ARIA Authoring Practices, and focus behaviour. Publish a test-utils package with helpers likeselectOption(user, label, option)so product teams test composite widgets through stable, semantic helpers instead of internal DOM. Because the queries are framework-agnostic, the same contract tests can run for React and Web Component versions of a component.
Answer: When no user gesture exists for the event or
user-eventdoes not model it:scroll,resize,transitionend,animationend, media events likeplayorloadedmetadata, and custom events dispatched by web components. It is also used for very low-level tests of event handler wiring. For anything a person does with pointer or keyboard,user-eventis preferred.
Answer: Mocking a child with a stub removes its behaviour from the test, so integration bugs between parent and child go undetected, and the test becomes coupled to the component tree's structure. It is justified for genuinely heavy or side-effectful children (a map, a rich-text editor, a third-party payment iframe) at a clear boundary. The default should be rendering the real tree and mocking at the network or browser API edge.
Answer: jsdom does not implement it, so install a controllable fake in the setup file that records observed elements and exposes a helper to trigger intersection entries. The test renders, triggers intersection for the sentinel element inside
act(since nothing else is driving the update), then usesfindByto wait for the loaded content. For real scroll-driven behaviour, use a browser-mode test.
Answer: Migrate incrementally by file, starting with components being actively changed. Replace
shallowwith fullrender, rewritefind('.class')andstate()assertions into role and text queries plus user-observable outcomes, and swapsimulateforuser-event. Introduceeslint-plugin-testing-libraryandthrowSuggestionsearly so new tests follow the patterns. Expect some components to need accessibility fixes (labels, roles) to become testable, and treat that as a benefit of the migration rather than a cost.
Answer: Hold the mutation request open with a deferred MSW handler or a deferred promise, perform the action, and assert the optimistic state immediately (item shown, button disabled or labelled "Saving"). Then resolve the request successfully and assert the final state, and in a separate test reject it and assert the rollback plus an error message. This proves both the happy path and the reconciliation logic without timing hacks.
Answer: Query priority with
ByRolefirst and test IDs as a documented exception;screenover destructured queries;userEvent.setup()per test and awaited calls;findByfor appearance andqueryByfor absence; rules forwaitFor; MSW for network withonUnhandledRequest: 'error'; a shared custom render; per-test fresh state; no whole-tree snapshots; lint rules enforcing the above; and guidance on what moves to browser mode or E2E. Each rule should link to a short example in the codebase.
Answer: Make the flag source injectable through a provider in the custom render, defaulting to production defaults, with a per-test override (
renderWithApp(ui, { flags: { newCheckout: true } })). Write tests for both flag states on behaviour that differs, and avoid mocking the flag module globally, which leaks across tests. When a flag is fully rolled out, delete its off-state tests along with the code path.
Answer: Look beyond line coverage. Mutation testing (for example Stryker) shows whether tests fail when logic is altered; escaped-defect tracking shows which bugs reached production without a failing test; and flake rate and the ratio of test changes to behaviour changes show whether tests are coupled to implementation. A healthy suite fails on real regressions, rarely changes during refactors, runs fast enough for pre-commit, and reads as a specification of what users can do.
Contributions are welcomed! Fork the repository, create a feature branch, and submit a PR conforming to the unified curriculum architecture.
Released under the MIT License.