Vitest Testing Patterns
This skill helps you write effective tests using Vitest and React Testing Library following project conventions.
When to Use
✅ USE this skill for:
-
Writing unit tests for utilities and functions
-
Creating component tests with React Testing Library
-
Setting up mocks for API calls, databases, or external services
-
Integration testing patterns
-
Understanding test coverage and CI setup
❌ DO NOT use for:
-
Jest-specific patterns → similar but check Jest docs for differences
-
End-to-end testing → use Playwright or Cypress skills
-
Performance testing → use dedicated performance tools
-
API contract testing → use OpenAPI/Pact patterns
Test Infrastructure
Configuration: vitest.config.ts
-
Environment: jsdom
-
Setup file: src/test/setup.ts
-
Coverage: v8 provider
Commands:
npm test # Watch mode npm run test:run # Single run npm run test:coverage # With coverage
File Organization
src/ ├── app/api/tests/ # API route tests ├── components/tests/ # Component tests ├── lib/tests/ # Library/utility tests └── lib/{feature}/tests/ # Feature-specific tests
Name tests as {name}.test.ts or {name}.test.tsx .
Core Testing Patterns
- API Route Tests
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { GET, POST } from '../route'; import { NextRequest } from 'next/server';
// Mock dependencies vi.mock('@/lib/auth', () => ({ getSession: vi.fn(), }));
vi.mock('@/db', () => ({ db: { select: vi.fn().mockReturnThis(), from: vi.fn().mockReturnThis(), where: vi.fn().mockResolvedValue([]), }, }));
describe('GET /api/feature', () => { beforeEach(() => { vi.clearAllMocks(); });
it('returns 401 when not authenticated', async () => { vi.mocked(getSession).mockResolvedValue(null);
const request = new NextRequest('http://localhost/api/feature');
const response = await GET(request);
expect(response.status).toBe(401);
});
it('returns data when authenticated', async () => { vi.mocked(getSession).mockResolvedValue({ userId: 'user-123' }); vi.mocked(db.select).mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([{ id: '1', name: 'Test' }]), }), });
const request = new NextRequest('http://localhost/api/feature');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toHaveLength(1);
}); });
- Component Tests
import { describe, it, expect, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { FeatureComponent } from '../FeatureComponent';
// Mock hooks vi.mock('@/hooks/useAuth', () => ({ useAuth: vi.fn().mockReturnValue({ user: { id: 'user-123', name: 'Test User' }, isLoading: false, }), }));
describe('FeatureComponent', () => { it('renders loading state', () => { vi.mocked(useAuth).mockReturnValueOnce({ user: null, isLoading: true, });
render(<FeatureComponent />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it('handles user interaction', async () => { const user = userEvent.setup(); const onSubmit = vi.fn();
render(<FeatureComponent onSubmit={onSubmit} />);
await user.type(screen.getByRole('textbox'), 'Test input');
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(onSubmit).toHaveBeenCalledWith('Test input');
});
it('displays error state', async () => { vi.mocked(fetch).mockRejectedValueOnce(new Error('Network error'));
render(<FeatureComponent />);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(/error/i);
});
}); });
- Library/Utility Tests
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { processData, formatDate } from '../utils';
describe('processData', () => { it('transforms input correctly', () => { const input = { raw: 'data' }; const result = processData(input);
expect(result).toEqual({
processed: true,
data: 'DATA',
});
});
it('throws on invalid input', () => { expect(() => processData(null)).toThrow('Invalid input'); }); });
describe('formatDate', () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2025-01-15T10:00:00Z')); });
afterEach(() => { vi.useRealTimers(); });
it('formats relative dates', () => { const yesterday = new Date('2025-01-14T10:00:00Z'); expect(formatDate(yesterday)).toBe('yesterday'); }); });
Mocking Patterns
Module Mocking
// Mock entire module vi.mock('@/lib/auth', () => ({ getSession: vi.fn(), requireAuth: vi.fn(), }));
// Mock with partial implementation vi.mock('date-fns', async () => { const actual = await vi.importActual('date-fns'); return { ...actual, format: vi.fn(() => '2025-01-15'), }; });
// Mock default export (like Anthropic SDK) vi.mock('@anthropic-ai/sdk', () => ({ default: class MockAnthropic { messages = { create: vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'Mock response' }], usage: { input_tokens: 10, output_tokens: 20 }, }), }; }, }));
Function Mocking
// Create mock function const mockFn = vi.fn();
// Set return values mockFn.mockReturnValue('sync value'); mockFn.mockResolvedValue('async value'); mockFn.mockRejectedValue(new Error('Failed'));
// One-time behavior mockFn.mockReturnValueOnce('first call only');
// Custom implementation mockFn.mockImplementation((arg) => arg.toUpperCase());
// Verify calls expect(mockFn).toHaveBeenCalled(); expect(mockFn).toHaveBeenCalledTimes(2); expect(mockFn).toHaveBeenCalledWith('expected', 'args');
Chained Mock Pattern (Drizzle ORM)
vi.mock('@/db', () => ({ db: { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ orderBy: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ id: '1' }]), }), }), }), }), insert: vi.fn().mockReturnValue({ values: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([{ id: 'new-1' }]), }), }), }, }));
Timer Mocking
describe('debounced function', () => { beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it('debounces calls', async () => { const callback = vi.fn(); const debounced = debounce(callback, 300);
debounced();
debounced();
debounced();
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(callback).toHaveBeenCalledTimes(1);
}); });
Query Priorities
Use queries in this order (most to least preferred):
-
getByRole - Accessible queries (buttons, links, headings)
-
getByLabelText - Form fields with labels
-
getByPlaceholderText - Inputs with placeholders
-
getByText - Non-interactive elements
-
getByTestId - Last resort (data-testid)
// Preferred screen.getByRole('button', { name: /submit/i }); screen.getByRole('heading', { level: 1 }); screen.getByLabelText(/email/i);
// Avoid unless necessary screen.getByTestId('submit-button');
Async Patterns
// Wait for element to appear await waitFor(() => { expect(screen.getByText('Loaded')).toBeInTheDocument(); });
// Find (built-in waitFor) const element = await screen.findByText('Loaded');
// Wait for element to disappear await waitFor(() => { expect(screen.queryByText('Loading')).not.toBeInTheDocument(); });
Test Cleanup
import { cleanup } from '@testing-library/react';
afterEach(() => { cleanup(); // React cleanup (automatic with setup.ts) vi.clearAllMocks(); // Reset mock call counts vi.resetAllMocks(); // Reset mocks to initial state vi.restoreAllMocks(); // Restore original implementations });
Accessibility Testing
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
it('has no accessibility violations', async () => { const { container } = render(<Component />); const results = await axe(container); expect(results).toHaveNoViolations(); });
Common Matchers
// jest-dom matchers (from setup.ts) expect(element).toBeInTheDocument(); expect(element).toBeVisible(); expect(element).toBeDisabled(); expect(element).toHaveTextContent('text'); expect(element).toHaveAttribute('href', '/path'); expect(element).toHaveClass('active'); expect(input).toHaveValue('input value');
References
-
Vitest Mocking Guide
-
React Testing Library
-
Testing Library Queries