MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Vitest: A Vite-Native Unit Test Framework

Test with Vitest: native Vite integration, snapshot testing, and coverage for TypeScript.

VitestTestingTypeScriptFrontend

By MinhVo

Introduction

Testing JavaScript and TypeScript applications has traditionally been a friction-heavy process. Jest, while powerful, requires complex configuration for TypeScript, ESM modules, and modern frameworks. Vite's ecosystem demanded a testing solution that could leverage its native module resolution and transform pipeline. Enter Vitest—a blazing-fast, Vite-native testing framework that provides Jest-compatible APIs while solving the configuration headaches that have plagued frontend developers for years.

Vitest was created by Anthony Fu in late 2021 and has since become one of the fastest-growing testing frameworks in the JavaScript ecosystem. It's now used by major projects including Vue, Nuxt, Astro, and SolidJS. The framework's philosophy is simple: if your build tool already understands your code, your test runner should too.

In this comprehensive guide, we'll explore Vitest's architecture, walk through real-world testing patterns, and compare it directly with Jest. You'll learn how to set up Vitest from scratch, write unit and integration tests, use snapshot testing effectively, configure code coverage, and optimize test performance for large codebases.

Testing and code quality concept

Why Vitest Over Jest?

The Configuration Problem

With Jest, testing a modern TypeScript + Vite project requires:

  1. Installing jest, @jest/globals, ts-jest or @swc/jest, and jest-environment-jsdom
  2. Configuring module resolution for path aliases
  3. Handling CSS/asset imports with mock transforms
  4. Dealing with ESM compatibility issues
  5. Maintaining separate tsconfig.test.json files

Vitest eliminates most of this by reusing your existing vite.config.ts. Module resolution, path aliases, transforms, and plugins all work out of the box because Vitest uses Vite's module graph.

Performance Comparison

Vitest is significantly faster than Jest in most scenarios:

BenchmarkJestVitestSpeedup
100 unit tests4.2s1.1s3.8x
500 component tests18.5s5.2s3.6x
Watch mode (1 change)800ms150ms5.3x
TypeScript transform2.1s0s (Vite)∞

The speed advantage comes from Vitest's use of Vite's transform pipeline—TypeScript compilation happens once and is shared between dev server and tests.

API Compatibility

Vitest is designed to be Jest-compatible. Most Jest tests can be migrated by changing imports:

// Jest
import { describe, it, expect, vi } from 'jest';
 
// Vitest
import { describe, it, expect, vi } from 'vitest';

The vi object replaces jest for mocking, with some improvements (better ESM mocking, auto-cleanup).

Setting Up Vitest

Installation

npm install -D vitest @vitest/coverage-v8 @vitest/ui

Configuration

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
 
export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './src/test/setup.ts',
    include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'],
    exclude: ['node_modules', 'dist', 'e2e'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: ['node_modules/', 'src/test/'],
      thresholds: {
        branches: 80,
        functions: 80,
        lines: 80,
        statements: 80,
      },
    },
  },
});

Setup File

// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
 
afterEach(() => {
  cleanup();
});

TypeScript Configuration

// tsconfig.json
{
  "compilerOptions": {
    "types": ["vitest/globals"]
  }
}

Setup configuration

Step-by-Step Implementation

Basic Unit Tests

// src/utils/math.test.ts
import { describe, it, expect } from 'vitest';
import { add, multiply, fibonacci } from './math';
 
describe('add', () => {
  it('adds two positive numbers', () => {
    expect(add(2, 3)).toBe(5);
  });
 
  it('handles negative numbers', () => {
    expect(add(-1, 1)).toBe(0);
  });
 
  it('handles floating point', () => {
    expect(add(0.1, 0.2)).toBeCloseTo(0.3);
  });
});
 
describe('fibonacci', () => {
  it('returns correct sequence', () => {
    expect(fibonacci(0)).toBe(0);
    expect(fibonacci(1)).toBe(1);
    expect(fibonacci(10)).toBe(55);
  });
 
  it('handles negative input', () => {
    expect(() => fibonacci(-1)).toThrow('Input must be non-negative');
  });
});

Testing with Mocks

Vitest's mocking system is one of its strongest features. The vi object provides comprehensive mocking capabilities:

// src/services/api.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fetchUser, createUser } from './api';
 
// Mock the entire module
vi.mock('./database', () => ({
  query: vi.fn(),
  insert: vi.fn(),
}));
 
// Import after mock
import { query, insert } from './database';
 
describe('fetchUser', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });
 
  it('fetches user by ID', async () => {
    const mockUser = { id: '1', name: 'Alice', email: 'alice@example.com' };
    vi.mocked(query).mockResolvedValue([mockUser]);
 
    const user = await fetchUser('1');
 
    expect(user).toEqual(mockUser);
    expect(query).toHaveBeenCalledWith(
      'SELECT * FROM users WHERE id = ?',
      ['1']
    );
  });
 
  it('throws when user not found', async () => {
    vi.mocked(query).mockResolvedValue([]);
 
    await expect(fetchUser('999')).rejects.toThrow('User not found');
  });
});
 
describe('createUser', () => {
  it('creates user with hashed password', async () => {
    vi.mocked(insert).mockResolvedValue({ id: '2' });
 
    const result = await createUser({
      name: 'Bob',
      email: 'bob@example.com',
      password: 'secret123',
    });
 
    expect(result.id).toBe('2');
    expect(insert).toHaveBeenCalledWith('users', expect.objectContaining({
      name: 'Bob',
      email: 'bob@example.com',
      password: expect.not.stringContaining('secret123'),
    }));
  });
});

Spy Functions

// src/events/emitter.test.ts
import { describe, it, expect, vi } from 'vitest';
import { EventEmitter } from './emitter';
 
describe('EventEmitter', () => {
  it('calls registered listeners', () => {
    const emitter = new EventEmitter();
    const listener = vi.fn();
 
    emitter.on('click', listener);
    emitter.emit('click', { x: 10, y: 20 });
 
    expect(listener).toHaveBeenCalledOnce();
    expect(listener).toHaveBeenCalledWith({ x: 10, y: 20 });
  });
 
  it('supports multiple listeners', () => {
    const emitter = new EventEmitter();
    const listener1 = vi.fn();
    const listener2 = vi.fn();
 
    emitter.on('click', listener1);
    emitter.on('click', listener2);
    emitter.emit('click');
 
    expect(listener1).toHaveBeenCalledOnce();
    expect(listener2).toHaveBeenCalledOnce();
  });
 
  it('removes listeners with off', () => {
    const emitter = new EventEmitter();
    const listener = vi.fn();
 
    emitter.on('click', listener);
    emitter.off('click', listener);
    emitter.emit('click');
 
    expect(listener).not.toHaveBeenCalled();
  });
});

Timer Mocks

// src/utils/debounce.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { debounce } from './debounce';
 
describe('debounce', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });
 
  afterEach(() => {
    vi.useRealTimers();
  });
 
  it('delays function execution', () => {
    const fn = vi.fn();
    const debounced = debounce(fn, 100);
 
    debounced();
    expect(fn).not.toHaveBeenCalled();
 
    vi.advanceTimersByTime(100);
    expect(fn).toHaveBeenCalledOnce();
  });
 
  it('resets timer on subsequent calls', () => {
    const fn = vi.fn();
    const debounced = debounce(fn, 100);
 
    debounced();
    vi.advanceTimersByTime(50);
    debounced();
    vi.advanceTimersByTime(50);
    expect(fn).not.toHaveBeenCalled();
 
    vi.advanceTimersByTime(50);
    expect(fn).toHaveBeenCalledOnce();
  });
});

Testing workflow

Advanced Features

Snapshot Testing

Vitest supports both inline and external snapshots:

// src/components/UserCard.test.tsx
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { UserCard } from './UserCard';
 
describe('UserCard', () => {
  const mockUser = {
    id: '1',
    name: 'Alice',
    email: 'alice@example.com',
    avatar: '/avatars/alice.jpg',
    role: 'admin' as const,
  };
 
  it('renders user card snapshot', () => {
    const { container } = render(<UserCard user={mockUser} />);
    expect(container).toMatchSnapshot();
  });
 
  it('renders inline snapshot', () => {
    const { getByText } = render(<UserCard user={mockUser} />);
    expect(getByText('Alice')).toMatchInlineSnapshot(`
      <span class="user-name">Alice</span>
    `);
  });
 
  it('renders admin badge for admin users', () => {
    const { getByTestId } = render(<UserCard user={mockUser} />);
    expect(getByTestId('admin-badge')).toBeInTheDocument();
  });
 
  it('does not render badge for regular users', () => {
    const regularUser = { ...mockUser, role: 'user' as const };
    const { queryByTestId } = render(<UserCard user={regularUser} />);
    expect(queryByTestId('admin-badge')).not.toBeInTheDocument();
  });
});

Parameterized Tests

// src/utils/validators.test.ts
import { describe, it, expect } from 'vitest';
import { validateEmail, validatePassword } from './validators';
 
describe('validateEmail', () => {
  const validEmails = [
    'user@example.com',
    'user.name@domain.co.uk',
    'user+tag@example.org',
  ];
 
  const invalidEmails = [
    'invalid',
    '@example.com',
    'user@',
    'user@.com',
    '',
  ];
 
  it.each(validEmails)('accepts valid email: %s', (email) => {
    expect(validateEmail(email)).toBe(true);
  });
 
  it.each(invalidEmails)('rejects invalid email: %s', (email) => {
    expect(validateEmail(email)).toBe(false);
  });
});
 
describe('validatePassword', () => {
  it.each([
    { password: 'short', minLength: 8, expected: false },
    { password: 'longenough', minLength: 8, expected: true },
    { password: 'with123numbers', minLength: 8, expected: true },
    { password: 'AllUpper123!', minLength: 8, expected: true },
  ])('validates "$password" (min: $minLength)', ({ password, minLength, expected }) => {
    expect(validatePassword(password, { minLength })).toBe(expected);
  });
});

Custom Matchers

// src/test/matchers.ts
import { expect } from 'vitest';
 
expect.extend({
  toBeWithinRange(received: number, floor: number, ceiling: number) {
    const pass = received >= floor && received <= ceiling;
    return {
      pass,
      message: () =>
        pass
          ? `expected ${received} not to be within range ${floor} - ${ceiling}`
          : `expected ${received} to be within range ${floor} - ${ceiling}`,
    };
  },
 
  toBeValidDate(received: string) {
    const date = new Date(received);
    const pass = !isNaN(date.getTime());
    return {
      pass,
      message: () =>
        pass
          ? `expected ${received} not to be a valid date`
          : `expected ${received} to be a valid date`,
    };
  },
});
 
// Usage
describe('custom matchers', () => {
  it('checks range', () => {
    expect(15).toBeWithinRange(10, 20);
  });
 
  it('checks valid date', () => {
    expect('2024-01-15').toBeValidDate();
  });
});

Comparison with Jest

FeatureVitestJest
TypeScript supportNative (via Vite)Requires ts-jest/SWC
ESM supportNativePartial (experimental)
Vite integrationNativeManual configuration
Watch mode speed~150ms~800ms
API compatibilityJest-compatibleJest
Snapshot testingYesYes
Code coveragev8/istanbulistanbul
Browser modeYes (experimental)No
In-source testingYesNo

Best Practices

  1. Use vi.mock() at the top level: Module mocking in Vitest uses static hoisting, so vi.mock() calls are automatically moved to the top of the file. Don't put them inside describe or it blocks.

  2. Leverage Vite's transform pipeline: Since Vitest uses Vite's transforms, you can use path aliases, CSS modules, and asset imports in tests without additional configuration.

  3. Use vi.hoisted() for mock factories: When you need to reference mocked values in mock factories, use vi.hoisted() to declare them before the mock.

  4. Configure coverage thresholds: Set minimum coverage thresholds in your config to prevent coverage regressions. Use --coverage flag to generate reports.

  5. Use workspace for monorepos: Vitest supports workspaces for testing multiple packages with different configurations.

  6. Prefer inline snapshots for small values: Use toMatchInlineSnapshot() for small outputs that benefit from being visible in the test file. Use toMatchSnapshot() for larger outputs.

  7. Use vi.stubGlobal() for browser APIs: Instead of manual globalThis assignments, use vi.stubGlobal() for cleaner browser API mocking.

Common Pitfalls

PitfallImpactSolution
Mocking after importTests use real moduleUse vi.mock() (auto-hoisted)
Not clearing mocksState leaks between testsUse beforeEach(() => vi.clearAllMocks())
Snapshot driftFalse positivesReview snapshot changes carefully
Fake timers leakingUnexpected behaviorAlways call vi.useRealTimers() in cleanup
Coverage collection slowdownSlow CIUse --coverage only in CI, not in watch mode
ESM/CJS interop issuesImport errorsUse vi.mock() with factory for CJS modules

Testing React Components

// src/components/SearchInput.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, userEvent } from '@testing-library/react';
import { SearchInput } from './SearchInput';
 
describe('SearchInput', () => {
  it('calls onSearch when user types', async () => {
    const onSearch = vi.fn();
    const user = userEvent.setup();
 
    render(<SearchInput onSearch={onSearch} />);
    const input = screen.getByRole('searchbox');
 
    await user.type(input, 'vitest');
 
    expect(onSearch).toHaveBeenCalledWith('v');
    expect(onSearch).toHaveBeenCalledWith('vi');
    expect(onSearch).toHaveBeenCalledWith('vit');
  });
 
  it('debounces search calls', async () => {
    vi.useFakeTimers();
    const onSearch = vi.fn();
    const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
 
    render(<SearchInput onSearch={onSearch} debounceMs={300} />);
    const input = screen.getByRole('searchbox');
 
    await user.type(input, 'test');
    expect(onSearch).not.toHaveBeenCalled();
 
    vi.advanceTimersByTime(300);
    expect(onSearch).toHaveBeenCalledWith('test');
 
    vi.useRealTimers();
  });
 
  it('renders with placeholder', () => {
    render(<SearchInput placeholder="Search..." />);
    expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument();
  });
});

Performance Optimization

Parallel Execution

Vitest runs test files in parallel by default using worker threads. Control concurrency with the pool option:

// vitest.config.ts
export default defineConfig({
  test: {
    pool: 'threads', // or 'forks', 'vmThreads'
    poolOptions: {
      threads: {
        maxThreads: 4,
        minThreads: 1,
      },
    },
  },
});

Isolated vs. Non-Isolated Mode

// For faster execution (less isolation)
export default defineConfig({
  test: {
    isolate: false, // Share Vite server between test files
  },
});

CI/CD Integration

# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
 
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 -- --coverage
      - uses: codecov/codecov-action@v3
        with:
          files: ./coverage/lcov.info

Architecture Decision Records

When evaluating architectural choices for your project, documenting your decision-making process through Architecture Decision Records (ADRs) provides invaluable context for future team members and stakeholders. Each ADR captures the context, decision, and consequences of a specific architectural choice.

Creating Effective ADRs

An ADR should include the date of the decision, the status (proposed, accepted, deprecated, or superseded), the context that motivated the decision, the decision itself, and the expected consequences both positive and negative. This structured approach ensures that decisions are traceable and reversible when circumstances change.

# ADR-001: Choose React for Frontend Framework
 
## Status: Accepted
 
## Context
We need a frontend framework that supports component-based architecture,
has a large ecosystem, and provides good TypeScript support.
 
## Decision
We will use React 18+ with TypeScript for all new frontend projects.
 
## Consequences
- Large talent pool available for hiring
- Mature ecosystem with extensive third-party libraries
- Strong TypeScript integration
- Requires additional libraries for routing and state management

Decision Matrix for Technology Selection

Create a weighted decision matrix when comparing multiple options. List your evaluation criteria (performance, learning curve, ecosystem maturity, community support, long-term viability) and assign weights based on your project priorities. Score each option on a scale of 1-5 for each criterion, then calculate weighted totals.

This systematic approach removes emotion from technology decisions and provides a defensible rationale when stakeholders question your choices. Document the matrix alongside your ADR so future teams understand not just what was chosen, but why alternatives were rejected.

Reversibility and Migration Paths

Every architectural decision should include a migration path in case the decision needs to be reversed. Consider the cost of changing course at six months, twelve months, and two years. Decisions with low reversal costs can be made more aggressively, while irreversible decisions warrant extended evaluation periods and proof-of-concept implementations.

For example, choosing a CSS-in-JS library has a relatively low reversal cost since styles can be migrated incrementally component by component. However, choosing a database technology has a high reversal cost due to data migration complexity and potential schema changes throughout the codebase.

Community Resources and Further Learning

The technology landscape evolves rapidly, making continuous learning essential for maintaining expertise. Building a systematic approach to staying current with developments in your technology stack ensures you can leverage new features and avoid deprecated patterns.

Curated Learning Pathways

Rather than consuming content randomly, create structured learning pathways aligned with your current projects and career goals. Start with official documentation and specification documents, which provide the most accurate and comprehensive information. Follow this with hands-on tutorials and workshops that reinforce concepts through practical application.

Technical blogs from framework maintainers and core team members often provide deeper insights into design decisions and upcoming features. Subscribe to the official blogs of your primary frameworks and libraries to stay ahead of breaking changes and deprecation timelines.

Contributing to Open Source

Contributing to open-source projects in your technology stack provides unparalleled learning opportunities. Start with documentation improvements and bug reports, then progress to fixing small issues tagged as "good first issue" in your favorite projects. This direct engagement with maintainers and the codebase accelerates your understanding far beyond what passive learning can achieve.

# Setting up for contribution
git clone https://github.com/project/repository.git
cd repository
git checkout -b fix/issue-description
 
# Run the project's contribution setup
npm run setup:dev
npm run test  # Ensure tests pass before making changes
 
# Make your changes, then run the full test suite
npm run test:full
npm run lint
npm run build
 
# Submit your contribution
git add -A
git commit -m "fix: description of the fix
 
Closes #1234"
git push origin fix/issue-description

Building a Technical Knowledge Base

Maintain a personal knowledge base that captures insights, solutions, and patterns you discover during your work. Tools like Obsidian, Notion, or even a simple Markdown repository can serve as an external memory that grows more valuable over time.

Organize your notes by topic rather than chronologically, and include code examples, links to relevant documentation, and explanations of why certain approaches work better than others. When you encounter a particularly insightful article or conference talk, write a summary that captures the key takeaways and how they apply to your current projects.

Follow key conferences and their published talks to stay informed about emerging patterns and best practices. Many conferences publish recorded talks on YouTube within weeks of the event, making world-class technical content freely accessible.

Join relevant Discord servers, Slack communities, and forums where practitioners discuss real-world challenges and solutions. These communities provide early warning about emerging issues and access to collective wisdom that isn't available through formal documentation.

Mentorship and Knowledge Sharing

Teaching others is one of the most effective ways to deepen your own understanding. Consider writing technical blog posts, giving talks at local meetups, or mentoring junior developers. The process of explaining concepts to others forces you to organize your knowledge and identify gaps in your understanding.

Pair programming sessions with colleagues of different experience levels create mutual learning opportunities. Senior developers gain fresh perspectives on problems they've solved the same way for years, while junior developers benefit from exposure to production-grade thinking and decision-making processes.

Conclusion

Vitest represents a paradigm shift in JavaScript testing. By leveraging Vite's transform pipeline, it eliminates the configuration overhead that has made testing feel like a chore. The Jest-compatible API means migration is straightforward, while the native TypeScript and ESM support means you spend less time fighting tooling and more time writing tests.

Key takeaways:

  1. Native Vite integration: Zero-config TypeScript, path aliases, and asset handling
  2. Blazing fast: 3-5x faster than Jest through shared transforms
  3. Jest-compatible API: Easy migration with vi replacing jest
  4. Modern features: Browser mode, in-source testing, workspace support
  5. Active development: Rapidly evolving with strong community support

Start by adding Vitest to your Vite project today. The setup takes minutes, and the speed improvement is immediately noticeable. As your test suite grows, you'll appreciate the performance gains and the elimination of configuration headaches.

For more information, explore the Vitest documentation and the Vitest GitHub repository.