본문으로 건너뛰기
Previous
Next
Vitest Complete Guide | Unit Testing· Mocking

Vitest Complete Guide | Unit Testing· Mocking

Vitest Complete Guide | Unit Testing· Mocking

이 글의 핵심

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 and has rapidly become the testing standard for modern frontend projects:

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

Performance and Adoption

Real-world benchmarks (1000 test suite):

  • Jest: 12-15s cold start, 3-5s watch mode
  • Vitest: 1-2s cold start, <1s watch mode

The speed comes from reusing Vite’s transform pipeline — no need to parse/compile files twice (once for dev server, once for tests).

Production usage:

  • Nuxt 3 ships Vitest by default for testing
  • SvelteKit, Solid.js, and Qwik official docs recommend Vitest
  • Storybook 7+ uses Vitest for interaction testing
  • Thousands of open-source React/Vue projects migrated from Jest to Vitest in 2024-2025

Why teams switch to Vitest:

  • 5-10x faster test runs = faster CI/CD pipeline
  • Single config for dev and test (no context switching)
  • Better debugging — use Vite DevTools to debug tests in browser

When to stick with Jest:

  • Large existing Jest suites with custom matchers/transformers
  • Non-Vite projects (plain Node.js, Create React App not yet migrated)
  • Need Jest-specific ecosystem packages (though most work with Vitest)

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

Runner architecture, mocking & coverage (internals)

Runner: Vitest reuses Vite’s dev server pipeline for transforms, so ESM, TypeScript, and plugins behave like production builds. Tests execute in worker threads (configurable) with isolated module caches—avoid relying on global singletons unless documented.

Mocking: vi.mock uses Vite’s module graph; dynamic imports and vi.importActual mirror Jest patterns. vi.stubGlobal helps replace window APIs in jsdom. For browser-specific behavior, consider [Vitest Browser Mode](/en/blog/vitest-browser-mode-testing-guide/.

Coverage: @vitest/coverage-v8 hooks V8’s native counters (fast, accurate for modern V8). Istanbul mode exists for legacy tooling. Merge reports from multiple projects (unit vs browser) in CI if you split suites.

Production-facing quality: Unit tests gate regressions; add E2E (Playwright/Cypress) on staging and synthetic checks after deploy—Vitest does not replace observability.


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:

  • [Vite Complete Guide](/en/blog/vite-complete-guide/
  • [React 18 Deep Dive](/en/blog/react-18-deep-dive/
  • [GitHub Actions CI/CD Guide](/en/blog/github-actions-complete-guide/

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. Master Vitest for modern JavaScript testing. Covers test syntax, mocking modules and APIs, snapshot testing, coverage re… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

Vitest, Testing, JavaScript, TypeScript, React, Unit Testing, Frontend 등으로 검색하시면 이 글이 도움이 됩니다.