Principles and patterns for writing effective React tests with Jest and React Testing Library. Use during implementation for test structure guidance, choosing test patterns, and deciding testing strategies. Emphasizes testing user behavior, not implementation details.
Provides React Testing Library and Jest guidance for testing user behavior, not implementation. Triggers during implementation when structuring tests, choosing patterns, or deciding testing strategies.
/plugin marketplace add buzzdan/ai-coding-rules/plugin install ts-react-linter-driven-development@ai-coding-rulesThis skill inherits all available tools. When active, it can use any tool Claude has access to.
reference.mdPrinciples and patterns for writing effective TypeScript + React tests.
Test user behavior, not implementation details
Prefer real implementations over mocks
Coverage targets
Pure Components/Hooks (Leaf types):
Examples:
Container Components (Orchestrating types):
Examples:
test.each() - Use when:
describe/it blocks - Use when:
React Testing Library Suite - Always use:
// src/features/auth/components/LoginForm.tsx
// src/features/auth/components/LoginForm.test.tsx
// ✅ Good: Real implementations
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AuthProvider } from '../context/AuthContext'
import { LoginForm } from './LoginForm'
// MSW for API mocking (real HTTP)
import { rest } from 'msw'
import { setupServer } from 'msw/node'
const server = setupServer(
rest.post('/api/login', (req, res, ctx) => {
return res(ctx.json({ token: 'fake-token' }))
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
test('user can log in', async () => {
const user = userEvent.setup()
render(
<AuthProvider>
<LoginForm />
</AuthProvider>
)
// Real user interactions
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByRole('button', { name: /log in/i }))
// Assert on user-visible changes
expect(await screen.findByText(/welcome/i)).toBeInTheDocument()
})
import { render, screen } from '@testing-library/react'
import { Button } from './Button'
describe('Button', () => {
test.each([
{ variant: 'primary', expectedClass: 'btn-primary' },
{ variant: 'secondary', expectedClass: 'btn-secondary' },
{ variant: 'danger', expectedClass: 'btn-danger' }
])('renders $variant variant with class $expectedClass', ({ variant, expectedClass }) => {
render(<Button variant={variant} label='Click me' onClick={() => {}} />)
const button = screen.getByRole('button', { name: /click me/i })
expect(button).toHaveClass(expectedClass)
})
test.each([
{ isDisabled: true, shouldBeDisabled: true },
{ isDisabled: false, shouldBeDisabled: false }
])('when isDisabled=$isDisabled, button is disabled=$shouldBeDisabled',
({ isDisabled, shouldBeDisabled }) => {
render(<Button label='Click me' onClick={() => {}} isDisabled={isDisabled} />)
const button = screen.getByRole('button')
if (shouldBeDisabled) {
expect(button).toBeDisabled()
} else {
expect(button).toBeEnabled()
}
}
)
})
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { SearchBox } from './SearchBox'
describe('SearchBox', () => {
test('calls onSearch when user types and submits', async () => {
const user = userEvent.setup()
const onSearch = jest.fn()
render(<SearchBox onSearch={onSearch} />)
// Type in search box
const input = screen.getByRole('textbox', { name: /search/i })
await user.type(input, 'react testing')
// Submit form
await user.click(screen.getByRole('button', { name: /search/i }))
// Assert callback called
expect(onSearch).toHaveBeenCalledWith('react testing')
expect(onSearch).toHaveBeenCalledTimes(1)
})
test('shows validation error for empty search', async () => {
const user = userEvent.setup()
const onSearch = jest.fn()
render(<SearchBox onSearch={onSearch} />)
// Submit without typing
await user.click(screen.getByRole('button', { name: /search/i }))
// Assert error message
expect(screen.getByText(/search cannot be empty/i)).toBeInTheDocument()
expect(onSearch).not.toHaveBeenCalled()
})
})
import { renderHook, waitFor } from '@testing-library/react'
import { useUsers } from './useUsers'
// MSW setup for API
import { rest } from 'msw'
import { setupServer } from 'msw/node'
const mockUsers = [
{ id: '1', name: 'Alice', email: 'alice@example.com' },
{ id: '2', name: 'Bob', email: 'bob@example.com' }
]
const server = setupServer(
rest.get('/api/users', (req, res, ctx) => {
return res(ctx.json(mockUsers))
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
describe('useUsers', () => {
test('fetches users successfully', async () => {
const { result } = renderHook(() => useUsers())
// Initially loading
expect(result.current.isLoading).toBe(true)
expect(result.current.users).toEqual([])
// Wait for data to load
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
// Assert users loaded
expect(result.current.users).toEqual(mockUsers)
expect(result.current.error).toBeNull()
})
test('handles error when fetch fails', async () => {
// Override handler to return error
server.use(
rest.get('/api/users', (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ message: 'Server error' }))
})
)
const { result } = renderHook(() => useUsers())
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.users).toEqual([])
expect(result.current.error).toBeTruthy()
})
})
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AuthProvider } from '../context/AuthContext'
import { ProtectedRoute } from './ProtectedRoute'
// Helper to render with providers
function renderWithAuth(ui: React.ReactElement, { user = null } = {}) {
return render(
<AuthProvider initialUser={user}>
{ui}
</AuthProvider>
)
}
describe('ProtectedRoute', () => {
test('redirects to login when user is not authenticated', () => {
renderWithAuth(<ProtectedRoute><div>Protected Content</div></ProtectedRoute>)
expect(screen.queryByText(/protected content/i)).not.toBeInTheDocument()
expect(screen.getByText(/please log in/i)).toBeInTheDocument()
})
test('shows content when user is authenticated', () => {
const user = { id: '1', email: 'test@example.com', name: 'Test User' }
renderWithAuth(
<ProtectedRoute><div>Protected Content</div></ProtectedRoute>,
{ user }
)
expect(screen.getByText(/protected content/i)).toBeInTheDocument()
expect(screen.queryByText(/please log in/i)).not.toBeInTheDocument()
})
})
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { UserProfile } from './UserProfile'
test('loads and displays user profile', async () => {
render(<UserProfile userId='123' />)
// Assert loading state
expect(screen.getByText(/loading/i)).toBeInTheDocument()
// Wait for content to appear
await waitFor(() => {
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument()
})
// Assert loaded content
expect(screen.getByText(/john doe/i)).toBeInTheDocument()
expect(screen.getByText(/john@example.com/i)).toBeInTheDocument()
})
test('displays error when load fails', async () => {
// Mock API to return error
server.use(
rest.get('/api/users/:id', (req, res, ctx) => {
return res(ctx.status(404), ctx.json({ message: 'User not found' }))
})
)
render(<UserProfile userId='999' />)
// Wait for error message
await waitFor(() => {
expect(screen.getByText(/user not found/i)).toBeInTheDocument()
})
})
Use queries in this order (from most to least preferred):
getByRole - Best for accessibility
screen.getByRole('button', { name: /submit/i })
screen.getByRole('textbox', { name: /email/i })
getByLabelText - Good for form fields
screen.getByLabelText(/email address/i)
getByPlaceholderText - When label isn't available
screen.getByPlaceholderText(/enter your email/i)
getByText - For non-interactive elements
screen.getByText(/welcome back/i)
getByTestId - Last resort only
screen.getByTestId('custom-component')
Mock Service Worker for realistic API mocking:
// src/test/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
// src/test/mocks/handlers.ts
import { rest } from 'msw'
export const handlers = [
rest.get('/api/users', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json([
{ id: '1', name: 'User 1' },
{ id: '2', name: 'User 2' }
])
)
}),
rest.post('/api/login', (req, res, ctx) => {
const { email, password } = req.body as any
if (email === 'test@example.com' && password === 'password') {
return res(
ctx.status(200),
ctx.json({ token: 'fake-token', user: { id: '1', email } })
)
}
return res(
ctx.status(401),
ctx.json({ message: 'Invalid credentials' })
)
})
]
// src/test/setup.ts (in Jest config)
import { server } from './mocks/server'
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
See reference.md for detailed principles:
Pure components (100% coverage):
Container components (integration tests):
Custom hooks (100% coverage):
// Fill form fields
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
// Submit form
await user.click(screen.getByRole('button', { name: /submit/i }))
// Assert success
expect(await screen.findByText(/success/i)).toBeInTheDocument()
// Assert list items
const items = screen.getAllByRole('listitem')
expect(items).toHaveLength(3)
// Assert specific item
expect(screen.getByText(/item 1/i)).toBeInTheDocument()
// Open modal
await user.click(screen.getByRole('button', { name: /open modal/i }))
// Assert modal visible
expect(screen.getByRole('dialog')).toBeInTheDocument()
// Close modal
await user.click(screen.getByRole('button', { name: /close/i }))
// Assert modal hidden
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
import { MemoryRouter } from 'react-router-dom'
function renderWithRouter(ui: React.ReactElement, { initialEntries = ['/'] } = {}) {
return render(
<MemoryRouter initialEntries={initialEntries}>
{ui}
</MemoryRouter>
)
}
test('navigates to user profile on click', async () => {
const user = userEvent.setup()
renderWithRouter(<UserList />)
await user.click(screen.getByText(/john doe/i))
expect(screen.getByText(/user profile/i)).toBeInTheDocument()
})
See reference.md for complete testing patterns and examples.
This skill should be used when the user asks to "create a slash command", "add a command", "write a custom command", "define command arguments", "use command frontmatter", "organize commands", "create command with file references", "interactive command", "use AskUserQuestion in command", or needs guidance on slash command structure, YAML frontmatter fields, dynamic arguments, bash execution in commands, user interaction patterns, or command development best practices for Claude Code.
This skill should be used when the user asks to "create an agent", "add an agent", "write a subagent", "agent frontmatter", "when to use description", "agent examples", "agent tools", "agent colors", "autonomous agent", or needs guidance on agent structure, system prompts, triggering conditions, or agent development best practices for Claude Code plugins.
This skill should be used when the user asks to "create a hook", "add a PreToolUse/PostToolUse/Stop hook", "validate tool use", "implement prompt-based hooks", "use ${CLAUDE_PLUGIN_ROOT}", "set up event-driven automation", "block dangerous commands", or mentions hook events (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, UserPromptSubmit, PreCompact, Notification). Provides comprehensive guidance for creating and implementing Claude Code plugin hooks with focus on advanced prompt-based hooks API.