본문으로 건너뛰기 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('test@email.com').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: 'alice@example.com' });

    expect(sendEmail).toHaveBeenCalledWith({
      to: 'alice@example.com',
      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: 'alice@example.com',
    });

    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('alice@example.com')).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), 'alice@example.com');
    await user.type(screen.getByLabelText(/password/i), 'password123');
    await user.click(screen.getByRole('button', { name: /sign in/i }));

    expect(onSubmit).toHaveBeenCalledWith({
      email: 'alice@example.com',
      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.

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:


Frequently Asked Questions (FAQ)

Q. When would I use this in practice?

A. Master Vitest for modern JavaScript testing. Covers test syntax, mocking modules and APIs, snapshot testing, coverage re…

Q. What should I read before this?

A. Follow the previous article or related articles links at the bottom of each post to learn in sequence.

Q. Where can I study this more deeply?

A. Check cppreference and the relevant library’s official documentation. The reference links at the end of the article are also worth using.


Other articles related to this topic.


Keywords Covered in This Article (Related Search Terms)

This article covers Vitest, Testing, JavaScript, TypeScript, React, Unit Testing, Frontend.