| 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
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/**'],
},
},
})
import { expect, afterEach } from 'vitest'
import { cleanup } from '@testing-library/react'
import * as matchers from '@testing-library/jest-dom/matchers'
expect.extend(matchers)
afterEach(() => {
cleanup()
})
Basic Unit Tests
import { describe, it, expect } from 'vitest'
function add(a: number, b: number): number {
return a + b
}
describe('add', () => {
it('should add two numbers', () => {
const a = 2
const b = 3
const result = add(a, b)
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)
})
})
function formatCurrency(: ): {
}
(, {
(, {
(()).()
})
(, {
(()).()
})
})
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={() => {}} />)
const button = screen.getByRole('button', { name: })
(button).()
})
(, {
()
button = screen.()
(button).()
})
(, {
{ container } = ()
(container.).()
})
})
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')
await user.click(button)
expect(screen.getByText('Count: 1')).toBeInTheDocument()
})
it('should handle multiple clicks', async () => {
const user = userEvent.setup()
render(<Counter />)
const button = screen.getByRole('button', { name: })
user.(button)
user.(button)
user.(button)
(screen.()).()
})
})
(, {
(, () => {
user = userEvent.()
handleSubmit = vi.()
()
emailInput = screen.()
passwordInput = screen.()
submitButton = screen.(, { : })
user.(emailInput, )
user.(passwordInput, )
user.(submitButton)
(handleSubmit).({
: ,
: ,
})
})
(, () => {
user = userEvent.()
()
submitButton = screen.(, { : })
user.(submitButton)
(screen.()).()
(screen.()).()
})
})
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)
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 = = />)
(handleDelete)..()
})
})
* api
(, {
(, {
spy = vi.(api, )
()
(spy).()
})
})
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 } = ( ())
(result..).()
})
(, {
{ result } = ( ())
( {
result..()
})
(result..).()
})
(, {
{ result } = ( ())
( {
result..()
result..()
result..()
})
(result..).()
})
})
useFetch<T>(: ) {
[data, setData] = useState<T | >()
[loading, setLoading] = ()
( {
(url)
.( res.())
.(setData)
.( ())
}, [url])
{ data, loading }
}
(, {
(, () => {
{ result } = ( useFetch<[]>())
(result..).()
( {
(result..).()
})
(result..).()
})
})
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" />)
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument()
})
it('should display error on fetch failure', async () => {
vi.spyOn(api, 'fetchUser').mockRejectedValue( ())
()
( {
(screen.()).()
})
})
})
(, {
(, () => {
()
heading = screen.(, { : })
(heading).()
})
})
MSW (Mock Service Worker) for API Mocking
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(, {
(, { : })
}),
]
{ setupServer }
{ handlers }
server = (...handlers)
{ beforeAll, afterEach, afterAll }
{ server }
( server.())
( server.())
( server.())
import { server } from './test/mocks/server'
import { http, HttpResponse } from 'msw'
describe('UserList', () => {
it('should display users from API', async () => {
render(<UserList />)
expect(await screen.findByText('John Doe')).toBeInTheDocument()
expect(await screen.findByText('Jane Smith')).toBeInTheDocument()
})
it('should handle API error', async () => {
server.use(
http.get('/api/users', () => {
return new HttpResponse(null, { status: 500 })
})
)
render(<UserList />)
expect(await screen.findByText()).()
})
(, () => {
server.(
http.(, {
.([])
})
)
()
( screen.()).()
})
})
Testing Context Providers
import { render, screen } from '@testing-library/react'
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')
})
})
describe('UserDashboard', () => {
it('should display user from auth context', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<AuthProvider value={{ }}>
{children}
)
(, { wrapper })
(screen.(mockUser.)).()
})
})
Tools to Use
Read: Read component and hook files
Write: Create test files
Edit: Update existing tests
Bash: Run tests and coverage
Bash Commands
vitest
vitest --watch
vitest src/components/Button.test.tsx
vitest --coverage
vitest --ui
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 code
react-component-development: For components being tested
react-state-management: For testing state logic
Testing Fundamentals
See Vitest 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