Switch Language
Toggle Theme

Vitest Unit Testing in Practice: From Setup to TDD Workflow

Introduction

How long does it take to configure Jest for an ESM project? ts-jest, babel-jest, jest.config.js… and you still have to deal with various module resolution issues. I once spent an entire afternoon just getting Jest to correctly recognize .vue file imports.

Vitest needs just one line of configuration.

That’s not an exaggeration. When I ran vitest for the first time in a Vite project and watched test cases complete in seconds, it felt like replacing a three-year-old sluggish computer—refreshing.

How fast is Vitest? Official data and community benchmarks both show: cold start around 200ms (Jest takes 2-4 seconds), running 500 test cases takes about 8 seconds (Jest needs around 45 seconds). Even better, it shares configuration with Vite, has native TypeScript support, and its API is nearly identical to Jest—migration might take just 30 minutes.

This article will guide you from zero configuration to a complete TDD workflow, including mocking techniques and coverage setup. Whether you want to use Vitest in a new project or migrate an existing Jest project, you’ll find what you need here.

What is Vitest? Why is it so fast?

Simply put, Vitest is a Vite-native testing framework.

If you’re already using Vite to build your project, Vitest is basically “plug and play.” It directly reuses Vite’s configuration—aliases, environment variables, CSS handling—all automatically inherited. No need to configure that pile of Jest settings: transform, moduleFileExtensions, moduleNameMapper.

It has three core advantages:

Speed. Cold start is around 200 milliseconds, while Jest typically takes 2-4 seconds. The difference is even more noticeable in large projects: 500 test cases take Vitest about 8 seconds, but Jest needs around 45 seconds (data from DEV Community 2026 benchmarks). That’s not a small gap.

Jest compatibility. The API is nearly identical—describe, it, expect, vi.fn(), the syntax is no different from Jest. Migrating from Jest is basically just changing import paths.

Smart watch mode. It has a feature called “HMR for tests”: when you change code, only relevant tests re-run, not the entire suite. That instant feedback during development feels great.

Now that we’ve covered “what it is,” let’s look at how to install it.

Installation and Configuration

First, system requirements: Vite version 6.0.0 or higher, Node version 20.0.0 or higher. If your project is relatively new, these should already be satisfied.

Installation

One command:

npm install -D vitest

That’s it. No need to install a bunch of packages like @types/jest, ts-jest, jest-environment-jsdom… Vitest natively supports TypeScript.

Configuration File

Two options: either add a test field to vite.config.ts, or create a separate vitest.config.ts.

For simple projects, just use vite.config.ts:

// vite.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    globals: true,  // Global variables, no need to import { describe, it, expect } each time
    environment: 'node', // or 'jsdom' (for browser environment testing)
    include: ['tests/**/*.test.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
    },
  },
})

The globals: true line is quite useful—once enabled, describe, it, and expect all become global variables, so you don’t have to import them in every test file. Just like Jest.

If your tests need DOM APIs (like testing component rendering), change environment to 'jsdom', then install jsdom:

npm install -D jsdom

Run Scripts

Add two lines to package.json:

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run"
  }
}

npm test enters watch mode (automatically re-runs when code changes), npm run test:run runs once and exits (use this in CI environments).

That’s all the configuration. Compared to Jest’s pile of preset, transform, moduleFileExtensions, it saves so much hassle.

Writing Unit Tests

Test file naming convention uses .test.ts or .spec.ts, placed in a tests/ directory or alongside source files. It’s up to team preference.

Basic Structure

A simplest test:

import { describe, it, expect } from 'vitest'
import { add, divide } from './math'

describe('Math utilities', () => {
  it('should add two numbers', () => {
    expect(add(2, 3)).toBe(5)
  })

  it('should throw on division by zero', () => {
    expect(() => divide(10, 0)).toThrow('Division by zero')
  })
})

describe groups tests, it defines individual test cases, and expect makes assertions.

If you’ve enabled globals: true, you can skip the import line.

Common Assertions

Here are the most commonly used ones:

// Basic equality
expect(value).toBe(5)            // Strict equality (===)
expect(obj).toEqual({ a: 1 })    // Deep equality

// Truthiness checks
expect(value).toBeTruthy()
expect(value).toBeFalsy()
expect(value).toBeNull()

// Exceptions
expect(() => fn()).toThrow()
expect(() => fn()).toThrow('Error message')

// Number comparisons
expect(n).toBeGreaterThan(10)
expect(n).toBeLessThanOrEqual(5)

// Array/string contains
expect(arr).toContain('item')
expect(str).toMatch(/pattern/)

Test Filtering

Only want to run a specific test during development? Use .only:

it.only('only run this test', () => { ... })

Want to temporarily skip a test? Use .skip:

it.skip('skip this for now', () => { ... })

Both work on describe too: describe.only(...), describe.skip(...).

Run it and see the effect. In watch mode, you’ll immediately see test results turn green or red after changing code—this instant feedback is so much better than Jest’s experience of waiting several seconds for startup every time.

TDD Workflow in Practice

The core philosophy of TDD (Test-Driven Development) is simple: write tests first, then write code.

It sounds counterintuitive, but once you try it, you’ll find it forces you to think clearly about “what this function should do” before writing code. Rather than writing code first and then adding tests as an afterthought—at which point tests often become a formality to “pad coverage numbers.”

Let’s walk through a complete workflow with a practical example. We’ll implement an email validation function validateEmail.

Step 1: Write Tests, No Implementation Yet

First, create a test file:

// tests/validateEmail.test.ts
import { describe, it, expect } from 'vitest'
import { validateEmail } from '../src/validateEmail'

describe('validateEmail', () => {
  it('should return true for valid email', () => {
    expect(validateEmail('[email protected]')).toBe(true)
  })

  it('should return false for invalid email', () => {
    expect(validateEmail('invalid')).toBe(false)
  })
})

At this point, the validateEmail function doesn’t exist yet, so running tests will definitely fail. But that’s okay—this is TDD’s first step: make the test fail.

Step 2: Implement Minimal Code

Now create the function with the simplest implementation to make tests pass:

// src/validateEmail.ts
export function validateEmail(email: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}

Run tests: npm test.

See both tests turn green? Good, Step 2 complete.

Step 3: Add More Edge Case Tests

The basics pass, but email validation has many edge cases. Let’s add a few:

// tests/validateEmail.test.ts (append)
it('should return false for empty string', () => {
  expect(validateEmail('')).toBe(false)
})

it('should return false for email without domain', () => {
  expect(validateEmail('user@')).toBe(false)
})

it('should return false for email with spaces', () => {
  expect(validateEmail('test @example.com')).toBe(false)
})

Run the tests—if they all pass, the regex is solid. If any fail, fix the regex.

Step 4: Refactor

Tests are passing, now you can safely refactor the code. Maybe improve the regex to a stricter version, or add comments:

// src/validateEmail.ts
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/

export function validateEmail(email: string): boolean {
  if (!email || email.trim() === '') {
    return false
  }
  return EMAIL_REGEX.test(email)
}

Run tests again—still all green. That’s the benefit of TDD: tests have your back during refactoring. If you break something, you’ll know immediately.

Why is TDD Useful?

Honestly, I wasn’t used to “writing tests first” at the beginning either. But after trying it a few times, I discovered:

  1. Think before you code: Writing tests is essentially designing the function’s behavior, forcing you to clarify inputs and outputs.
  2. Fast iteration: In Vitest watch mode, changing code gives instant feedback, no waiting.
  3. Safe refactoring: With test coverage, changing code doesn’t feel scary.

Start with simple functions like utility functions and formatting functions. Once you get the hang of it, extend to more complex logic.

Advanced Mocking Techniques

When writing unit tests, you often need to “fake” external dependencies—API requests, database queries, third-party libraries. That’s when you need Mocks.

Vitest provides a Mock API similar to Jest, centered around the vi object.

vi.fn(): Mock a Single Function

The simplest usage is creating a fake function:

import { vi, describe, it, expect } from 'vitest'

describe('vi.fn() demo', () => {
  it('tracks calls', () => {
    const mockFn = vi.fn()

    mockFn('hello')
    mockFn('world')

    expect(mockFn).toHaveBeenCalledTimes(2)
    expect(mockFn).toHaveBeenNthCalledWith(1, 'hello')
  })
})

You can also preset return values:

const mockFn = vi.fn().mockReturnValue('mocked result')
// Or async return
const asyncMock = vi.fn().mockResolvedValue({ data: 'ok' })

vi.mock(): Mock an Entire Module

Testing a function that depends on an external API? Just mock that module:

import { vi, describe, it, expect, beforeEach } from 'vitest'
import { fetchUser } from './api'
import { UserService } from './UserService'

// Mock api module
vi.mock('./api', () => ({
  fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'Alice' })
}))

describe('UserService', () => {
  beforeEach(() => {
    vi.clearAllMocks() // Clear previous call records before each test
  })

  it('should fetch user', async () => {
    const service = new UserService()
    const user = await service.getUser(1)

    expect(user.name).toBe('Alice')
    expect(fetchUser).toHaveBeenCalledWith(1)
  })
})

vi.mock() executes before module imports, so place it at the top of the file.

vi.spyOn(): Monitor Real Functions

Sometimes you don’t want to completely replace a function, just “listen” to its calls:

import { vi, describe, it, expect, afterEach } from 'vitest'
import { calculator } from './calculator'

describe('spyOn demo', () => {
  afterEach(() => {
    vi.restoreAllMocks()
  })

  it('tracks add calls', () => {
    const addSpy = vi.spyOn(calculator, 'add')

    const result = calculator.add(2, 3)

    expect(result).toBe(5) // Original function executes normally
    expect(addSpy).toHaveBeenCalledWith(2, 3) // While recording the call
  })
})

spyOn is gentler than mock—the function works normally, just with an added “monitor.”

Mock Global Objects

Don’t want to make real HTTP requests in tests? Mock the global fetch:

vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
  ok: true,
  json: () => Promise.resolve({ data: 'mocked' })
}))

Or use Vitest’s vi.stubGlobal to mock browser global objects like window and localStorage.

Mocking is the trickiest part of testing. I recommend starting with simple vi.fn(), then moving up to vi.mock(). Remember to clean up Mock state after each test (clearAllMocks or restoreAllMocks), otherwise tests will pollute each other.

Coverage and Best Practices

Test coverage is a reference metric for code quality. Vitest supports two coverage providers: v8 (faster, native support) and istanbul (better compatibility). Usually v8 is sufficient.

Configure Coverage

Add to vite.config.ts:

test: {
  coverage: {
    provider: 'v8',
    reporter: ['text', 'html', 'lcov'], // Output formats
    thresholds: {
      lines: 80,      // Line coverage threshold
      functions: 80,  // Function coverage threshold
      branches: 70,   // Branch coverage threshold
    },
    exclude: ['node_modules/', 'tests/', '**/*.d.ts'],
  },
}

Run command:

vitest run --coverage

Terminal will display the coverage report, and also generate a coverage/ directory with an HTML report you can open to see which code isn’t covered.

The Purpose of Thresholds

thresholds aren’t just for show—if coverage doesn’t meet the set values, Vitest will error out. This is useful in CI environments: enforcing a certain level of test quality, preventing “just get it in” submissions.

Of course, don’t set thresholds too high. 80% is a reasonable starting point; 100% can actually tire out the team.

CI/CD Integration

Add a step in GitHub Actions or other CI:

# .github/workflows/test.yml
- name: Run tests with coverage
  run: npm run test:run -- --coverage

After running, you can upload the lcov report to Codecov or Coveralls to visually track coverage changes.

Practical Suggestions

  1. Don’t chase 100%: High coverage numbers don’t mean high code quality. Test key logic, let edge cases go.
  2. Test core paths first: Main flow tests have highest priority, exception branches second.
  3. Clean up unused tests regularly: Tests need maintenance too, delete outdated and redundant ones.
  4. Make watch mode a habit: Keep vitest watch running during development, catch issues immediately.

Summary

Vitest’s core advantages come down to: fast, simple, easy to use.

Fast—cold start 200ms, running 500 test cases in about 8 seconds, nearly an order of magnitude faster than Jest. Simple—shares Vite configuration, runs out of the box, no wrestling with transforms and moduleNameMapper. Easy to use—API is basically the same as Jest, migration cost is low.

If you’re using Vite, just choose Vitest. There’s no reason to wrestle with Jest’s ESM configuration issues anymore.

If your project still uses Jest, try spending half an hour to migrate. Install Vitest first, change test file imports, and most cases will run directly.

As for TDD—don’t overthink it. Start with simple utility functions, write tests first then code. Once you get used to it, you’ll find this “think before you code” approach is actually more efficient.

Testing isn’t a burden, it’s a safety net. Spend some time configuring Vitest properly, and you’ll write code with more peace of mind later.

Vitest Unit Testing Setup and TDD Workflow

Complete steps to configure Vitest from scratch and practice TDD development workflow

⏱️ Estimated time: 30 min

  1. 1

    Step1: Install Vitest

    Run the installation command:

    ```bash
    npm install -D vitest
    ```

    System requirements: Vite >= 6.0.0, Node >= 20.0.0
  2. 2

    Step2: Configure vite.config.ts

    Add test field to configuration file:

    ```typescript
    import { defineConfig } from 'vitest/config'

    export default defineConfig({
    test: {
    globals: true,
    environment: 'node',
    include: ['tests/**/*.test.ts'],
    },
    })
    ```

    globals: true lets you skip importing describe, it, expect each time.
  3. 3

    Step3: Add run scripts

    Add to package.json:

    ```json
    {
    "scripts": {
    "test": "vitest",
    "test:run": "vitest run"
    }
    }
    ```

    npm test for watch mode, npm run test:run for single run (CI use).
  4. 4

    Step4: Write first test

    Create test file tests/math.test.ts:

    ```typescript
    import { describe, it, expect } from 'vitest'

    describe('Math', () => {
    it('should add numbers', () => {
    expect(1 + 1).toBe(2)
    })
    })
    ```

    Run npm test to verify configuration is successful.
  5. 5

    Step5: Practice TDD workflow

    Follow test-driven development flow:

    • Step 1: Write test first, define expected function behavior
    • Step 2: Implement minimal code to pass test
    • Step 3: Add edge case tests
    • Step 4: Refactor and optimize

    Use Vitest watch mode for instant feedback.
  6. 6

    Step6: Configure Coverage

    Add coverage configuration:

    ```typescript
    coverage: {
    provider: 'v8',
    reporter: ['text', 'html'],
    thresholds: {
    lines: 80,
    functions: 80,
    },
    }
    ```

    Run vitest run --coverage to generate report.

FAQ

What's the difference between Vitest and Jest?
Vitest is a Vite-native testing framework that shares configuration with Vite, with cold start around 200ms (Jest takes 2-4 seconds). API is almost fully compatible with Jest, migration cost is low. Main advantages are speed, simple configuration, and native TypeScript support.
How to migrate from Jest to Vitest?
Migration steps are simple:

• Uninstall Jest packages, install vitest
• Migrate jest.config.js configuration to vite.config.ts
• Change test file imports from 'jest' to 'vitest'
• Replace jest.fn(), jest.mock() with vi.fn(), vi.mock()

Most cases can complete migration within 30 minutes.
What test environments does Vitest support?
Vitest supports multiple environments: node (default, for backend testing), jsdom (browser DOM environment), happy-dom (faster DOM alternative). Specify via the environment field in configuration; testing browser components requires installing the jsdom package.
How to mock API requests in Vitest?
Three common approaches:

• vi.fn(): Mock a single function, preset return values
• vi.mock(): Mock an entire module, replace all exports
• vi.spyOn(): Monitor real function calls without replacing implementation

Remember to clean up state with vi.clearAllMocks() or vi.restoreAllMocks() after each test.
How to configure Vitest coverage?
Configure provider (recommend v8), reporter (text/html/lcov), and thresholds (coverage thresholds) in vite.config.ts's test.coverage field. Run vitest run --coverage to generate report. Not meeting thresholds will error, suitable for enforcing code quality in CI environments.
What's the core of TDD development workflow?
TDD core is the "Red-Green-Refactor" cycle:

• Red: Write a failing test first
• Green: Write minimal code to pass the test
• Refactor: Optimize code structure

With Vitest watch mode, you get instant feedback after code changes for fast iteration. Writing tests first helps clarify function design thinking.

9 min read · Published on: Apr 14, 2026 · Modified on: Apr 14, 2026

Comments

Sign in with GitHub to leave a comment