Vitest (Testing Framework)
Overview
Vitest is a blazing fast unit test framework powered by Vite. Native TypeScript support, ESM by default, Jest-compatible API, and instant watch mode.
Version: v2.x (2024-2025)
Requirements: Node ≥18
Key Benefit: Zero config for TypeScript, uses Vite's transform pipeline, 10-20x faster than Jest.
When to Use This Skill
✅ Use Vitest when:
-
Testing TypeScript code (tRPC, Zod, utilities)
-
Working with Vite-based projects
-
Need fast watch mode during development
-
Want native ESM support
-
Testing React components (with @testing-library)
❌ Consider Jest when:
-
Existing Jest setup with many custom configs
-
Need specific Jest ecosystem plugins
-
Team unfamiliar with Vitest
Quick Start
Installation
npm install -D vitest @vitest/coverage-v8 vitest-mock-extended npm install -D vite-tsconfig-paths # For path aliases
Configuration
// vitest.config.ts import { defineConfig } from 'vitest/config'; import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({ plugins: [tsconfigPaths()], test: { globals: true, // Use describe, it, expect without imports environment: 'node', // or 'jsdom' for React include: ['/*.test.ts'], setupFiles: ['./src/test/setup.ts'], coverage: { provider: 'v8', include: ['src//.ts'], exclude: ['src/**/.test.ts', 'src/test/**'], }, mockReset: true, restoreMocks: true, }, });
TypeScript Config (for globals)
// tsconfig.json { "compilerOptions": { "types": ["vitest/globals"] } }
Scripts
// package.json { "scripts": { "test": "vitest", "test:run": "vitest run", "test:coverage": "vitest run --coverage" } }
Basic Test Structure
// src/utils/math.test.ts import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { add, multiply } from './math';
describe('Math Utils', () => { describe('add', () => { it('should add two numbers', () => { expect(add(2, 3)).toBe(5); });
it('should handle negative numbers', () => {
expect(add(-1, 1)).toBe(0);
});
});
describe('multiply', () => { it('should multiply two numbers', () => { expect(multiply(2, 3)).toBe(6); }); }); });
Testing tRPC Procedures
Mock Context Setup
// src/test/context.ts import { PrismaClient } from '@prisma/client'; import { mockDeep, DeepMockProxy } from 'vitest-mock-extended';
export type MockContext = { prisma: DeepMockProxy<PrismaClient>; user: { id: string; role: string } | null; };
export const createMockContext = (user = null): MockContext => ({ prisma: mockDeep<PrismaClient>(), user, });
Testing with createCallerFactory
// src/server/routers/user.test.ts import { describe, it, expect, beforeEach } from 'vitest'; import { createCallerFactory } from '../trpc'; import { userRouter } from './user'; import { createMockContext, MockContext } from '@/test/context';
describe('User Router', () => { let mockCtx: MockContext; const createCaller = createCallerFactory(userRouter);
beforeEach(() => { mockCtx = createMockContext(); });
describe('getById', () => { it('should return user by id', async () => { const mockUser = { id: '1', email: 'test@example.com', name: 'Test', role: 'USER', createdAt: new Date(), updatedAt: new Date(), };
mockCtx.prisma.user.findUnique.mockResolvedValue(mockUser);
const caller = createCaller(mockCtx);
const result = await caller.getById({ id: '1' });
expect(result).toEqual(mockUser);
expect(mockCtx.prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: '1' },
});
});
it('should throw NOT_FOUND for missing user', async () => {
mockCtx.prisma.user.findUnique.mockResolvedValue(null);
const caller = createCaller(mockCtx);
await expect(caller.getById({ id: '1' }))
.rejects.toThrow('NOT_FOUND');
});
});
describe('create (protected)', () => { it('should reject unauthenticated requests', async () => { const caller = createCaller(mockCtx); // user is null
await expect(caller.create({
email: 'new@example.com',
name: 'New'
})).rejects.toThrow('UNAUTHORIZED');
});
it('should create user when authenticated', async () => {
mockCtx = createMockContext({ id: 'admin', role: 'ADMIN' });
const mockUser = { id: '2', email: 'new@example.com', name: 'New' };
mockCtx.prisma.user.create.mockResolvedValue(mockUser);
const caller = createCaller(mockCtx);
const result = await caller.create({
email: 'new@example.com',
name: 'New'
});
expect(result).toEqual(mockUser);
});
}); });
Testing Zod Schemas
// src/schemas/user.schema.test.ts import { describe, it, expect } from 'vitest'; import { CreateUserSchema, EmailSchema } from './user.schema';
describe('CreateUserSchema', () => { it('should validate correct input', () => { const result = CreateUserSchema.safeParse({ email: 'test@example.com', name: 'Test User', password: 'SecurePass123', });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.email).toBe('test@example.com');
}
});
it('should reject invalid email', () => { const result = CreateUserSchema.safeParse({ email: 'invalid-email', name: 'Test', password: 'SecurePass123', });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].path).toEqual(['email']);
}
});
it('should reject short password', () => { const result = CreateUserSchema.safeParse({ email: 'test@example.com', name: 'Test', password: '123', });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].path).toEqual(['password']);
}
}); });
describe('EmailSchema', () => { it.each([ ['test@example.com', true], ['user.name@domain.org', true], ['invalid', false], ['@missing.com', false], ['no-domain@', false], ])('should validate "%s" as %s', (email, expected) => { const result = EmailSchema.safeParse(email); expect(result.success).toBe(expected); }); });
Mocking Patterns
Mock Functions
import { vi, describe, it, expect } from 'vitest';
// Mock a function const mockFn = vi.fn(); mockFn.mockReturnValue(42); mockFn.mockResolvedValue(42); // For async mockFn.mockRejectedValue(new Error('fail'));
// Verify calls expect(mockFn).toHaveBeenCalled(); expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2'); expect(mockFn).toHaveBeenCalledTimes(1);
Mock Modules
import { vi, describe, it, expect } from 'vitest';
// Mock entire module vi.mock('@/lib/email', () => ({ sendEmail: vi.fn().mockResolvedValue({ success: true }), }));
import { sendEmail } from '@/lib/email';
it('should send email', async () => { await sendEmail('test@example.com', 'Subject', 'Body'); expect(sendEmail).toHaveBeenCalled(); });
Spy on Methods
import { vi, describe, it, expect } from 'vitest';
const obj = { method: () => 'original', };
const spy = vi.spyOn(obj, 'method'); spy.mockReturnValue('mocked');
expect(obj.method()).toBe('mocked'); expect(spy).toHaveBeenCalled();
spy.mockRestore(); // Restore original
Test Boundaries
Type Scope Database Speed Use
Unit Single function Mocked Fast tRPC procedures, utils
Integration Router + DB Real test DB Medium Full flow testing
E2E HTTP stack Real test DB Slow API contracts
Setup Files
// src/test/setup.ts import { beforeAll, afterAll, afterEach } from 'vitest';
// Global setup beforeAll(async () => { // Connect to test database, etc. });
afterAll(async () => { // Cleanup });
afterEach(() => { // Reset mocks between tests });
Common Assertions
// Equality expect(value).toBe(exact); // === expect(value).toEqual(deepEqual); // Deep equality expect(value).toStrictEqual(strict); // Including undefined props
// Truthiness expect(value).toBeTruthy(); expect(value).toBeFalsy(); expect(value).toBeNull(); expect(value).toBeDefined();
// Numbers expect(value).toBeGreaterThan(3); expect(value).toBeLessThanOrEqual(10); expect(value).toBeCloseTo(0.3, 5); // Float precision
// Strings expect(value).toMatch(/regex/); expect(value).toContain('substring');
// Arrays/Objects expect(array).toContain(item); expect(array).toHaveLength(3); expect(object).toHaveProperty('key', 'value');
// Errors expect(() => fn()).toThrow(); expect(() => fn()).toThrow('message'); await expect(asyncFn()).rejects.toThrow();
Rules
Do ✅
-
Use describe blocks to organize related tests
-
Use beforeEach for fresh mock context
-
Mock external dependencies (DB, APIs)
-
Test edge cases and error paths
-
Use it.each for parameterized tests
-
Keep unit tests fast and isolated
Avoid ❌
-
Testing implementation details
-
Skipping error case tests
-
Sharing mutable state between tests
-
Over-mocking (test real logic)
-
Tests that depend on other tests
Troubleshooting
"Cannot find module": → Check vite-tsconfig-paths plugin → Verify path aliases in tsconfig → Restart Vitest
"Mock not working": → Ensure vi.mock() is at top level (hoisted) → Check mockReset/restoreMocks in config → Use vi.mocked() for type inference
"Async test timeout": → Increase timeout: it('test', async () => {}, 10000) → Check for unresolved promises → Verify mocks return resolved values
"Coverage not accurate": → Use v8 provider (faster, more accurate) → Exclude test files from coverage → Run with --coverage flag
File Structure
src/ ├── server/routers/ │ ├── user.ts │ └── user.test.ts # Co-located tests ├── schemas/ │ ├── user.schema.ts │ └── user.schema.test.ts ├── utils/ │ ├── helpers.ts │ └── helpers.test.ts └── test/ ├── setup.ts # Global setup └── context.ts # Mock context factory
vitest.config.ts # Vitest configuration
References
-
https://vitest.dev — Official documentation
-
https://vitest.dev/api — API reference
-
https://vitest.dev/guide/mocking — Mocking guide