
React Impl Testing
- 11 installs
- 6 repo stars
- Updated July 8, 2026
- openaec-foundation/react-claude-skill-package
Helps with testing & qa tasks.
About
react-impl-testing is a Claude Code skill for testing & qa. It helps solo builders move faster with AI-assisted development.
- react-impl-testing
- Testing & QA
- AI-coding skill
React Impl Testing by the numbers
- 11 all-time installs (skills.sh)
- Ranked #1,539 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/react-claude-skill-package --skill react-impl-testingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 11 |
|---|---|
| repo stars | ★ 6 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/react-claude-skill-package ↗ |
What it does
Helps with testing & qa tasks.
Files
react-impl-testing
Quick Reference
Installation
npm install --save-dev vitest @testing-library/react @testing-library/dom \
@testing-library/jest-dom @testing-library/user-event jsdomVitest Configuration
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
css: true,
include: ['src/**/*.{test,spec}.{ts,tsx}'],
},
});// src/test/setup.ts
import '@testing-library/jest-dom/vitest';// tsconfig.json — add to compilerOptions.types
{ "compilerOptions": { "types": ["vitest/globals", "@testing-library/jest-dom"] } }Query Priority (ALWAYS follow this order)
| Priority | Query | When to Use |
|---|---|---|
| 1 | getByRole | Accessible role — buttons, headings, inputs, links |
| 2 | getByLabelText | Form fields with associated <label> |
| 3 | getByPlaceholderText | Input with placeholder (less accessible) |
| 4 | getByText | Non-interactive visible text content |
| 5 | getByDisplayValue | Input/textarea with current value |
| 6 | getByAltText | Image alt text |
| 7 | getByTitle | Title attribute |
| 8 | getByTestId | data-testid — LAST RESORT ONLY |
Query Variants
| Variant | No Match | 1 Match | >1 Match | Async? |
|---|---|---|---|---|
getBy... | throw | return | throw | No |
queryBy... | null | return | throw | No |
findBy... | throw | return | throw | Yes |
getAllBy... | throw | array | array | No |
queryAllBy... | [] | array | array | No |
findAllBy... | throw | array | array | Yes |
Critical Warnings
NEVER use Enzyme. It is deprecated and incompatible with React 18+. ALWAYS use React Testing Library.
NEVER test implementation details — internal state, instance methods, component internals, or private functions. ALWAYS test visible behavior from the user's perspective.
NEVER use getByTestId when a semantic query (getByRole, getByLabelText, getByText) is available. data-testid is a last resort that provides zero accessibility value.
ALWAYS prefer userEvent over fireEvent for simulating user interactions. userEvent fires the full event sequence a real browser would produce.
ALWAYS use screen for queries instead of destructuring from render(). It makes tests more readable and consistent.
NEVER wrap RTL interactions in act() manually — render, fireEvent, userEvent, and waitFor already handle this internally.
---
Decision Trees
Which query variant should I use?
Need to assert element EXISTS?
├── Yes, and it MUST be there → getBy... (throws on missing)
├── Yes, and I need to WAIT for it → findBy... (async, retries)
└── Maybe not there (asserting absence) → queryBy... (returns null)fireEvent or userEvent?
Simulating user interaction?
├── Typing, clicking, selecting, tabbing → ALWAYS userEvent
├── Custom/synthetic event (resize, scroll) → fireEvent
└── Low-level DOM event testing → fireEventDo I need act()?
Using RTL utilities (render, fireEvent, userEvent, waitFor)?
├── Yes → act() is handled internally, do NOT add it
└── No (manual state update, direct hook call) → wrap in act()When to use snapshot testing?
Is this a good use case for snapshots?
├── Detecting unintended UI changes → Yes, sparingly
├── Testing specific behavior or logic → No, use assertions
├── Large component tree → No, snapshots become brittle
└── Small, stable output (CSS class, data attribute) → Yes, inline snapshot---
Patterns
Pattern 1: Basic Component Test
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Greeting } from './Greeting';
describe('Greeting', () => {
it('renders the name in a heading', () => {
render(<Greeting name="Alice" />);
expect(screen.getByRole('heading', { name: /alice/i })).toBeInTheDocument();
});
});Pattern 2: User Interaction with userEvent
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';
describe('Counter', () => {
it('increments count on button click', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
});Pattern 3: Async Operations with waitFor
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { UserList } from './UserList';
describe('UserList', () => {
it('displays users after loading', async () => {
render(<UserList />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
});Pattern 4: Testing Custom Hooks with renderHook
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('starts at initial value', () => {
const { result } = renderHook(() => useCounter(5));
expect(result.current.count).toBe(5);
});
it('increments the counter', () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
});Pattern 5: Testing with Context Providers
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from './ThemeContext';
import { ThemedButton } from './ThemedButton';
function renderWithTheme(ui: React.ReactElement, theme: string = 'light') {
return render(ui, {
wrapper: ({ children }) => (
<ThemeProvider value={theme}>{children}</ThemeProvider>
),
});
}
describe('ThemedButton', () => {
it('applies dark theme class', () => {
renderWithTheme(<ThemedButton>Click</ThemedButton>, 'dark');
expect(screen.getByRole('button', { name: /click/i })).toHaveClass('dark-theme');
});
});Pattern 6: API Mocking with vi.fn()
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SearchForm } from './SearchForm';
import * as api from './api';
vi.mock('./api');
describe('SearchForm', () => {
beforeEach(() => {
vi.mocked(api.search).mockResolvedValue([
{ id: '1', title: 'React Testing' },
]);
});
it('displays search results', async () => {
const user = userEvent.setup();
render(<SearchForm />);
await user.type(screen.getByRole('searchbox'), 'react');
await user.click(screen.getByRole('button', { name: /search/i }));
await waitFor(() => {
expect(screen.getByText('React Testing')).toBeInTheDocument();
});
expect(api.search).toHaveBeenCalledWith('react');
});
});Pattern 7: Error State Testing
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { DataLoader } from './DataLoader';
import * as api from './api';
vi.mock('./api');
describe('DataLoader', () => {
it('displays error message on fetch failure', async () => {
vi.mocked(api.fetchData).mockRejectedValue(new Error('Network error'));
render(<DataLoader />);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(/network error/i);
});
});
});Pattern 8: Snapshot Testing (Use Sparingly)
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { Badge } from './Badge';
describe('Badge', () => {
it('matches snapshot for status variant', () => {
const { asFragment } = render(<Badge status="success">Done</Badge>);
expect(asFragment()).toMatchSnapshot();
});
it('renders correct class via inline snapshot', () => {
const { container } = render(<Badge status="error">Fail</Badge>);
expect(container.firstChild).toMatchInlineSnapshot(`
<span class="badge badge-error">Fail</span>
`);
});
});---
React 18 vs 19 Testing Differences
| Feature | React 18 | React 19 |
|---|---|---|
act() warnings | Frequent in async tests | Improved — fewer false warnings |
| Form actions | Test via onSubmit handler | Test via action prop with FormData |
ref in tests | Use forwardRef wrapper | Direct ref prop works |
| Suspense | Limited hydration testing | Full Suspense integration |
use() hook | N/A | Test components that read promises in render |
---
Reference Links
- references/examples.md -- Complete test examples for all patterns
- references/api-table.md -- React Testing Library API reference
- references/anti-patterns.md -- Common testing mistakes and fixes
Official Sources
- https://testing-library.com/docs/react-testing-library/intro
- https://testing-library.com/docs/react-testing-library/api
- https://testing-library.com/docs/queries/about
- https://testing-library.com/docs/user-event/intro
- https://vitest.dev/guide/
- https://react.dev/learn/testing
react-impl-testing — Anti-Patterns
Common testing mistakes, why they are wrong, and the correct alternative.
---
Anti-Pattern 1: Testing Implementation Details
// WRONG: Testing internal state
it('updates internal state', () => {
const { result } = renderHook(() => useState(0));
// Directly inspecting or manipulating component internals
});
// WRONG: Testing that a specific function was called internally
it('calls the internal handler', () => {
const wrapper = shallow(<Counter />); // Enzyme — NEVER use
wrapper.instance().handleClick();
expect(wrapper.state('count')).toBe(1);
});
// CORRECT: Test visible behavior from the user's perspective
it('displays incremented count after clicking', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});WHY: Implementation details change during refactoring. Tests that depend on internal state break when you refactor without changing behavior. User-facing tests survive refactors and provide real confidence.
---
Anti-Pattern 2: Using Enzyme
// WRONG: Enzyme is deprecated and incompatible with React 18+
import { shallow, mount } from 'enzyme';
const wrapper = shallow(<MyComponent />);
expect(wrapper.find('.my-class')).toHaveLength(1);
wrapper.instance().someMethod(); // accessing component instanceWHY: Enzyme encourages testing implementation details (shallow rendering, component instances, internal state). It has no official adapter for React 18 or 19. ALWAYS use React Testing Library instead.
---
Anti-Pattern 3: Using getByTestId as First Choice
// WRONG: Using data-testid when semantic queries are available
render(<button data-testid="submit-btn">Submit Order</button>);
screen.getByTestId('submit-btn');
// CORRECT: Use getByRole — it also verifies accessibility
screen.getByRole('button', { name: /submit order/i });WHY: getByRole validates that the element has the correct accessible role and name. getByTestId provides zero accessibility verification. If you can query by role, label, or text, those queries are strictly better.
---
Anti-Pattern 4: Wrapping Everything in act()
// WRONG: Unnecessary act() — RTL already wraps internally
await act(async () => {
render(<MyComponent />);
});
await act(async () => {
fireEvent.click(screen.getByRole('button'));
});
await act(async () => {
await waitFor(() => {
expect(screen.getByText('Done')).toBeInTheDocument();
});
});
// CORRECT: RTL methods handle act() internally
render(<MyComponent />);
await userEvent.setup().click(screen.getByRole('button'));
await waitFor(() => {
expect(screen.getByText('Done')).toBeInTheDocument();
});WHY: render(), fireEvent, userEvent, and waitFor all wrap their operations in act() internally. Adding an outer act() is redundant and makes tests harder to read. Only use act() when directly calling hook functions from renderHook.
---
Anti-Pattern 5: Using Fixed Delays Instead of waitFor
// WRONG: Arbitrary setTimeout delay
it('loads data', async () => {
render(<DataList />);
await new Promise((r) => setTimeout(r, 2000)); // fragile!
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
// CORRECT: waitFor retries until the assertion passes or times out
it('loads data', async () => {
render(<DataList />);
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
});
// ALSO CORRECT: findBy queries are waitFor + getBy combined
it('loads data', async () => {
render(<DataList />);
expect(await screen.findByText('Item 1')).toBeInTheDocument();
});WHY: Fixed delays make tests slow (waiting full duration) and flaky (fails if async operation takes longer than expected). waitFor polls at short intervals and resolves as soon as the assertion passes.
---
Anti-Pattern 6: Not Cleaning Up Mocks
// WRONG: Mock leaks between tests
vi.mock('./api');
describe('ComponentA', () => {
it('test one', () => {
vi.mocked(api.fetchData).mockResolvedValue({ name: 'Alice' });
render(<ComponentA />);
// ...
});
it('test two', () => {
// fetchData still returns Alice! Mock was not reset
render(<ComponentA />);
// ...
});
});
// CORRECT: Reset mocks before each test
describe('ComponentA', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('test one', () => {
vi.mocked(api.fetchData).mockResolvedValue({ name: 'Alice' });
// ...
});
it('test two', () => {
vi.mocked(api.fetchData).mockResolvedValue({ name: 'Bob' });
// ...
});
});WHY: Mock state persists across tests in the same describe block. Stale mocks cause tests to pass or fail depending on execution order — a common source of flaky tests.
---
Anti-Pattern 7: Destructuring Queries from render()
// WRONG: Destructuring creates scope confusion
const { getByText, getByRole } = render(<MyComponent />);
getByText('Hello');
// CORRECT: Use screen — single, consistent API surface
render(<MyComponent />);
screen.getByText('Hello');WHY: screen is always available and does not need to be destructured. It reduces variable noise and makes it clear which DOM tree is being queried. The only exception is container and helper methods like debug(), rerender(), and unmount().
---
Anti-Pattern 8: Testing CSS Class Names Directly
// WRONG: Tied to CSS implementation
expect(screen.getByRole('button')).toHaveClass('btn-primary-active-large');
// CORRECT: Test visible behavior or accessibility
expect(screen.getByRole('button')).toBeEnabled();
expect(screen.getByRole('button')).toBeVisible();
expect(screen.getByRole('button')).toHaveAccessibleName('Submit');WHY: CSS class names are implementation details that change during styling refactors. Test what the user sees and experiences — enabled state, visibility, accessible names, text content. Exception: testing a theme or variant system where classes ARE the public API.
---
Anti-Pattern 9: Large Snapshot Tests
// WRONG: Full page snapshot — breaks on any change, hard to review
it('renders correctly', () => {
const { asFragment } = render(<EntireApp />);
expect(asFragment()).toMatchSnapshot();
});
// CORRECT: Small, focused inline snapshots for stable output
it('renders badge with correct structure', () => {
const { container } = render(<Badge variant="success">Done</Badge>);
expect(container.firstChild).toMatchInlineSnapshot(`
<span class="badge badge-success">Done</span>
`);
});
// BETTER: Use specific assertions instead of snapshots
it('renders success badge', () => {
render(<Badge variant="success">Done</Badge>);
expect(screen.getByText('Done')).toHaveClass('badge-success');
});WHY: Large snapshots are noise — reviewers approve changes without reading them. They break on unrelated changes (a sibling component update, a key change). Use specific assertions for behavior and reserve snapshots for small, stable output only.
---
Anti-Pattern 10: Not Testing Error States
// WRONG: Only testing the happy path
describe('UserProfile', () => {
it('renders user data', async () => {
vi.mocked(api.getUser).mockResolvedValue({ name: 'Alice' });
render(<UserProfile userId="1" />);
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
});
// Missing: error state, loading state, empty state
});
// CORRECT: Test all states
describe('UserProfile', () => {
it('shows loading indicator', () => {
vi.mocked(api.getUser).mockReturnValue(new Promise(() => {}));
render(<UserProfile userId="1" />);
expect(screen.getByRole('status')).toBeInTheDocument();
});
it('renders user data on success', async () => {
vi.mocked(api.getUser).mockResolvedValue({ name: 'Alice' });
render(<UserProfile userId="1" />);
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
});
it('shows error message on failure', async () => {
vi.mocked(api.getUser).mockRejectedValue(new Error('Not found'));
render(<UserProfile userId="1" />);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(/not found/i);
});
});
it('shows empty state for missing data', async () => {
vi.mocked(api.getUser).mockResolvedValue(null);
render(<UserProfile userId="1" />);
await waitFor(() => {
expect(screen.getByText(/no user found/i)).toBeInTheDocument();
});
});
});WHY: Happy-path-only tests give false confidence. Users encounter errors, slow networks, and empty states. ALWAYS test loading, success, error, and empty states for any async component.
---
Anti-Pattern 11: Querying by DOM Structure
// WRONG: Brittle — breaks when HTML structure changes
const { container } = render(<Nav />);
const link = container.querySelector('nav > ul > li:first-child > a');
// CORRECT: Query by accessible role and name
const link = screen.getByRole('link', { name: /home/i });WHY: DOM structure is an implementation detail. Changing from <ul> to <nav> with <a> links, or wrapping items in a <div>, breaks structure-based selectors. Accessible queries survive structural refactors.
---
Summary: What to Test and How
| Test This | How |
|---|---|
| User sees text | screen.getByText(), toHaveTextContent() |
| User clicks button | userEvent.click() + assert visible result |
| Form submission | userEvent.type() + userEvent.click() + assert callback/result |
| Async data loading | waitFor() or findBy queries |
| Error states | Mock rejection + assert error UI |
| Accessibility | getByRole(), getByLabelText(), toHaveAccessibleName() |
| Hook behavior | renderHook() + act() for updates |
| NEVER Test This | Why |
|---|---|
| Internal state values | Implementation detail |
| Component instance methods | Implementation detail |
| CSS class names (usually) | Styling detail |
| DOM structure/nesting | HTML detail |
| Specific rendered HTML | Brittle |
react-impl-testing — API Reference
Complete API reference for React Testing Library, user-event, and jest-dom matchers.
---
render()
import { render } from '@testing-library/react';
function render(
ui: React.ReactElement,
options?: {
container?: Element;
baseElement?: Element;
hydrate?: boolean;
wrapper?: React.ComponentType<{ children: React.ReactNode }>;
queries?: typeof queries;
}
): RenderResult;RenderResult
| Property | Type | Description |
|---|---|---|
...queries | Query functions | All getBy, queryBy, findBy queries bound to the container |
container | HTMLElement | The DOM node wrapping the rendered component |
baseElement | HTMLElement | The document.body or custom base element |
debug(el?) | () => void | Logs formatted DOM output to console |
rerender(ui) | (ui: ReactElement) => void | Re-render with new props |
unmount() | () => void | Unmount the rendered component |
asFragment() | () => DocumentFragment | Returns a DocumentFragment for snapshot testing |
---
screen
import { screen } from '@testing-library/react';All query methods are available on screen after calling render(). ALWAYS use screen instead of destructuring queries from render().
---
Query Methods
By Role (PREFERRED)
screen.getByRole('button');
screen.getByRole('button', { name: /submit/i });
screen.getByRole('heading', { level: 2 });
screen.getByRole('textbox', { name: /email/i });
screen.getByRole('checkbox', { checked: true });
screen.getByRole('combobox', { expanded: true });
screen.getByRole('tab', { selected: true });
screen.getByRole('link', { name: /home/i });
screen.getByRole('alert');
screen.getByRole('dialog');
screen.getByRole('navigation');
screen.getByRole('list');
screen.getByRole('listitem');
screen.getByRole('region', { name: /sidebar/i });Common ARIA Roles
| HTML Element | Implicit Role |
|---|---|
<button> | button |
<a href> | link |
<input type="text"> | textbox |
<input type="checkbox"> | checkbox |
<input type="radio"> | radio |
<select> | combobox |
<textarea> | textbox |
<h1>-<h6> | heading |
<img> | img |
<nav> | navigation |
<ul>, <ol> | list |
<li> | listitem |
<table> | table |
<tr> | row |
<td> | cell |
<form> | form (with accessible name) |
<dialog> | dialog |
By Label
screen.getByLabelText('Email');
screen.getByLabelText(/email/i);
screen.getByLabelText('Email', { selector: 'input' }); // disambiguateBy Text
screen.getByText('Hello World');
screen.getByText(/hello/i);
screen.getByText((content, element) => {
return element?.tagName === 'SPAN' && content.startsWith('Hello');
});By Test ID
// HTML: <div data-testid="custom-element" />
screen.getByTestId('custom-element');---
waitFor()
import { waitFor } from '@testing-library/react';
await waitFor(
() => expect(screen.getByText('Loaded')).toBeInTheDocument(),
{
timeout: 1000, // default: 1000ms
interval: 50, // default: 50ms (polling interval)
onTimeout: (error) => error, // custom timeout error
}
);ALWAYS use waitFor for assertions on async operations. NEVER use arbitrary setTimeout delays.
---
waitForElementToBeRemoved()
import { waitForElementToBeRemoved } from '@testing-library/react';
await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));---
within()
import { within } from '@testing-library/react';
const section = screen.getByRole('region', { name: /users/i });
const heading = within(section).getByRole('heading');---
renderHook()
import { renderHook } from '@testing-library/react';
const { result, rerender, unmount } = renderHook(
(props) => useMyHook(props.value),
{
initialProps: { value: 'initial' },
wrapper: MyContextProvider,
}
);
// Access current return value
result.current;
// Re-render with new props
rerender({ value: 'updated' });
// Unmount the hook
unmount();---
userEvent
import userEvent from '@testing-library/user-event';
const user = userEvent.setup();Methods
| Method | Description |
|---|---|
user.click(element) | Click an element (focus, pointerdown, mousedown, pointerup, mouseup, click) |
user.dblClick(element) | Double-click |
user.tripleClick(element) | Triple-click (selects text) |
user.type(element, text) | Type text character by character (focus, keydown, keypress, input, keyup per char) |
user.clear(element) | Clear an input/textarea |
user.selectOptions(select, values) | Select option(s) in a <select> |
user.deselectOptions(select, values) | Deselect option(s) in a multi-select |
user.tab() | Press Tab key (moves focus) |
user.tab({ shift: true }) | Press Shift+Tab |
user.keyboard('{Enter}') | Press specific keys |
user.keyboard('{Shift>}A{/Shift}') | Key combinations |
user.hover(element) | Hover over element |
user.unhover(element) | Move cursor away |
user.upload(input, file) | Upload a file |
user.paste(text) | Paste from clipboard |
user.copy() | Copy selection |
user.cut() | Cut selection |
Keyboard Key Syntax
| Syntax | Key |
|---|---|
{Enter} | Enter |
{Escape} | Escape |
{Tab} | Tab |
{Backspace} | Backspace |
{Delete} | Delete |
{ArrowUp} | Arrow Up |
{ArrowDown} | Arrow Down |
{ArrowLeft} | Arrow Left |
{ArrowRight} | Arrow Right |
{Home} | Home |
{End} | End |
{Space} | Space |
{Shift>} | Hold Shift |
{/Shift} | Release Shift |
{Control>} | Hold Ctrl |
{Meta>} | Hold Meta (Cmd) |
---
jest-dom Matchers
import '@testing-library/jest-dom/vitest';| Matcher | Description |
|---|---|
toBeInTheDocument() | Element exists in the DOM |
toBeVisible() | Element is visible (not hidden by CSS) |
toBeEnabled() | Element is not disabled |
toBeDisabled() | Element has disabled attribute |
toBeChecked() | Checkbox/radio is checked |
toHaveTextContent(text) | Element contains text |
toHaveValue(value) | Input/textarea has value |
toHaveDisplayValue(value) | Select/input displays value |
toHaveAttribute(attr, value?) | Element has attribute |
toHaveClass(...classes) | Element has CSS class(es) |
toHaveStyle(css) | Element has inline style |
toHaveFocus() | Element currently has focus |
toBeRequired() | Form element is required |
toBeInvalid() | Form element is invalid |
toBeValid() | Form element is valid |
toBeEmptyDOMElement() | Element has no content |
toContainElement(element) | Element contains another element |
toContainHTML(html) | Element contains HTML string |
toHaveAccessibleName(name) | Element has accessible name |
toHaveAccessibleDescription(desc) | Element has accessible description |
toHaveErrorMessage(message) | Element has error message (via aria-errormessage) |
toHaveFormValues(values) | Form contains expected values |
---
Vitest API (Testing Framework)
Test Structure
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
describe('ComponentName', () => {
beforeEach(() => { /* runs before each test */ });
afterEach(() => { /* runs after each test */ });
it('does something', () => {
expect(value).toBe(expected);
});
it.skip('skipped test', () => { /* not run */ });
it.only('only this test runs', () => { /* focus */ });
it.todo('implement later');
});Mocking
// Mock a module
vi.mock('./api');
// Mock a specific export
vi.mock('./utils', () => ({
formatDate: vi.fn(() => '2024-01-01'),
}));
// Create a mock function
const mockFn = vi.fn();
const mockFnWithReturn = vi.fn().mockReturnValue(42);
const mockFnAsync = vi.fn().mockResolvedValue({ data: [] });
const mockFnReject = vi.fn().mockRejectedValue(new Error('fail'));
// Spy on an object method
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
// Timer mocks
vi.useFakeTimers();
vi.advanceTimersByTime(1000);
vi.runAllTimers();
vi.useRealTimers();
// Reset/restore
vi.clearAllMocks(); // clear call history
vi.resetAllMocks(); // clear history + implementations
vi.restoreAllMocks(); // restore original implementationsreact-impl-testing — Examples
Complete test examples for all common React testing patterns with React Testing Library and Vitest.
---
Example 1: Form Submission Test
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
it('submits email and password', async () => {
const handleSubmit = vi.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/email/i), 'user@example.com');
await user.type(screen.getByLabelText(/password/i), 'secret123');
await user.click(screen.getByRole('button', { name: /log in/i }));
expect(handleSubmit).toHaveBeenCalledOnce();
expect(handleSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'secret123',
});
});
it('shows validation error for empty email', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={vi.fn()} />);
await user.click(screen.getByRole('button', { name: /log in/i }));
expect(screen.getByRole('alert')).toHaveTextContent(/email is required/i);
});
it('disables submit button while submitting', async () => {
const handleSubmit = vi.fn(() => new Promise((r) => setTimeout(r, 100)));
const user = userEvent.setup();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/email/i), 'user@example.com');
await user.type(screen.getByLabelText(/password/i), 'secret123');
await user.click(screen.getByRole('button', { name: /log in/i }));
expect(screen.getByRole('button', { name: /log in/i })).toBeDisabled();
});
});---
Example 2: List with Loading and Empty States
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, within } from '@testing-library/react';
import { TodoList } from './TodoList';
import * as api from './api';
vi.mock('./api');
describe('TodoList', () => {
it('shows loading spinner initially', () => {
vi.mocked(api.fetchTodos).mockReturnValue(new Promise(() => {})); // never resolves
render(<TodoList />);
expect(screen.getByRole('status')).toBeInTheDocument();
});
it('renders todo items after loading', async () => {
vi.mocked(api.fetchTodos).mockResolvedValue([
{ id: '1', text: 'Buy milk', done: false },
{ id: '2', text: 'Write tests', done: true },
]);
render(<TodoList />);
await waitFor(() => {
expect(screen.getByText('Buy milk')).toBeInTheDocument();
});
const items = screen.getAllByRole('listitem');
expect(items).toHaveLength(2);
});
it('shows empty message when no todos exist', async () => {
vi.mocked(api.fetchTodos).mockResolvedValue([]);
render(<TodoList />);
await waitFor(() => {
expect(screen.getByText(/no todos yet/i)).toBeInTheDocument();
});
});
it('shows error message on fetch failure', async () => {
vi.mocked(api.fetchTodos).mockRejectedValue(new Error('Server down'));
render(<TodoList />);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(/server down/i);
});
});
});---
Example 3: Component with Context Provider
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AuthProvider, useAuth } from './AuthContext';
import { UserMenu } from './UserMenu';
// Reusable wrapper for auth context
function renderWithAuth(ui: React.ReactElement, authState = { user: null }) {
return render(ui, {
wrapper: ({ children }) => (
<AuthProvider initialState={authState}>{children}</AuthProvider>
),
});
}
describe('UserMenu', () => {
it('shows login button when not authenticated', () => {
renderWithAuth(<UserMenu />);
expect(screen.getByRole('button', { name: /log in/i })).toBeInTheDocument();
expect(screen.queryByText(/welcome/i)).not.toBeInTheDocument();
});
it('shows username when authenticated', () => {
renderWithAuth(<UserMenu />, { user: { name: 'Alice', email: 'alice@test.com' } });
expect(screen.getByText(/welcome, alice/i)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /log in/i })).not.toBeInTheDocument();
});
});---
Example 4: Testing Custom Hooks
import { describe, it, expect, vi } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { useDebounce } from './useDebounce';
import { useLocalStorage } from './useLocalStorage';
describe('useDebounce', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('returns initial value immediately', () => {
const { result } = renderHook(() => useDebounce('hello', 500));
expect(result.current).toBe('hello');
});
it('updates value after delay', () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 'hello', delay: 500 } },
);
rerender({ value: 'world', delay: 500 });
expect(result.current).toBe('hello'); // not yet updated
act(() => {
vi.advanceTimersByTime(500);
});
expect(result.current).toBe('world');
});
});
describe('useLocalStorage', () => {
beforeEach(() => {
localStorage.clear();
});
it('returns initial value when localStorage is empty', () => {
const { result } = renderHook(() => useLocalStorage('theme', 'light'));
expect(result.current[0]).toBe('light');
});
it('persists value to localStorage', () => {
const { result } = renderHook(() => useLocalStorage('theme', 'light'));
act(() => {
result.current[1]('dark');
});
expect(result.current[0]).toBe('dark');
expect(localStorage.getItem('theme')).toBe('"dark"');
});
it('reads existing value from localStorage', () => {
localStorage.setItem('theme', '"dark"');
const { result } = renderHook(() => useLocalStorage('theme', 'light'));
expect(result.current[0]).toBe('dark');
});
});---
Example 5: Keyboard Navigation and Accessibility
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Dropdown } from './Dropdown';
describe('Dropdown', () => {
const options = ['Apple', 'Banana', 'Cherry'];
it('opens menu on Enter key', async () => {
const user = userEvent.setup();
render(<Dropdown options={options} label="Fruit" />);
const trigger = screen.getByRole('combobox', { name: /fruit/i });
await user.tab(); // focus the trigger
await user.keyboard('{Enter}');
expect(screen.getByRole('listbox')).toBeInTheDocument();
expect(screen.getAllByRole('option')).toHaveLength(3);
});
it('navigates options with arrow keys', async () => {
const user = userEvent.setup();
render(<Dropdown options={options} label="Fruit" />);
await user.tab();
await user.keyboard('{Enter}');
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute(
'aria-selected',
'true',
);
});
it('selects option on Enter', async () => {
const onSelect = vi.fn();
const user = userEvent.setup();
render(<Dropdown options={options} label="Fruit" onSelect={onSelect} />);
await user.tab();
await user.keyboard('{Enter}');
await user.keyboard('{ArrowDown}');
await user.keyboard('{Enter}');
expect(onSelect).toHaveBeenCalledWith('Apple');
});
});---
Example 6: Testing with Timer Mocks
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, act } from '@testing-library/react';
import { AutoSave } from './AutoSave';
describe('AutoSave', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('saves content after 2 seconds of inactivity', () => {
const onSave = vi.fn();
render(<AutoSave content="Draft text" onSave={onSave} />);
act(() => {
vi.advanceTimersByTime(2000);
});
expect(onSave).toHaveBeenCalledWith('Draft text');
});
it('resets timer on content change', () => {
const onSave = vi.fn();
const { rerender } = render(<AutoSave content="First" onSave={onSave} />);
act(() => {
vi.advanceTimersByTime(1500);
});
rerender(<AutoSave content="Second" onSave={onSave} />);
act(() => {
vi.advanceTimersByTime(1500);
});
expect(onSave).not.toHaveBeenCalled(); // timer was reset
act(() => {
vi.advanceTimersByTime(500);
});
expect(onSave).toHaveBeenCalledWith('Second');
});
});---
Example 7: Testing with Router
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { Navigation } from './Navigation';
import { HomePage } from './HomePage';
import { AboutPage } from './AboutPage';
function renderWithRouter(initialRoute: string = '/') {
return render(
<MemoryRouter initialEntries={[initialRoute]}>
<Navigation />
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/about" element={<AboutPage />} />
</Routes>
</MemoryRouter>,
);
}
describe('Navigation', () => {
it('renders home page by default', () => {
renderWithRouter('/');
expect(screen.getByRole('heading', { name: /home/i })).toBeInTheDocument();
});
it('navigates to about page on link click', async () => {
const user = userEvent.setup();
renderWithRouter('/');
await user.click(screen.getByRole('link', { name: /about/i }));
expect(screen.getByRole('heading', { name: /about/i })).toBeInTheDocument();
});
});---
Example 8: Testing React 19 Form Actions
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FeedbackForm } from './FeedbackForm';
// React 19 form action component
// function FeedbackForm() {
// async function submitFeedback(formData: FormData) { ... }
// return <form action={submitFeedback}>...</form>;
// }
describe('FeedbackForm (React 19)', () => {
it('submits feedback and shows success message', async () => {
const user = userEvent.setup();
render(<FeedbackForm />);
await user.type(screen.getByLabelText(/message/i), 'Great product!');
await user.click(screen.getByRole('button', { name: /submit/i }));
await waitFor(() => {
expect(screen.getByText(/thank you/i)).toBeInTheDocument();
});
});
});---
Example 9: within() for Scoped Queries
import { describe, it, expect } from 'vitest';
import { render, screen, within } from '@testing-library/react';
import { Dashboard } from './Dashboard';
describe('Dashboard', () => {
it('shows different data in each section', () => {
render(<Dashboard />);
const revenueSection = within(screen.getByRole('region', { name: /revenue/i }));
const usersSection = within(screen.getByRole('region', { name: /users/i }));
expect(revenueSection.getByText('$12,500')).toBeInTheDocument();
expect(usersSection.getByText('1,234')).toBeInTheDocument();
});
});