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:
- Think before you code: Writing tests is essentially designing the function’s behavior, forcing you to clarify inputs and outputs.
- Fast iteration: In Vitest watch mode, changing code gives instant feedback, no waiting.
- 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
- Don’t chase 100%: High coverage numbers don’t mean high code quality. Test key logic, let edge cases go.
- Test core paths first: Main flow tests have highest priority, exception branches second.
- Clean up unused tests regularly: Tests need maintenance too, delete outdated and redundant ones.
- Make watch mode a habit: Keep
vitestwatch 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
Step1: Install Vitest
Run the installation command:
```bash
npm install -D vitest
```
System requirements: Vite >= 6.0.0, Node >= 20.0.0 - 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
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
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
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
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?
How to migrate from Jest to Vitest?
• 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?
How to mock API requests in Vitest?
• 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?
What's the core of TDD development workflow?
• 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
Related Posts
Supabase Storage in Practice: File Uploads, CDN, and Access Control
Supabase Storage in Practice: File Uploads, CDN, and Access Control
Docker Compose Production Deployment: Health Checks, Restart Policies, and Log Management
Docker Compose Production Deployment: Health Checks, Restart Policies, and Log Management
Nginx Performance Tuning: gzip, Caching, and Connection Pool Configuration

Comments
Sign in with GitHub to leave a comment