--- name: react-testing description: React component testing patterns including components, hooks, context, and forms. Covers Vitest Browser Mode with vitest-browser-react (preferred) and @testing-library/react. Use when testing React applications. For general UI testing patterns, see the front-end-testing skill. --- # React Testing For general UI testing patterns (queries, events, async, accessibility), load the `front-end-testing` skill. For TDD workflow, load the `tdd` skill. ## Vitest Browser Mode with React (Preferred) **Always prefer `vitest-browser-react` over `@testing-library/react`.** Tests run in a real browser, giving production-accurate rendering, events, and CSS. ### Setup ```bash npm install -D vitest @vitest/browser-playwright vitest-browser-react @vitejs/plugin-react ``` ```typescript // vitest.config.ts import { defineConfig } from 'vitest/config' import { playwright } from '@vitest/browser-playwright' import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], test: { browser: { enabled: true, provider: playwright(), headless: true, instances: [{ browser: 'chromium' }], }, }, }) ``` ### Component Testing ```tsx import { render } from 'vitest-browser-react' import { expect, test } from 'vitest' test('should display user name when provided', async () => { const screen = await render() await expect.element(screen.getByText(/alice/i)).toBeVisible() await expect.element(screen.getByText(/alice@example.com/i)).toBeVisible() }) ``` **Key differences from `@testing-library/react`:** - `render()` is async — use `await` - Returns a `screen` scoped to the rendered component - Use `expect.element()` for auto-retrying assertions - No `act()` wrapper needed — CDP events + retry handle timing - Auto-cleanup happens before each test (not after), so components stay visible for debugging ### Testing Props and Callbacks ```tsx test('should call onSubmit when form submitted', async () => { const handleSubmit = vi.fn() const screen = await render() await screen.getByLabelText(/email/i).fill('test@example.com') await screen.getByRole('button', { name: /submit/i }).click() expect(handleSubmit).toHaveBeenCalledWith({ email: 'test@example.com', }) }) ``` ### Testing Conditional Rendering ```tsx test('should show error message when login fails', async () => { server.use( http.post('/api/login', () => { return HttpResponse.json({ error: 'Invalid credentials' }, { status: 401 }) }) ) const screen = await render() await screen.getByLabelText(/email/i).fill('wrong@example.com') await screen.getByRole('button', { name: /submit/i }).click() await expect.element(screen.getByText(/invalid credentials/i)).toBeVisible() }) ``` ### Testing Hooks with renderHook ```tsx import { renderHook } from 'vitest-browser-react' test('should toggle value', async () => { const { result } = await renderHook(() => useToggle(false)) expect(result.current.value).toBe(false) await act(() => { result.current.toggle() }) expect(result.current.value).toBe(true) }) ``` ### Testing Context Providers ```tsx test('should show user menu when authenticated', async () => { const screen = await render( ) await expect.element(screen.getByRole('button', { name: /user menu/i })).toBeVisible() }) ``` For hooks that need context: ```tsx const { result } = await renderHook(() => useAuth(), { wrapper: ({ children }) => ( {children} ), }) ``` --- ## Legacy: @testing-library/react Patterns The patterns below apply when using `@testing-library/react` with jsdom. **Prefer `vitest-browser-react`** for new projects. --- ## Testing React Components **React components are just functions that return JSX.** Test them like functions: inputs (props) → output (rendered DOM). ### Basic Component Testing ```tsx // ✅ CORRECT - Test component behavior it('should display user name when provided', () => { render(); expect(screen.getByText(/alice/i)).toBeInTheDocument(); expect(screen.getByText(/alice@example.com/i)).toBeInTheDocument(); }); ``` ```tsx // ❌ WRONG - Testing implementation it('should set name state', () => { const wrapper = mount(); expect(wrapper.state('name')).toBe('Alice'); // Internal state! }); ``` ### Testing Props ```tsx // ✅ CORRECT - Test how props affect rendered output it('should call onSubmit when form submitted', async () => { const handleSubmit = vi.fn(); const user = userEvent.setup(); render(); await user.type(screen.getByLabelText(/email/i), 'test@example.com'); await user.click(screen.getByRole('button', { name: /submit/i })); expect(handleSubmit).toHaveBeenCalledWith({ email: 'test@example.com', }); }); ``` ### Testing Conditional Rendering ```tsx // ✅ CORRECT - Test what user sees in different states it('should show error message when login fails', async () => { server.use( http.post('/api/login', () => { return HttpResponse.json({ error: 'Invalid credentials' }, { status: 401 }); }) ); const user = userEvent.setup(); render(); await user.type(screen.getByLabelText(/email/i), 'wrong@example.com'); await user.click(screen.getByRole('button', { name: /submit/i })); await screen.findByText(/invalid credentials/i); }); ``` --- ## Testing React Hooks ### Custom Hooks with renderHook **Built into `@testing-library/react`** (import directly, no separate package needed): ```tsx import { renderHook } from '@testing-library/react'; it('should toggle value', () => { const { result } = renderHook(() => useToggle(false)); expect(result.current.value).toBe(false); act(() => { result.current.toggle(); }); expect(result.current.value).toBe(true); }); ``` **Pattern:** - `result.current` - Current return value of hook - `act()` - Wrap state updates - `rerender()` - Re-run hook with new props ### Hooks with Props ```tsx it('should accept initial value', () => { const { result, rerender } = renderHook( ({ initialValue }) => useCounter(initialValue), { initialProps: { initialValue: 10 } } ); expect(result.current.count).toBe(10); // Test with different initial value rerender({ initialValue: 20 }); expect(result.current.count).toBe(20); }); ``` --- ## Testing Context ### wrapper Option **For hooks that need context providers:** ```tsx const { result } = renderHook(() => useAuth(), { wrapper: ({ children }) => ( {children} ), }); expect(result.current.user).toBeNull(); act(() => { result.current.login({ email: 'test@example.com' }); }); expect(result.current.user).toEqual({ email: 'test@example.com' }); ``` ### Multiple Providers ```tsx const AllProviders = ({ children }) => ( {children} ); const { result } = renderHook(() => useMyHook(), { wrapper: AllProviders, }); ``` ### Testing Components with Context ```tsx // ✅ CORRECT - Wrap component in provider const renderWithAuth = (ui, { user = null, ...options } = {}) => { return render( {ui} , options ); }; it('should show user menu when authenticated', () => { renderWithAuth(, { user: { name: 'Alice', role: 'admin' }, }); expect(screen.getByRole('button', { name: /user menu/i })).toBeInTheDocument(); }); ``` --- ## Testing Forms ### Controlled Inputs ```tsx it('should update input value as user types', async () => { const user = userEvent.setup(); render(); const input = screen.getByLabelText(/search/i); await user.type(input, 'react'); expect(input).toHaveValue('react'); }); ``` ### Form Submissions ```tsx it('should submit form with user input', async () => { const handleSubmit = vi.fn(); const user = userEvent.setup(); render(); await user.type(screen.getByLabelText(/name/i), 'Alice'); await user.type(screen.getByLabelText(/email/i), 'alice@example.com'); await user.type(screen.getByLabelText(/password/i), 'password123'); await user.click(screen.getByRole('button', { name: /sign up/i })); expect(handleSubmit).toHaveBeenCalledWith({ name: 'Alice', email: 'alice@example.com', password: 'password123', }); }); ``` ### Form Validation ```tsx it('should show validation errors for invalid input', async () => { const user = userEvent.setup(); render(); // Submit empty form await user.click(screen.getByRole('button', { name: /sign up/i })); // Validation errors appear expect(screen.getByText(/name is required/i)).toBeInTheDocument(); expect(screen.getByText(/email is required/i)).toBeInTheDocument(); expect(screen.getByText(/password is required/i)).toBeInTheDocument(); }); ``` --- ## React-Specific Anti-Patterns ### 1. Unnecessary act() wrapping ❌ **WRONG - Manual act() everywhere** ```tsx act(() => { render(); }); await act(async () => { await user.click(button); }); ``` ✅ **CORRECT - RTL handles it** ```tsx render(); await user.click(button); ``` **Modern RTL auto-wraps:** - `render()` - `userEvent` methods - `fireEvent` - `waitFor`, `findBy` **When you DO need manual `act()`:** - Custom hook state updates (`renderHook`) - Direct state mutations (rare, usually bad practice) --- ### 2. Manual cleanup() calls ❌ **WRONG - Manual cleanup** ```tsx afterEach(() => { cleanup(); // Automatic since RTL 9! }); ``` ✅ **CORRECT - No cleanup needed** ```tsx // Cleanup happens automatically after each test ``` --- ### 3. beforeEach render pattern ❌ **WRONG - Shared render in beforeEach** ```tsx let button; beforeEach(() => { render(); button = screen.getByRole('button'); // Shared state across tests }); it('test 1', () => { // Uses shared button from beforeEach }); ``` ✅ **CORRECT - Factory function per test** ```tsx const renderComponent = () => { render(); return { button: screen.getByRole('button'), }; }; it('test 1', () => { const { button } = renderComponent(); // Fresh state }); ``` For factory patterns, see `testing` skill. --- ### 4. Testing component internals ❌ **WRONG - Accessing component internals** ```tsx const wrapper = shallow(); expect(wrapper.state('isOpen')).toBe(true); // Internal state expect(wrapper.instance().handleClick).toBeDefined(); // Internal method ``` ✅ **CORRECT - Test rendered output** ```tsx render(); expect(screen.getByRole('dialog')).toBeInTheDocument(); // What user sees ``` --- ### 5. Shallow rendering ❌ **WRONG - Shallow rendering** ```tsx const wrapper = shallow(); // Child components not rendered - incomplete test ``` ✅ **CORRECT - Full rendering** ```tsx render(); // Full component tree rendered - realistic test ``` **Why:** Shallow rendering hides integration bugs between parent/child components. --- ## Testing Loading States ```tsx it('should show loading then data', async () => { render(); // Initially loading expect(screen.getByText(/loading/i)).toBeInTheDocument(); // Wait for data await screen.findByText(/alice/i); // Loading gone expect(screen.queryByText(/loading/i)).not.toBeInTheDocument(); }); ``` --- ## Testing Error Boundaries ```tsx it('should catch errors with error boundary', () => { // Suppress console.error for this test const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); render( Something went wrong}> ); expect(screen.getByText(/something went wrong/i)).toBeInTheDocument(); spy.mockRestore(); }); ``` --- ## Testing Portals ```tsx it('should render modal in portal', () => { render(Modal content); // Portal renders outside root, but Testing Library finds it expect(screen.getByText(/modal content/i)).toBeInTheDocument(); }); ``` **Testing Library queries the entire document,** so portals work automatically. --- ## Testing Suspense ```tsx it('should show fallback then content', async () => { render( Loading...}> ); // Initially fallback expect(screen.getByText(/loading/i)).toBeInTheDocument(); // Wait for component await screen.findByText(/lazy content/i); }); ``` --- ## Summary Checklist React-specific checks: - [ ] **Preferred**: Using `vitest-browser-react` with Vitest Browser Mode (real browser) - [ ] **Fallback**: Using `@testing-library/react` if Browser Mode not yet configured - [ ] All Playwright/Browser Mode tests are idempotent (no shared state between tests) - [ ] Using `renderHook()` for custom hooks - [ ] Using `wrapper` option for context providers - [ ] No manual `act()` calls (handled automatically) - [ ] No manual `cleanup()` calls (automatic) - [ ] Testing component output, not internal state - [ ] Using factory functions, not `beforeEach` render - [ ] Using `expect.element()` for auto-retrying assertions (Browser Mode) - [ ] Following TDD workflow (see `tdd` skill) - [ ] Using general UI testing patterns (see `front-end-testing` skill) - [ ] Using test factories for data (see `testing` skill)