Vitest Complete Guide | Unit Testing, Mocking, Coverage & React Testing

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: