| name | backend-vitest |
| description | Fast unit testing framework for TypeScript/JavaScript. Use for testing tRPC procedures, Zod schemas, utility functions, and any TypeScript code. Built on Vite with native ESM, TypeScript support, and Jest-compatible API. Choose Vitest over Jest for modern TypeScript projects, especially with Vite-based setups. |
| allowed-tools | Read, Edit, Write, Bash (*) |
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
describeblocks to organize related tests - Use
beforeEachfor fresh mock context - Mock external dependencies (DB, APIs)
- Test edge cases and error paths
- Use
it.eachfor 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