Vitest Unit Testing with TypeScript — Complete Guide 2026

Sanjeev SharmaSanjeev Sharma
5 min read

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.mock inside describe or it — always hoist vi.mock to 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 await on async assertions — expect(promise).resolves.toBe(x) needs await

Best Practices

  • Name test files *.test.ts or *.spec.ts alongside source files for co-location
  • Follow AAA: Arrange (set up mocks and data), Act (call the function), Assert (verify output)
  • Use vi.clearAllMocks() in beforeEach, not vi.resetAllMocks() — reset clears implementations
  • Target 80% line coverage as a floor — 100% coverage does not mean 100% correct behavior
  • Run vitest --reporter=verbose in 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 top
  • vi.spyOn() wraps existing functions to track calls without replacing the original implementation
  • vi.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() in beforeEach ensures 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

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading