Vitest Complete Guide | Unit Testing, Mocking, Coverage & React Testing
이 글의 핵심
Vitest is the testing framework purpose-built for Vite projects — it shares the same config, supports native ESM, and runs tests 5-10x faster than Jest on cold starts. This guide covers everything from basic assertions to component testing and CI coverage reports.
Why Vitest?
Vitest is built for the Vite ecosystem:
Jest setup for Vite project:
- Babel transform to handle ESM
- Separate jest.config.ts
- Module name mapper for assets
- 10+ seconds cold start
Vitest:
- Reads your vite.config.ts
- Native ESM support
- Same alias and plugin config
- < 2 second cold start
Setup
npm install -D vitest
# For React + DOM testing
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
// vite.config.ts — add test config
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true, // Use describe/it/expect without imports
environment: 'jsdom', // DOM environment (or 'node' for backend)
setupFiles: ['./src/test/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
thresholds: {
lines: 80,
functions: 80,
branches: 70,
},
},
},
});
// src/test/setup.ts
import '@testing-library/jest-dom'; // Adds .toBeInTheDocument() etc.
// package.json scripts
{
"scripts": {
"test": "vitest",
"test:run": "vitest run", // Single run (no watch)
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui" // Visual UI in browser
}
}
Basic Test Syntax
// src/utils/math.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { add, multiply, divide } from './math';
describe('Math utilities', () => {
it('adds two numbers', () => {
expect(add(2, 3)).toBe(5);
});
it('multiplies two numbers', () => {
expect(multiply(4, 5)).toBe(20);
});
it('throws on division by zero', () => {
expect(() => divide(10, 0)).toThrow('Division by zero');
});
// Setup and teardown
beforeEach(() => {
// Runs before each test
});
afterEach(() => {
// Runs after each test (cleanup)
});
});
Common Matchers
// Primitives
expect(value).toBe(expected); // Strict equality (===)
expect(value).toEqual(expected); // Deep equality (objects, arrays)
expect(value).not.toBe(expected); // Negation
// Numbers
expect(0.1 + 0.2).toBeCloseTo(0.3); // Float comparison
expect(5).toBeGreaterThan(4);
expect(3).toBeLessThanOrEqual(3);
// Strings
expect('Hello world').toContain('world');
expect('[email protected]').toMatch(/^[\w.]+@[\w.]+\.\w+$/);
// Arrays and objects
expect([1, 2, 3]).toHaveLength(3);
expect([1, 2, 3]).toContain(2);
expect({ name: 'Alice', role: 'admin' }).toMatchObject({ name: 'Alice' });
// Truthiness
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect(value).toBeDefined();
expect(true).toBeTruthy();
expect(0).toBeFalsy();
// Async
await expect(asyncFn()).resolves.toBe('result');
await expect(asyncFn()).rejects.toThrow('Error message');
// Functions (spies)
const fn = vi.fn();
fn('arg1');
expect(fn).toHaveBeenCalled();
expect(fn).toHaveBeenCalledWith('arg1');
expect(fn).toHaveBeenCalledTimes(1);
Mocking
vi.fn() — Mock Functions
import { vi, expect } from 'vitest';
const mockFn = vi.fn();
mockFn.mockReturnValue(42);
mockFn.mockResolvedValue({ data: 'result' }); // Async
mockFn.mockRejectedValue(new Error('Failed')); // Async error
// Implement behavior
mockFn.mockImplementation((x: number) => x * 2);
// Mock specific calls
mockFn
.mockReturnValueOnce('first')
.mockReturnValueOnce('second')
.mockReturnValue('default'); // Subsequent calls
vi.mock() — Mock Modules
// src/services/email.test.ts
import { vi, describe, it, expect } from 'vitest';
// Mock the entire module
vi.mock('../lib/email', () => ({
sendEmail: vi.fn().mockResolvedValue({ success: true }),
}));
import { sendEmail } from '../lib/email';
import { createUser } from '../services/user';
describe('createUser', () => {
it('sends a welcome email after creating a user', async () => {
await createUser({ name: 'Alice', email: '[email protected]' });
expect(sendEmail).toHaveBeenCalledWith({
to: '[email protected]',
subject: 'Welcome!',
});
});
});
vi.spyOn() — Spy on Existing Functions
import { vi } from 'vitest';
// Spy without replacing implementation
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// After test: verify it was called
expect(consoleSpy).toHaveBeenCalledWith('Something went wrong');
// Spy on date
vi.spyOn(Date, 'now').mockReturnValue(1713168000000);
Mock API Calls (fetch)
// src/services/api.test.ts
import { vi, describe, it, expect, beforeEach } from 'vitest';
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe('fetchUser', () => {
beforeEach(() => {
mockFetch.mockReset();
});
it('returns user data on success', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, name: 'Alice' }),
});
const user = await fetchUser(1);
expect(user.name).toBe('Alice');
expect(mockFetch).toHaveBeenCalledWith('/api/users/1');
});
it('throws on API error', async () => {
mockFetch.mockResolvedValue({ ok: false, status: 404 });
await expect(fetchUser(999)).rejects.toThrow('User not found');
});
});
React Component Testing
// src/components/Counter.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';
describe('Counter', () => {
it('renders initial count of 0', () => {
render(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
it('increments count when button is clicked', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
it('can be initialized with a starting value', () => {
render(<Counter initialCount={5} />);
expect(screen.getByText('Count: 5')).toBeInTheDocument();
});
});
Testing Async Components
// src/components/UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { vi } from 'vitest';
import { UserProfile } from './UserProfile';
import * as api from '../services/api';
vi.mock('../services/api');
describe('UserProfile', () => {
it('shows loading state then user data', async () => {
vi.mocked(api.fetchUser).mockResolvedValue({
id: 1,
name: 'Alice',
email: '[email protected]',
});
render(<UserProfile userId={1} />);
// Initially shows loading
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Eventually shows user data
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
expect(screen.getByText('[email protected]')).toBeInTheDocument();
});
it('shows error state on fetch failure', async () => {
vi.mocked(api.fetchUser).mockRejectedValue(new Error('Network error'));
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
});
Testing Forms
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
it('submits with email and password', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/email/i), '[email protected]');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(onSubmit).toHaveBeenCalledWith({
email: '[email protected]',
password: 'password123',
});
});
it('shows validation error for empty email', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={vi.fn()} />);
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
});
});
Snapshot Testing
import { render } from '@testing-library/react';
import { Button } from './Button';
it('matches snapshot', () => {
const { container } = render(
<Button variant="primary" disabled>
Submit
</Button>
);
expect(container.firstChild).toMatchSnapshot();
});
// Update snapshots when intentional changes are made:
// vitest run --update-snapshots
Testing Hooks
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('starts at 0', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('increments count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
});
Coverage Report
# Generate coverage
vitest run --coverage
# Output
# --------------|---------|----------|---------|---------|
# File | % Stmts | % Branch | % Funcs | % Lines |
# --------------|---------|----------|---------|---------|
# All files | 82.45 | 74.13 | 85.71 | 82.45 |
# src/utils | 95.00 | 88.88 | 100.00 | 95.00 |
# math.ts | 100.00 | 100.00 | 100.00 | 100.00 |
# format.ts | 90.00 | 77.77 | 100.00 | 90.00 |
CI Integration
# .github/workflows/test.yml
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run test:run
- run: npm run test:coverage
# Upload coverage to Codecov (optional)
- uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
Quick Reference
// Test file conventions
// *.test.ts, *.spec.ts, __tests__/*.ts
// Run modes
vitest // Watch mode
vitest run // Single run
vitest run --coverage
vitest --ui // Visual UI
// Filter tests
vitest run src/components // Specific file/dir
vitest -t "Counter" // Tests matching name pattern
// Skip and focus
it.skip('skipped test', () => { ... });
it.only('focused test', () => { ... });
describe.skip('skipped suite', () => { ... });
Related posts: