vitest-react-testing
Write unit and component tests with Vitest, React Testing Library, and MSW. Use when writing unit tests, component tests, or mocking APIs following TDD workflow.
$ 설치
git clone https://github.com/nekorush14/dotfiles /tmp/dotfiles && cp -r /tmp/dotfiles/configs/claude/skills/vitest-react-testing ~/.claude/skills/dotfiles// tip: Run this command in your terminal to install the skill
SKILL.md
name: vitest-react-testing description: Write unit and component tests with Vitest, React Testing Library, and MSW. Use when writing unit tests, component tests, or mocking APIs following TDD workflow.
Vitest React Testing Specialist
Specialized in writing tests for React applications using Vitest, React Testing Library, and MSW.
When to Use This Skill
- Writing unit tests for functions and utilities
- Testing React components with React Testing Library
- Testing user interactions with userEvent
- Testing custom hooks
- Mocking API requests with MSW
- Writing async tests
- Following TDD (Test-Driven Development) workflow
Core Principles
- Test Behavior, Not Implementation: Test what users see and do
- Arrange-Act-Assert: Structure tests clearly
- Test Isolation: Each test should be independent
- User-Centric: Use queries that match how users interact
- Avoid Test IDs: Prefer accessible queries (getByRole, getByLabelText)
- TDD Workflow: Write tests first (Red → Green → Refactor)
Implementation Guidelines
Vitest Setup
// vite.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',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['**/*.test.{ts,tsx}', '**/test/**'],
},
},
})
// src/test/setup.ts
import { expect, afterEach } from 'vitest'
import { cleanup } from '@testing-library/react'
import * as matchers from '@testing-library/jest-dom/matchers'
expect.extend(matchers)
// WHY: Cleanup after each test to prevent state leakage
afterEach(() => {
cleanup()
})
Basic Unit Tests
import { describe, it, expect } from 'vitest'
// Function to test
function add(a: number, b: number): number {
return a + b
}
describe('add', () => {
it('should add two numbers', () => {
// Arrange
const a = 2
const b = 3
// Act
const result = add(a, b)
// Assert
expect(result).toBe(5)
})
it('should handle negative numbers', () => {
expect(add(-1, -2)).toBe(-3)
})
it('should handle zero', () => {
expect(add(5, 0)).toBe(5)
})
})
// Utility function tests
function formatCurrency(amount: number): string {
return `$${amount.toFixed(2)}`
}
describe('formatCurrency', () => {
it('should format with 2 decimal places', () => {
expect(formatCurrency(10)).toBe('$10.00')
})
it('should round to 2 decimal places', () => {
expect(formatCurrency(10.567)).toBe('$10.57')
})
})
Component Testing with React Testing Library
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { Button } from './Button'
interface ButtonProps {
label: string
onClick: () => void
disabled?: boolean
}
const Button: FC<ButtonProps> = ({ label, onClick, disabled }) => {
return (
<button onClick={onClick} disabled={disabled}>
{label}
</button>
)
}
describe('Button', () => {
it('should render with label', () => {
render(<Button label="Click me" onClick={() => {}} />)
// WHY: getByRole is accessible and matches user perception
const button = screen.getByRole('button', { name: 'Click me' })
expect(button).toBeInTheDocument()
})
it('should be disabled when disabled prop is true', () => {
render(<Button label="Click me" onClick={() => {}} disabled />)
const button = screen.getByRole('button')
expect(button).toBeDisabled()
})
it('should not render when label is empty', () => {
const { container } = render(<Button label="" onClick={() => {}} />)
expect(container.firstChild).toBeNull()
})
})
User Interaction Testing
import { render, screen } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
describe('Counter', () => {
it('should increment count when button is clicked', async () => {
const user = userEvent.setup()
render(<Counter />)
const button = screen.getByRole('button', { name: /increment/i })
const count = screen.getByText('Count: 0')
// Act
await user.click(button)
// Assert
expect(screen.getByText('Count: 1')).toBeInTheDocument()
})
it('should handle multiple clicks', async () => {
const user = userEvent.setup()
render(<Counter />)
const button = screen.getByRole('button', { name: /increment/i })
await user.click(button)
await user.click(button)
await user.click(button)
expect(screen.getByText('Count: 3')).toBeInTheDocument()
})
})
// Form interaction testing
describe('LoginForm', () => {
it('should submit form with email and password', async () => {
const user = userEvent.setup()
const handleSubmit = vi.fn()
render(<LoginForm onSubmit={handleSubmit} />)
// WHY: getByLabelText matches how users find inputs
const emailInput = screen.getByLabelText(/email/i)
const passwordInput = screen.getByLabelText(/password/i)
const submitButton = screen.getByRole('button', { name: /log in/i })
// Act
await user.type(emailInput, 'user@example.com')
await user.type(passwordInput, 'password123')
await user.click(submitButton)
// Assert
expect(handleSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'password123',
})
})
it('should show validation errors for empty fields', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={() => {}} />)
const submitButton = screen.getByRole('button', { name: /log in/i })
await user.click(submitButton)
expect(screen.getByText(/email is required/i)).toBeInTheDocument()
expect(screen.getByText(/password is required/i)).toBeInTheDocument()
})
})
Testing with Mock Functions
import { vi } from 'vitest'
describe('UserList', () => {
it('should call onDelete when delete button is clicked', async () => {
const user = userEvent.setup()
const handleDelete = vi.fn()
render(
<UserList
users={[{ id: '1', name: 'John' }]}
onDelete={handleDelete}
/>
)
const deleteButton = screen.getByRole('button', { name: /delete/i })
await user.click(deleteButton)
// Assert function was called with correct arguments
expect(handleDelete).toHaveBeenCalledWith('1')
expect(handleDelete).toHaveBeenCalledTimes(1)
})
it('should not call onDelete when disabled', async () => {
const user = userEvent.setup()
const handleDelete = vi.fn()
render(<UserList users={[]} onDelete={handleDelete} disabled />)
// Assert function was never called
expect(handleDelete).not.toHaveBeenCalled()
})
})
// Spy on module functions
import * as api from './api'
describe('DataFetcher', () => {
it('should fetch data on mount', () => {
const spy = vi.spyOn(api, 'fetchUsers')
render(<DataFetcher />)
expect(spy).toHaveBeenCalled()
})
})
Custom Hook Testing
import { renderHook, waitFor } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue)
const increment = () => setCount(prev => prev + 1)
const decrement = () => setCount(prev => prev - 1)
const reset = () => setCount(initialValue)
return { count, increment, decrement, reset }
}
describe('useCounter', () => {
it('should initialize with default value', () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
})
it('should initialize with custom value', () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
})
it('should increment count', () => {
const { result } = renderHook(() => useCounter())
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
it('should reset to initial value', () => {
const { result } = renderHook(() => useCounter(5))
act(() => {
result.current.increment()
result.current.increment()
result.current.reset()
})
expect(result.current.count).toBe(5)
})
})
// Testing hook with async behavior
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(setData)
.finally(() => setLoading(false))
}, [url])
return { data, loading }
}
describe('useFetch', () => {
it('should fetch data', async () => {
const { result } = renderHook(() => useFetch<User[]>('/api/users'))
expect(result.current.loading).toBe(true)
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
expect(result.current.data).toBeDefined()
})
})
Async Testing
import { render, screen, waitFor } from '@testing-library/react'
describe('UserProfile', () => {
it('should show loading state initially', () => {
render(<UserProfile userId="1" />)
expect(screen.getByText(/loading/i)).toBeInTheDocument()
})
it('should display user data after loading', async () => {
render(<UserProfile userId="1" />)
// WHY: Wait for async operation to complete
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
// Loading indicator should be gone
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument()
})
it('should display error on fetch failure', async () => {
// Mock API to return error
vi.spyOn(api, 'fetchUser').mockRejectedValue(new Error('Failed'))
render(<UserProfile userId="1" />)
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument()
})
})
})
// Using findBy queries (combines getBy + waitFor)
describe('AsyncComponent', () => {
it('should find element after async update', async () => {
render(<AsyncComponent />)
// WHY: findBy automatically waits for element to appear
const heading = await screen.findByRole('heading', { name: /welcome/i })
expect(heading).toBeInTheDocument()
})
})
MSW (Mock Service Worker) for API Mocking
// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: '1', name: 'John Doe', email: 'john@example.com' },
{ id: '2', name: 'Jane Smith', email: 'jane@example.com' },
])
}),
http.get('/api/users/:id', ({ params }) => {
const { id } = params
return HttpResponse.json({
id,
name: 'John Doe',
email: 'john@example.com',
})
}),
http.post('/api/users', async ({ request }) => {
const body = await request.json()
return HttpResponse.json(
{ id: '3', ...body },
{ status: 201 }
)
}),
http.delete('/api/users/:id', () => {
return new HttpResponse(null, { status: 204 })
}),
]
// src/test/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
// src/test/setup.ts
import { beforeAll, afterEach, afterAll } from 'vitest'
import { server } from './mocks/server'
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
// Using MSW in tests
import { server } from './test/mocks/server'
import { http, HttpResponse } from 'msw'
describe('UserList', () => {
it('should display users from API', async () => {
render(<UserList />)
// WHY: Wait for async data to load
expect(await screen.findByText('John Doe')).toBeInTheDocument()
expect(await screen.findByText('Jane Smith')).toBeInTheDocument()
})
it('should handle API error', async () => {
// Override handler for this test
server.use(
http.get('/api/users', () => {
return new HttpResponse(null, { status: 500 })
})
)
render(<UserList />)
expect(await screen.findByText(/error/i)).toBeInTheDocument()
})
it('should handle empty response', async () => {
server.use(
http.get('/api/users', () => {
return HttpResponse.json([])
})
)
render(<UserList />)
expect(await screen.findByText(/no users/i)).toBeInTheDocument()
})
})
Testing Context Providers
import { render, screen } from '@testing-library/react'
// Helper to render with providers
function renderWithProviders(ui: React.ReactElement) {
return render(
<ThemeProvider>
<AuthProvider>
{ui}
</AuthProvider>
</ThemeProvider>
)
}
describe('ThemedButton', () => {
it('should apply theme from context', () => {
renderWithProviders(<ThemedButton />)
const button = screen.getByRole('button')
expect(button).toHaveClass('dark-theme')
})
})
// Testing with custom wrapper
describe('UserDashboard', () => {
it('should display user from auth context', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<AuthProvider value={{ user: mockUser }}>
{children}
</AuthProvider>
)
render(<UserDashboard />, { wrapper })
expect(screen.getByText(mockUser.name)).toBeInTheDocument()
})
})
Tools to Use
Read: Read component and hook filesWrite: Create test filesEdit: Update existing testsBash: Run tests and coverage
Bash Commands
# Run all tests
vitest
# Run in watch mode
vitest --watch
# Run specific test file
vitest src/components/Button.test.tsx
# Run tests with coverage
vitest --coverage
# Run tests in UI mode
vitest --ui
# Run only changed tests
vitest --changed
Workflow
- Write Test First: Start with failing test (Red)
- Run Test: Confirm it fails for the right reason
- Write Minimal Code: Make test pass (Green)
- Run Test: Ensure it passes
- Refactor: Improve code while keeping tests green
- Run All Tests: Ensure no regressions
- Commit: Create atomic commit
Related Skills
typescript-core-development: For type-safe test codereact-component-development: For components being testedreact-state-management: For testing state logic
Testing Fundamentals
TDD Workflow
Follow Frontend TDD Workflow
Key Reminders
- Write tests before implementation (TDD)
- Test behavior, not implementation details
- Use accessible queries (getByRole, getByLabelText) over test IDs
- Clean up after each test to prevent state leakage
- Use userEvent for user interactions
- Use MSW for mocking API requests
- Use waitFor and findBy for async operations
- Mock at the network layer, not the component layer
- Test what users see and do, not internal state
- Keep tests simple and focused
- Run tests frequently during development
- Aim for high coverage but focus on critical paths
- Write comments explaining WHY when testing complex scenarios
Repository

nekorush14
Author
nekorush14/dotfiles/configs/claude/skills/vitest-react-testing
2
Stars
0
Forks
Updated3d ago
Added1w ago