Vitest Unit Testing with TypeScript — Complete Guide 2026
Advertisement
Introduction
Why Vitest in 2026
Vitest is the de-facto testing framework for modern TypeScript projects. It uses Vite under the hood for native ESM, TypeScript, and JSX support with zero configuration. Tests run in parallel, and the watch mode is instant due to module-level caching.
Key advantages over Jest: native TypeScript without Babel, faster startup, compatible Jest API (easy migration), and built-in vi mock utilities.
Setup
npm install --save-dev vitest @vitest/coverage-v8// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true, // describe, it, expect available without import
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.d.ts', 'src/index.ts'],
thresholds: { lines: 80, functions: 80 },
},
setupFiles: ['./src/test/setup.ts'],
},
});// package.json scripts
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}Writing Your First Tests
// src/utils/math.ts
export function add(a: number, b: number): number {
return a + b;
}
export function divide(a: number, b: number): number {
if (b === 0) throw new Error('Division by zero');
return a / b;
}
// src/utils/math.test.ts
import { describe, it, expect } from 'vitest';
import { add, divide } from './math';
describe('add', () => {
it('returns the sum of two numbers', () => {
expect(add(2, 3)).toBe(5);
});
it('handles negative numbers', () => {
expect(add(-1, 1)).toBe(0);
});
});
describe('divide', () => {
it('divides two numbers', () => {
expect(divide(10, 2)).toBe(5);
});
it('throws on division by zero', () => {
expect(() => divide(10, 0)).toThrow('Division by zero');
});
});Mocking Modules
// src/services/email.ts
export async function sendEmail(to: string, subject: string): Promise<void> {
// real SMTP call
}
// src/services/user.ts
import { sendEmail } from './email';
export async function createUser(data: { name: string; email: string }) {
const user = await db.users.create(data);
await sendEmail(user.email, 'Welcome!');
return user;
}
// src/services/user.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the entire module before imports are resolved
vi.mock('./email', () => ({
sendEmail: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../db', () => ({
db: {
users: {
create: vi.fn(),
},
},
}));
import { createUser } from './user';
import { sendEmail } from './email';
import { db } from '../db';
describe('createUser', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('creates a user and sends welcome email', async () => {
const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' };
vi.mocked(db.users.create).mockResolvedValue(mockUser);
const result = await createUser({ name: 'Alice', email: 'alice@example.com' });
expect(db.users.create).toHaveBeenCalledWith({ name: 'Alice', email: 'alice@example.com' });
expect(sendEmail).toHaveBeenCalledWith('alice@example.com', 'Welcome!');
expect(result).toEqual(mockUser);
});
it('throws if db.create fails', async () => {
vi.mocked(db.users.create).mockRejectedValue(new Error('DB error'));
await expect(createUser({ name: 'Alice', email: 'a@b.com' })).rejects.toThrow('DB error');
});
});Spies
import { vi, describe, it, expect, afterEach } from 'vitest';
describe('console spy', () => {
afterEach(() => vi.restoreAllMocks());
it('logs when user is created', async () => {
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
await createUser({ name: 'Alice', email: 'a@b.com' });
expect(spy).toHaveBeenCalledWith(expect.stringContaining('User created'));
});
});Testing Async Code
import { describe, it, expect, vi } from 'vitest';
// Fake timers for setTimeout/setInterval
it('retries after delay', async () => {
vi.useFakeTimers();
const fn = vi.fn()
.mockRejectedValueOnce(new Error('timeout'))
.mockResolvedValue('ok');
const promise = retryWithDelay(fn, 1000);
vi.advanceTimersByTime(1000);
const result = await promise;
expect(result).toBe('ok');
expect(fn).toHaveBeenCalledTimes(2);
vi.useRealTimers();
});Testing Express Route Handlers
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Request, Response } from 'express';
function mockReqRes(overrides: Partial<Request> = {}) {
const req = { body: {}, params: {}, query: {}, headers: {}, ...overrides } as Request;
const res = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
send: vi.fn().mockReturnThis(),
} as unknown as Response;
return { req, res };
}
import { getUser } from '../controllers/users';
import { userService } from '../services/userService';
vi.mock('../services/userService');
describe('GET /users/:id', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 200 with user', async () => {
const mockUser = { id: 1, name: 'Alice' };
vi.mocked(userService.findById).mockResolvedValue(mockUser);
const { req, res } = mockReqRes({ params: { id: '1' } });
await getUser(req, res, vi.fn());
expect(res.status).not.toHaveBeenCalled();
expect(res.json).toHaveBeenCalledWith({ user: mockUser });
});
it('returns 404 when user not found', async () => {
vi.mocked(userService.findById).mockResolvedValue(null);
const { req, res } = mockReqRes({ params: { id: '99' } });
await getUser(req, res, vi.fn());
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'User not found' });
});
});Common Mistakes
- Using
vi.mockinsidedescribeorit— always hoistvi.mockto the top of the file - Not calling
vi.clearAllMocks()between tests — mock state bleeds across tests causing flaky results - Testing implementation details instead of behavior — test what the function returns, not how it does it
- Mocking everything — keep database interactions for integration tests; mock only external I/O in unit tests
- Forgetting
awaiton async assertions —expect(promise).resolves.toBe(x)needsawait
Best Practices
- Name test files
*.test.tsor*.spec.tsalongside source files for co-location - Follow AAA: Arrange (set up mocks and data), Act (call the function), Assert (verify output)
- Use
vi.clearAllMocks()inbeforeEach, notvi.resetAllMocks()— reset clears implementations - Target 80% line coverage as a floor — 100% coverage does not mean 100% correct behavior
- Run
vitest --reporter=verbosein CI to see individual test names in build logs
Key Takeaways
- Vitest provides native TypeScript support with zero configuration — no Babel or ts-jest required
vi.mock()replaces module exports with mock functions for unit isolation — hoist to file topvi.spyOn()wraps existing functions to track calls without replacing the original implementationvi.mocked()adds TypeScript types to mocked functions for safe.mockResolvedValue()calls- Fake timers (
vi.useFakeTimers()) control setTimeout and setInterval for deterministic async tests vi.clearAllMocks()inbeforeEachensures each test starts with clean mock state- Coverage thresholds enforced in config prevent merging under-tested code
- Vitest is Jest-API compatible — migration from Jest is usually just changing import paths
Advertisement
Related reading
Node.js Built-in Test Runner — Ditch Jest and Vitest for Zero-Dependency Testing6 min readPrisma ORM Guide 2026 — Type-Safe Database Access with PostgreSQL5 min readAPI-First Development in 2026 — Design, Mock, Validate, Then Build6 min readbetter-auth — The Open-Source Auth Library That Replaces NextAuth6 min readData Corruption from Bad Serialization — When Your Data Silently Changes6 min readDeno 2 for Backend Engineers — The Node.js Alternative That Finally Has npm Support6 min read