Why Vitest over Jest in 2026
Jest dominated Node.js testing for years, but Vitest has become the better default for new TypeScript projects. The advantages are practical: Vitest shares your existing TypeScript and path alias configuration (no separate babel or ts-jest setup), runs tests in parallel worker threads by default, starts 3–10x faster on cold runs, and has first-class ESM support. The API is intentionally compatible with Jest, so describe, it, expect, vi (instead of jest) all work the same way — migration is usually a search-and-replace.
Project setup
npm install -D vitest @vitest/coverage-v8 @vitest/ui// vitest.config.ts
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
test: {
globals: true, // No need to import describe, it, expect
environment: 'node',
setupFiles: ['./src/test/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov', 'html'],
exclude: [
'node_modules/**',
'dist/**',
'**/*.config.*',
'**/*.test.*',
'src/test/**',
],
thresholds: {
lines: 80,
functions: 80,
branches: 70,
},
},
},
});// src/test/setup.ts — runs before every test file
import { beforeAll, afterAll, beforeEach, vi } from 'vitest';
import { prisma } from '@/lib/db';
// Reset all mocks between tests
beforeEach(() => {
vi.clearAllMocks();
});
// Clean up database connection after all tests
afterAll(async () => {
await prisma.$disconnect();
});What to test and what not to
Unit tests are most valuable for business logic: validation rules, calculations, data transformations, branching conditions. They're least valuable for code that's mostly framework glue or database queries (integration tests handle those better).
Test thoroughly: Service layer business logic, utility functions, data validation, error conditions, edge cases
Test lightly: HTTP controllers (just check they call the right service), repository methods (just check they construct the right query)
Don't bother testing: Third-party library usage, trivial getters/setters, generated code
Writing good unit tests
// src/features/projects/projects.service.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ProjectService } from './projects.service';
import type { ProjectRepository, UserRepository } from './projects.types';
describe('ProjectService', () => {
let service: ProjectService;
let projectRepo: ProjectRepository;
let userRepo: UserRepository;
beforeEach(() => {
// Create fresh mocks before each test — prevents state leakage
projectRepo = {
create: vi.fn(),
findById: vi.fn(),
findByUserId: vi.fn(),
delete: vi.fn(),
};
userRepo = {
findById: vi.fn(),
};
service = new ProjectService(projectRepo, userRepo);
});
describe('createProject', () => {
it('creates a project for an existing user', async () => {
const mockUser = { id: 'usr_123', email: '[email protected]', plan: 'pro' };
const mockProject = { id: 'proj_456', name: 'My App', userId: 'usr_123' };
vi.mocked(userRepo.findById).mockResolvedValue(mockUser);
vi.mocked(projectRepo.create).mockResolvedValue(mockProject);
const result = await service.createProject('usr_123', { name: 'My App' });
expect(result).toEqual(mockProject);
expect(projectRepo.create).toHaveBeenCalledWith({
name: 'My App',
userId: 'usr_123',
});
});
it('throws NotFoundError when user does not exist', async () => {
vi.mocked(userRepo.findById).mockResolvedValue(null);
await expect(
service.createProject('nonexistent', { name: 'App' })
).rejects.toThrow('User not found');
// Ensure create was never called
expect(projectRepo.create).not.toHaveBeenCalled();
});
it('throws AuthorizationError when free plan user exceeds project limit', async () => {
const freeUser = { id: 'usr_123', plan: 'free' };
vi.mocked(userRepo.findById).mockResolvedValue(freeUser);
vi.mocked(projectRepo.findByUserId).mockResolvedValue(
Array(3).fill({ id: 'proj' }) // Already has 3 projects
);
await expect(
service.createProject('usr_123', { name: 'App' })
).rejects.toThrow('Project limit reached for free plan');
});
});
});Mocking dependencies effectively
// Mock an entire module at the top of the file
vi.mock('@/lib/db', () => ({
prisma: {
user: {
findUnique: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
project: {
findUnique: vi.fn(),
create: vi.fn(),
},
},
}));
// Get the mocked instance in tests
import { prisma } from '@/lib/db';
describe('UserRepository', () => {
it('finds user by email', async () => {
const mockUser = { id: '1', email: '[email protected]' };
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser);
const result = await userRepo.findByEmail('[email protected]');
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { email: '[email protected]' },
});
expect(result).toEqual(mockUser);
});
});
// Mock only part of a module (partial mock)
vi.mock('./email.service', async (importOriginal) => {
const actual = await importOriginal<typeof import('./email.service')>();
return {
...actual,
sendEmail: vi.fn().mockResolvedValue({ messageId: 'test-msg-id' }),
};
});Testing async patterns
// Testing resolved values
it('returns the created user', async () => {
const result = await userService.create({ email: '[email protected]' });
expect(result).toMatchObject({ email: '[email protected]', id: expect.any(String) });
});
// Testing rejected promises
it('rejects with duplicate email error', async () => {
vi.mocked(prisma.user.create).mockRejectedValue(
new Error('Unique constraint failed on email')
);
await expect(
userService.create({ email: '[email protected]' })
).rejects.toThrow('Email already exists'); // Service should translate DB errors
});
// Testing event emissions
it('emits user.created event after successful creation', async () => {
const emit = vi.spyOn(eventBus, 'emit');
await userService.create({ email: '[email protected]' });
expect(emit).toHaveBeenCalledWith(
'user.created',
expect.objectContaining({ id: expect.any(String), email: '[email protected]' })
);
});
// Testing with fake timers
it('rate limits after 5 attempts', async () => {
vi.useFakeTimers();
for (let i = 0; i < 5; i++) {
await service.login('[email protected]', 'wrong');
}
await expect(service.login('[email protected]', 'wrong'))
.rejects.toThrow('Too many attempts');
// After 15 minutes, should work again
vi.advanceTimersByTime(15 * 60 * 1000);
// Should not throw rate limit error anymore
vi.useRealTimers();
});Snapshot testing for complex output
// Useful for complex data transformations where you want to catch unexpected changes
it('transforms raw API response to domain model', () => {
const rawResponse = { /* complex nested API response */ };
const result = transformer.transform(rawResponse);
// Creates a snapshot file on first run, compares on subsequent runs
expect(result).toMatchSnapshot();
});
// For inline snapshots (visible in the test file):
expect(result).toMatchInlineSnapshot(`
{
"id": "proj_123",
"name": "My Project",
"status": "active",
}
`);Running tests in CI
// package.json
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui"
}
}
# GitHub Actions
- name: Run tests
run: npm run test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
file: ./coverage/lcov.infoCoverage as a guideline, not a goal
Coverage percentage is a proxy metric. 90% coverage that only tests happy paths is worse than 70% coverage that tests every error condition and edge case in your critical business logic. Set thresholds that enforce coverage on business-critical code but don't chase the number at the expense of test quality.
A test that imports a function, calls it with valid inputs, and checks that it returns without error is adding coverage numbers without adding confidence. Tests should assert on behavior, not just execution.