
Introduction 🎯
Testing is the difference between a codebase you’re afraid to touch and one you refactor with confidence. Yet many backend developers treat testing as an afterthought—something to add “later” when there’s time.
This guide changes that. We’ll build a comprehensive testing strategy for TypeScript backends using Vitest, Supertest, and Testcontainers. You’ll learn to write tests that catch bugs before production, document your API’s behavior, and give you the confidence to ship fast.
All examples are production-tested from real projects like lite-ims. No theory without practice. Let’s build something reliable. 🚀
Part 1: The Testing Pyramid for Backend APIs
1.1 Why the Pyramid Matters
graph TD
A["🔺 Testing Pyramid"]
subgraph Pyramid["Testing Pyramid"]
direction TB
E2E["E2E Tests<br/>(10-20 tests)<br/>🐌 Slow, High Confidence"]
INT["Integration Tests<br/>(50-100 tests)<br/>⚡ Medium Speed"]
UNIT["Unit Tests<br/>(200-500+ tests)<br/>⚡⚡⚡ Fastest"]
end
E2E --> INT --> UNIT
style E2E fill:#f9a8d4,stroke:#333,stroke-width:2px
style INT fill:#93c5fd,stroke:#333,stroke-width:2px
style UNIT fill:#86efac,stroke:#333,stroke-width:2px
The Backend Testing Pyramid:
| Level | What It Tests | Speed | Cost | Count |
|---|---|---|---|---|
| Unit | Individual functions, services, utilities | ~1ms | Free | 200-500+ |
| Integration | API endpoints, database queries, external services | ~10-100ms | Low | 50-100 |
| E2E | Full user workflows across multiple services | ~1-5s | Higher | 10-20 |
Key Insight: Most teams have it backwards—they write a few unit tests, skip integration tests, and wonder why their E2E suite is flaky and slow.
1.2 Vitest: Why It’s the Best Choice
Vitest vs Jest (2026):
Feature Vitest Jest
─────────────────────────────────────────
Speed ⚡ 2-5x faster 🐢 Slower
ESM Support ✅ Native ⚠️ Workaround
TypeScript ✅ Built-in ⚠️ ts-jest needed
Watch Mode ✅ Fastest 🐢 Moderate
Coverage ✅ Built-in ⚠️ Separate pkg
Mocking ✅ Intuitive ✅ Mature
Workspace Support ✅ Monorepo ⚠️ Complex
Why Vitest wins:
- Built by Vite team — same config, same speed
- Native ESM — no
ts-jesthacks - Watch mode — only re-runs affected tests
- Built-in coverage — no extra setup
- Jest-compatible — easy migration
Part 2: Project Setup & Configuration
2.1 Installing Vitest
# Install Vitest and dependencies
pnpm add -D vitest @types/node supertest testcontainers
# Or with npm
npm install -D vitest @types/node supertest testcontainers
2.2 Vitest Configuration
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: {
// Test environment
environment: 'node',
// Globals (describe, it, expect without imports)
globals: true,
// Coverage configuration
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.ts'],
exclude: [
'src/**/*.test.ts',
'src/**/*.spec.ts',
'src/db/**/*.ts',
'src/types/**/*.ts',
],
threshold: {
lines: 80,
functions: 80,
branches: 70,
statements: 80,
},
},
// Test file patterns
include: ['**/*.{test,spec}.ts'],
exclude: ['node_modules', 'dist', '.idea', '.git'],
// Setup files
setupFiles: ['./tests/setup.ts'],
// Test timeout (ms)
testTimeout: 10000,
// Hook timeout (ms)
hookTimeout: 30000,
// Pool configuration
pool: 'forks',
poolOptions: {
forks: {
minForks: 1,
maxForks: 4,
},
},
// Sequence
sequence: {
concurrent: true, // Run tests in parallel
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
2.3 TypeScript Configuration
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022"],
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowJs": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": ["vitest/globals"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}
2.4 Package.json Scripts
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"test:e2e": "vitest run --config vitest.e2e.config.ts"
},
"devDependencies": {
"vitest": "^1.3.1",
"supertest": "^6.3.4",
"testcontainers": "^10.7.1",
"@types/supertest": "^6.0.2"
}
}
Part 3: Unit Testing Services
3.1 Testing Pure Functions
Start with the easiest tests—functions with no external dependencies:
// src/utils/price-calculator.ts
export function calculateDiscount(
price: number,
discountPercent: number
): number {
if (discountPercent < 0 || discountPercent > 100) {
throw new Error('Discount must be between 0 and 100');
}
return price - (price * discountPercent / 100);
}
export function formatCurrency(amount: number, currency: string): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(amount);
}
// src/utils/price-calculator.test.ts
import { describe, it, expect } from 'vitest';
import { calculateDiscount, formatCurrency } from './price-calculator';
describe('calculateDiscount', () => {
it('should calculate 10% discount correctly', () => {
const result = calculateDiscount(100, 10);
expect(result).toBe(90);
});
it('should handle 0% discount', () => {
expect(calculateDiscount(100, 0)).toBe(100);
});
it('should handle 100% discount', () => {
expect(calculateDiscount(100, 100)).toBe(0);
});
it('should throw error for negative discount', () => {
expect(() => calculateDiscount(100, -5)).toThrow(
'Discount must be between 0 and 100'
);
});
it('should throw error for discount over 100', () => {
expect(() => calculateDiscount(100, 150)).toThrow(
'Discount must be between 0 and 100'
);
});
it('should handle decimal prices', () => {
expect(calculateDiscount(99.99, 20)).toBeCloseTo(79.992, 2);
});
});
describe('formatCurrency', () => {
it('should format USD correctly', () => {
expect(formatCurrency(1234.56, 'USD')).toBe('$1,234.56');
});
it('should format EUR correctly', () => {
expect(formatCurrency(1234.56, 'EUR')).toBe('€1,234.56');
});
it('should handle zero', () => {
expect(formatCurrency(0, 'USD')).toBe('$0.00');
});
});
3.2 Testing Services with Dependencies
Now the real challenge—services that depend on repositories, external APIs, or databases:
// src/services/user.service.ts
import type { UserRepository } from '@/repositories/user.repository';
import type { EmailService } from '@/services/email.service';
import { hash } from 'bcryptjs';
export class UserService {
constructor(
private userRepo: UserRepository,
private emailService: EmailService
) {}
async createUser(data: CreateUserDto) {
// Check if email already exists
const existing = await this.userRepo.findByEmail(data.email);
if (existing) {
throw new ConflictException('Email already registered');
}
// Hash password
const hashedPassword = await hash(data.password, 12);
// Create user
const user = await this.userRepo.create({
...data,
password: hashedPassword,
});
// Send welcome email (async, don't await)
this.emailService.sendWelcome(user.email).catch(console.error);
return user;
}
async getUserById(id: string) {
const user = await this.userRepo.findById(id);
if (!user) {
throw new NotFoundException('User not found');
}
return user;
}
async deactivateUser(id: string) {
const user = await this.getUserById(id);
return this.userRepo.update(id, { isActive: false });
}
}
// src/services/user.service.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { UserService } from './user.service';
import type { UserRepository } from '@/repositories/user.repository';
import type { EmailService } from '@/services/email.service';
import { ConflictException, NotFoundException } from '@/exceptions';
// Mock dependencies
vi.mock('bcryptjs', () => ({
hash: vi.fn().mockResolvedValue('hashed-password-123'),
}));
describe('UserService', () => {
let userService: UserService;
let userRepo: Mocked<UserRepository>;
let emailService: Mocked<EmailService>;
beforeEach(() => {
// Create mock instances
userRepo = {
findByEmail: vi.fn(),
findById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
emailService = {
sendWelcome: vi.fn(),
sendPasswordReset: vi.fn(),
};
userService = new UserService(userRepo, emailService);
});
describe('createUser', () => {
it('should create a user successfully', async () => {
// Arrange
const createData = {
email: 'test@example.com',
password: 'secure123',
name: 'Test User',
};
vi.mocked(userRepo.findByEmail).mockResolvedValue(null);
vi.mocked(userRepo.create).mockResolvedValue({
id: 'user-123',
...createData,
password: 'hashed-password-123',
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
});
// Act
const result = await userService.createUser(createData);
// Assert
expect(result).toMatchObject({
id: 'user-123',
email: 'test@example.com',
name: 'Test User',
});
expect(userRepo.findByEmail).toHaveBeenCalledWith('test@example.com');
expect(userRepo.create).toHaveBeenCalled();
expect(emailService.sendWelcome).toHaveBeenCalledWith('test@example.com');
});
it('should throw ConflictException if email exists', async () => {
// Arrange
const existingUser = { id: 'existing-123', email: 'test@example.com' };
vi.mocked(userRepo.findByEmail).mockResolvedValue(existingUser);
// Act & Assert
await expect(
userService.createUser({ email: 'test@example.com', password: '123', name: 'Test' })
).rejects.toThrow(ConflictException);
expect(userRepo.create).not.toHaveBeenCalled();
});
});
describe('getUserById', () => {
it('should return user if found', async () => {
// Arrange
const mockUser = { id: 'user-123', email: 'test@example.com', name: 'Test' };
vi.mocked(userRepo.findById).mockResolvedValue(mockUser);
// Act
const result = await userService.getUserById('user-123');
// Assert
expect(result).toEqual(mockUser);
expect(userRepo.findById).toHaveBeenCalledWith('user-123');
});
it('should throw NotFoundException if user not found', async () => {
// Arrange
vi.mocked(userRepo.findById).mockResolvedValue(null);
// Act & Assert
await expect(userService.getUserById('nonexistent'))
.rejects.toThrow(NotFoundException);
});
});
describe('deactivateUser', () => {
it('should deactivate an active user', async () => {
// Arrange
const mockUser = { id: 'user-123', isActive: true };
vi.mocked(userRepo.findById).mockResolvedValue(mockUser);
vi.mocked(userRepo.update).mockResolvedValue({ ...mockUser, isActive: false });
// Act
const result = await userService.deactivateUser('user-123');
// Assert
expect(result.isActive).toBe(false);
expect(userRepo.update).toHaveBeenCalledWith('user-123', { isActive: false });
});
});
});
3.3 Mocking Best Practices
// tests/mocks/email-service.mock.ts
import { vi } from 'vitest';
import type { EmailService } from '@/services/email.service';
export function createMockEmailService(): Mocked<EmailService> {
return {
sendWelcome: vi.fn().mockResolvedValue(undefined),
sendPasswordReset: vi.fn().mockResolvedValue(undefined),
sendVerification: vi.fn().mockResolvedValue(undefined),
};
}
// tests/mocks/database.mock.ts
import { vi } from 'vitest';
export function createMockRepository<T = any>() {
return {
findById: vi.fn<(id: string) => Promise<T | null>>(),
findAll: vi.fn<() => Promise<T[]>>(),
create: vi.fn<(data: Partial<T>) => Promise<T>>(),
update: vi.fn<(id: string, data: Partial<T>) => Promise<T>>(),
delete: vi.fn<(id: string) => Promise<void>>(),
findByEmail: vi.fn<(email: string) => Promise<T | null>>(),
};
}
Part 4: Integration Testing with Supertest
4.1 Setting Up Test Server
// src/app.ts
import express from 'express';
import { userRoutes } from '@/routes/user.routes';
import { authRoutes } from '@/routes/auth.routes';
import { errorHandler } from '@/middleware/error-handler';
export function createApp() {
const app = express();
app.use(express.json());
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
app.use(errorHandler);
return app;
}
// tests/setup.ts
import { vi } from 'vitest';
import dotenv from 'dotenv';
// Load test environment variables
dotenv.config({ path: '.env.test' });
// Mock console.error in tests to keep output clean
vi.spyOn(console, 'error').mockImplementation(() => {});
// Reset all mocks before each test
beforeEach(() => {
vi.resetAllMocks();
});
4.2 Testing REST API Endpoints
// tests/integration/users.test.ts
import { describe, it, expect, beforeEach, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { createApp } from '@/app';
import type { Express } from 'express';
import { Server } from 'http';
describe('Users API', () => {
let app: Express;
let server: Server;
let authToken: string;
beforeAll(async () => {
app = createApp();
server = app.listen(0); // Random available port
});
afterAll((done) => {
server.close(done);
});
beforeEach(async () => {
// Get auth token for authenticated requests
const authResponse = await request(app)
.post('/api/auth/login')
.send({ email: 'admin@test.com', password: 'admin123' });
authToken = authResponse.body.token;
});
describe('POST /api/users', () => {
it('should create a new user', async () => {
const userData = {
email: 'newuser@test.com',
password: 'securepass123',
name: 'New User',
role: 'user',
};
const response = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${authToken}`)
.send(userData)
.expect(201);
expect(response.body).toMatchObject({
id: expect.any(String),
email: userData.email,
name: userData.name,
isActive: true,
});
expect(response.body.password).toBeUndefined();
});
it('should return 400 for invalid email', async () => {
const response = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${authToken}`)
.send({ email: 'invalid', password: '123', name: 'Test' })
.expect(400);
expect(response.body.errors).toContainEqual(
expect.objectContaining({ field: 'email' })
);
});
it('should return 409 for duplicate email', async () => {
// Create first user
await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${authToken}`)
.send({ email: 'duplicate@test.com', password: '123', name: 'First' });
// Try to create duplicate
const response = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${authToken}`)
.send({ email: 'duplicate@test.com', password: '123', name: 'Second' })
.expect(409);
expect(response.body.message).toContain('already registered');
});
it('should return 401 without authentication', async () => {
const response = await request(app)
.post('/api/users')
.send({ email: 'test@test.com', password: '123', name: 'Test' })
.expect(401);
expect(response.body.message).toBe('Unauthorized');
});
});
describe('GET /api/users/:id', () => {
it('should return user by ID', async () => {
// Create user first
const createResponse = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${authToken}`)
.send({ email: 'getuser@test.com', password: '123', name: 'Get User' });
const userId = createResponse.body.id;
const response = await request(app)
.get(`/api/users/${userId}`)
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body).toMatchObject({
id: userId,
email: 'getuser@test.com',
name: 'Get User',
});
});
it('should return 404 for non-existent user', async () => {
const response = await request(app)
.get('/api/users/nonexistent-id')
.set('Authorization', `Bearer ${authToken}`)
.expect(404);
expect(response.body.message).toBe('User not found');
});
});
describe('PATCH /api/users/:id', () => {
it('should update user details', async () => {
const createResponse = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${authToken}`)
.send({ email: 'updateuser@test.com', password: '123', name: 'Original' });
const userId = createResponse.body.id;
const response = await request(app)
.patch(`/api/users/${userId}`)
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'Updated Name' })
.expect(200);
expect(response.body.name).toBe('Updated Name');
});
it('should not allow updating email to existing email', async () => {
// Create two users
const user1 = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${authToken}`)
.send({ email: 'user1@test.com', password: '123', name: 'User 1' });
const user2 = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${authToken}`)
.send({ email: 'user2@test.com', password: '123', name: 'User 2' });
// Try to update user2's email to user1's email
const response = await request(app)
.patch(`/api/users/${user2.body.id}`)
.set('Authorization', `Bearer ${authToken}`)
.send({ email: 'user1@test.com' })
.expect(409);
expect(response.body.message).toContain('already registered');
});
});
describe('DELETE /api/users/:id', () => {
it('should deactivate user (soft delete)', async () => {
const createResponse = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${authToken}`)
.send({ email: 'deleteuser@test.com', password: '123', name: 'Delete Me' });
const userId = createResponse.body.id;
const response = await request(app)
.delete(`/api/users/${userId}`)
.set('Authorization', `Bearer ${authToken}`)
.expect(204);
// Verify user is deactivated
const getResponse = await request(app)
.get(`/api/users/${userId}`)
.set('Authorization', `Bearer ${authToken}`)
.expect(404); // Should not find deactivated user
});
});
});
4.3 Testing Authentication Flow
// tests/integration/auth.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { createApp } from '@/app';
import type { Express } from 'express';
import { Server } from 'http';
describe('Authentication API', () => {
let app: Express;
let server: Server;
beforeAll(async () => {
app = createApp();
server = app.listen(0);
// Seed test database with admin user
await seedTestUser({
email: 'admin@test.com',
password: 'admin123',
role: 'admin',
});
});
afterAll((done) => {
server.close(done);
});
describe('POST /api/auth/register', () => {
it('should register a new user', async () => {
const userData = {
email: 'newuser@test.com',
password: 'SecurePass123!',
name: 'New User',
};
const response = await request(app)
.post('/api/auth/register')
.send(userData)
.expect(201);
expect(response.body).toMatchObject({
user: {
id: expect.any(String),
email: userData.email,
name: userData.name,
},
token: expect.any(String),
});
// Password should not be in response
expect(response.body.user.password).toBeUndefined();
expect(response.body.token).not.toBeUndefined();
});
it('should hash password before storing', async () => {
const userData = {
email: 'hashtest@test.com',
password: 'PlainPassword123',
name: 'Hash Test',
};
await request(app)
.post('/api/auth/register')
.send(userData)
.expect(201);
// Verify password is hashed in database
const user = await getUserByEmail(userData.email);
expect(user.password).not.toBe(userData.password);
expect(user.password).toMatch(/^\$2[ayb]\$.{56}$/); // bcrypt format
});
it('should return 400 for weak password', async () => {
const response = await request(app)
.post('/api/auth/register')
.send({ email: 'weak@test.com', password: '123', name: 'Weak' })
.expect(400);
expect(response.body.errors).toContainEqual(
expect.objectContaining({ field: 'password' })
);
});
});
describe('POST /api/auth/login', () => {
it('should login with valid credentials', async () => {
const response = await request(app)
.post('/api/auth/login')
.send({ email: 'admin@test.com', password: 'admin123' })
.expect(200);
expect(response.body).toMatchObject({
user: {
id: expect.any(String),
email: 'admin@test.com',
role: 'admin',
},
token: expect.any(String),
refreshToken: expect.any(String),
});
});
it('should return 401 for invalid password', async () => {
const response = await request(app)
.post('/api/auth/login')
.send({ email: 'admin@test.com', password: 'wrongpassword' })
.expect(401);
expect(response.body.message).toBe('Invalid credentials');
});
it('should return 401 for non-existent user', async () => {
const response = await request(app)
.post('/api/auth/login')
.send({ email: 'nonexistent@test.com', password: 'password123' })
.expect(401);
expect(response.body.message).toBe('Invalid credentials');
});
it('should return 403 for deactivated user', async () => {
// Create and deactivate user
await request(app)
.post('/api/auth/register')
.send({ email: 'deactivated@test.com', password: '123', name: 'Deactivated' });
const adminToken = await getAdminToken();
const user = await getUserByEmail('deactivated@test.com');
await request(app)
.patch(`/api/users/${user.id}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ isActive: false });
// Try to login
const response = await request(app)
.post('/api/auth/login')
.send({ email: 'deactivated@test.com', password: '123' })
.expect(403);
expect(response.body.message).toBe('Account deactivated');
});
});
describe('POST /api/auth/refresh', () => {
it('should refresh access token with valid refresh token', async () => {
// Login to get refresh token
const loginResponse = await request(app)
.post('/api/auth/login')
.send({ email: 'admin@test.com', password: 'admin123' });
const { refreshToken } = loginResponse.body;
// Refresh token
const response = await request(app)
.post('/api/auth/refresh')
.send({ refreshToken })
.expect(200);
expect(response.body).toMatchObject({
token: expect.any(String),
refreshToken: expect.any(String), // New refresh token (rotation)
});
});
it('should return 401 for invalid refresh token', async () => {
const response = await request(app)
.post('/api/auth/refresh')
.send({ refreshToken: 'invalid-token' })
.expect(401);
expect(response.body.message).toBe('Invalid refresh token');
});
});
describe('POST /api/auth/logout', () => {
it('should invalidate refresh token', async () => {
// Login
const loginResponse = await request(app)
.post('/api/auth/login')
.send({ email: 'admin@test.com', password: 'admin123' });
const { refreshToken } = loginResponse.body;
const token = loginResponse.body.token;
// Logout
await request(app)
.post('/api/auth/logout')
.set('Authorization', `Bearer ${token}`)
.send({ refreshToken })
.expect(204);
// Try to use refresh token again
const refreshResponse = await request(app)
.post('/api/auth/refresh')
.send({ refreshToken })
.expect(401);
expect(refreshResponse.body.message).toBe('Invalid refresh token');
});
});
});
Part 5: Database Testing with Testcontainers
5.1 Why Testcontainers?
Problem: Testing with in-memory databases or mocks doesn’t catch real database issues:
- SQL syntax errors
- Constraint violations
- Transaction behavior
- Index performance
- Migration compatibility
Solution: Testcontainers spins up real Docker containers for tests—isolated, reproducible, and production-like.
5.2 PostgreSQL with Testcontainers
// tests/containers/postgres.container.ts
import {
PostgreSqlContainer,
StartedPostgreSqlContainer,
} from '@testcontainers/postgresql';
import { Pool } from 'pg';
let container: StartedPostgreSqlContainer;
let pool: Pool;
export async function startPostgresContainer() {
container = await new PostgreSqlContainer('postgres:15-alpine')
.withDatabase('test_db')
.withUsername('test_user')
.withPassword('test_password')
.withExposedPorts(5432)
.start();
pool = new Pool({
host: container.getHost(),
port: container.getPort(),
database: container.getDatabase(),
user: container.getUsername(),
password: container.getPassword(),
});
return pool;
}
export async function stopPostgresContainer() {
if (pool) {
await pool.end();
}
if (container) {
await container.stop();
}
}
export function getTestPool() {
return pool;
}
5.3 Global Setup for Database Tests
// tests/setup.database.ts
import { beforeAll, afterAll } from 'vitest';
import { startPostgresContainer, stopPostgresContainer, getTestPool } from './containers/postgres.container';
import { execSync } from 'child_process';
import path from 'path';
let pool: Pool;
beforeAll(async () => {
// Start PostgreSQL container
pool = await startPostgresContainer();
// Run migrations
const migrationDir = path.resolve(__dirname, '../src/db/migrations');
execSync(`pnpm prisma migrate deploy`, {
env: {
...process.env,
DATABASE_URL: `postgresql://test_user:test_password@${pool.options.host}:${pool.options.port}/test_db`,
},
});
// Seed test data
await seedTestData(pool);
}, 60000); // 60s timeout for container startup
afterAll(async () => {
await stopPostgresContainer();
}, 30000);
async function seedTestData(pool: Pool) {
await pool.query(`
INSERT INTO users (id, email, password, name, role, is_active)
VALUES
('admin-001', 'admin@test.com', '$2a$12$hashedpassword', 'Admin User', 'admin', true),
('user-001', 'user@test.com', '$2a$12$hashedpassword', 'Regular User', 'user', true)
`);
}
5.4 Repository Integration Tests
// tests/integration/repositories/user.repository.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { getTestPool } from '../../containers/postgres.container';
import { UserRepository } from '@/repositories/user.repository';
import type { Pool } from 'pg';
describe('UserRepository', () => {
let pool: Pool;
let userRepo: UserRepository;
beforeEach(async () => {
pool = getTestPool();
userRepo = new UserRepository(pool);
// Clean up before each test
await pool.query('DELETE FROM users');
});
describe('create', () => {
it('should create a new user', async () => {
const userData = {
email: 'test@example.com',
password: 'hashed-password',
name: 'Test User',
role: 'user' as const,
};
const user = await userRepo.create(userData);
expect(user).toMatchObject({
id: expect.any(String),
email: userData.email,
name: userData.name,
role: userData.role,
isActive: true,
});
// Verify in database
const result = await pool.query(
'SELECT * FROM users WHERE id = $1',
[user.id]
);
expect(result.rows.length).toBe(1);
});
it('should throw error on duplicate email', async () => {
const userData = {
email: 'duplicate@example.com',
password: 'hashed',
name: 'First',
role: 'user' as const,
};
await userRepo.create(userData);
await expect(
userRepo.create({ ...userData, name: 'Second' })
).rejects.toThrow();
});
});
describe('findByEmail', () => {
it('should find user by email', async () => {
const userData = {
email: 'find@example.com',
password: 'hashed',
name: 'Find Me',
role: 'user' as const,
};
const created = await userRepo.create(userData);
const found = await userRepo.findByEmail(userData.email);
expect(found).toMatchObject({
id: created.id,
email: userData.email,
name: userData.name,
});
});
it('should return null for non-existent email', async () => {
const result = await userRepo.findByEmail('nonexistent@example.com');
expect(result).toBeNull();
});
});
describe('update', () => {
it('should update user fields', async () => {
const user = await userRepo.create({
email: 'update@example.com',
password: 'hashed',
name: 'Original',
role: 'user',
});
const updated = await userRepo.update(user.id, {
name: 'Updated Name',
isActive: false,
});
expect(updated.name).toBe('Updated Name');
expect(updated.isActive).toBe(false);
expect(updated.email).toBe('update@example.com'); // Unchanged
});
it('should throw error for non-existent user', async () => {
await expect(
userRepo.update('nonexistent-id', { name: 'Test' })
).rejects.toThrow();
});
});
describe('transactions', () => {
it('should rollback on error', async () => {
try {
await pool.query('BEGIN');
await userRepo.create({
email: 'transaction@example.com',
password: 'hashed',
name: 'Transaction Test',
role: 'user',
});
// Simulate error
throw new Error('Rollback test');
} catch (error) {
await pool.query('ROLLBACK');
}
// Verify user was not created
const result = await userRepo.findByEmail('transaction@example.com');
expect(result).toBeNull();
});
it('should commit on success', async () => {
await pool.query('BEGIN');
const user = await userRepo.create({
email: 'commit@example.com',
password: 'hashed',
name: 'Commit Test',
role: 'user',
});
await pool.query('COMMIT');
// Verify user exists
const found = await userRepo.findByEmail(user.email);
expect(found).not.toBeNull();
});
});
});
5.5 Redis with Testcontainers
// tests/containers/redis.container.ts
import {
RedisContainer,
StartedRedisContainer,
} from '@testcontainers/redis';
import { createClient, type RedisClientType } from 'redis';
let container: StartedRedisContainer;
let client: RedisClientType;
export async function startRedisContainer() {
container = await new RedisContainer('redis:7-alpine')
.withExposedPorts(6379)
.start();
client = createClient({
url: `redis://${container.getHost()}:${container.getPort()}`,
});
await client.connect();
return client;
}
export async function stopRedisContainer() {
if (client?.isReady) {
await client.quit();
}
if (container) {
await container.stop();
}
}
export function getTestRedisClient() {
return client;
}
// tests/integration/cache.service.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { getTestRedisClient } from '../containers/redis.container';
import { CacheService } from '@/services/cache.service';
import type { RedisClientType } from 'redis';
describe('CacheService', () => {
let redis: RedisClientType;
let cacheService: CacheService;
beforeEach(async () => {
redis = getTestRedisClient();
await redis.flushDb(); // Clean before each test
cacheService = new CacheService(redis);
});
describe('set', () => {
it('should set a value with TTL', async () => {
await cacheService.set('test-key', { data: 'value' }, 60);
const value = await cacheService.get('test-key');
expect(value).toEqual({ data: 'value' });
});
it('should expire after TTL', async () => {
await cacheService.set('expiring-key', 'value', 1); // 1 second
await new Promise(resolve => setTimeout(resolve, 1100));
const value = await cacheService.get('expiring-key');
expect(value).toBeNull();
});
});
describe('delete', () => {
it('should delete a key', async () => {
await cacheService.set('to-delete', 'value');
await cacheService.delete('to-delete');
const value = await cacheService.get('to-delete');
expect(value).toBeNull();
});
});
});
Part 6: E2E Testing Full Workflows
6.1 Setting Up E2E Tests
// vitest.e2e.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
globals: true,
// Separate config for E2E tests
include: ['tests/e2e/**/*.test.ts'],
// Longer timeouts for E2E
testTimeout: 30000,
hookTimeout: 60000,
// Sequential execution for E2E (tests may depend on each other)
sequence: {
concurrent: false,
},
// Single thread for E2E
pool: 'forks',
poolOptions: {
forks: {
minForks: 1,
maxForks: 1,
},
},
},
});
6.2 Complete User Journey Test
// tests/e2e/user-journey.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { createApp } from '@/app';
import type { Express } from 'express';
import { Server } from 'http';
describe('Complete User Journey', () => {
let app: Express;
let server: Server;
let baseUrl: string;
beforeAll(async () => {
app = createApp();
server = app.listen(0);
const address = server.address() as any;
baseUrl = `http://localhost:${address.port}`;
});
afterAll((done) => {
server.close(done);
});
it('should complete full user registration to purchase flow', async () => {
// Step 1: Register new user
const registerResponse = await request(baseUrl)
.post('/api/auth/register')
.send({
email: 'journey@test.com',
password: 'SecurePass123!',
name: 'Journey User',
})
.expect(201);
const { user, token } = registerResponse.body;
expect(user.email).toBe('journey@test.com');
// Step 2: Verify email (simulate email verification)
const verifyToken = await getVerificationTokenFromEmail(user.email);
await request(baseUrl)
.post('/api/auth/verify-email')
.send({ token: verifyToken })
.expect(200);
// Step 3: Complete profile
const profileResponse = await request(baseUrl)
.patch('/api/users/me')
.set('Authorization', `Bearer ${token}`)
.send({
phone: '+1234567890',
address: {
street: '123 Test St',
city: 'Test City',
country: 'Test Country',
},
})
.expect(200);
expect(profileResponse.body.phone).toBe('+1234567890');
// Step 4: Browse products
const productsResponse = await request(baseUrl)
.get('/api/products')
.expect(200);
expect(productsResponse.body.products).toHaveLength(5); // Seeded data
const productId = productsResponse.body.products[0].id;
// Step 5: Add to cart
await request(baseUrl)
.post('/api/cart/items')
.set('Authorization', `Bearer ${token}`)
.send({ productId, quantity: 2 })
.expect(201);
// Step 6: Create order
const orderResponse = await request(baseUrl)
.post('/api/orders')
.set('Authorization', `Bearer ${token}`)
.send({
shippingAddress: {
street: '123 Test St',
city: 'Test City',
country: 'Test Country',
},
paymentMethod: 'card',
})
.expect(201);
const orderId = orderResponse.body.order.id;
expect(orderResponse.body.order.total).toBeGreaterThan(0);
// Step 7: Process payment (simulate)
await request(baseUrl)
.post(`/api/orders/${orderId}/payment`)
.set('Authorization', `Bearer ${token}`)
.send({
cardNumber: '4111111111111111',
expiry: '12/25',
cvv: '123',
})
.expect(200);
// Step 8: Verify order status
const orderStatusResponse = await request(baseUrl)
.get(`/api/orders/${orderId}`)
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(orderStatusResponse.body.status).toBe('paid');
// Step 9: Check inventory was updated
const productResponse = await request(baseUrl)
.get(`/api/products/${productId}`)
.expect(200);
expect(productResponse.body.stock).toBeLessThan(100); // Initial stock
// Step 10: Receive order confirmation email (verify in database)
const email = await getEmailFromQueue('order-confirmation', user.email);
expect(email).toMatchObject({
to: user.email,
subject: expect.stringContaining('Order Confirmation'),
orderId,
});
});
});
Part 7: CI/CD Integration
7.1 GitHub Actions Workflow
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
POSTGRES_DB: test_db
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Run linter
run: pnpm run lint
- name: Run type check
run: pnpm run typecheck
- name: Run unit tests
run: pnpm run test:unit --coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: ./coverage/coverage-final.json
fail_ci_if_error: false
- name: Run integration tests
run: pnpm run test:integration
env:
DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db
REDIS_URL: redis://localhost:6379
- name: Run E2E tests
run: pnpm run test:e2e
env:
DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db
REDIS_URL: redis://localhost:6379
7.2 Parallel Test Execution
// vitest.parallel.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
// Run test files in parallel
pool: 'forks',
poolOptions: {
forks: {
minForks: 2,
maxForks: 4, // Adjust based on CI resources
},
},
// Isolate tests from each other
isolate: true,
// Retry flaky tests
retry: 2,
},
});
Part 8: Testing Best Practices
8.1 Test Naming Conventions
// ✅ Good: Clear, descriptive names
describe('UserService', () => {
describe('createUser', () => {
it('should create user with valid data');
it('should hash password before storing');
it('should throw ConflictException for duplicate email');
it('should send welcome email after creation');
});
});
// ❌ Bad: Vague names
describe('UserService', () => {
it('should work');
it('should handle error');
it('test 1');
});
8.2 Arrange-Act-Assert Pattern
// ✅ AAA Pattern
it('should calculate discount correctly', () => {
// Arrange
const price = 100;
const discount = 20;
// Act
const result = calculateDiscount(price, discount);
// Assert
expect(result).toBe(80);
});
8.3 Test Independence
// ✅ Each test is independent
describe('UserRepository', () => {
beforeEach(async () => {
await pool.query('DELETE FROM users'); // Clean state
});
it('should create user', async () => {
// Test doesn't depend on other tests
});
it('should find user by email', async () => {
// Creates its own data
await userRepo.create({ email: 'test@example.com', ... });
});
});
// ❌ Tests depend on each other
let userId: string;
it('should create user', async () => {
const user = await userRepo.create(...);
userId = user.id; // Shared state!
});
it('should find user', async () => {
const user = await userRepo.findById(userId); // Depends on previous test!
});
8.4 When NOT to Test
// ❌ Don't test trivial getters/setters
class User {
constructor(public name: string) {}
getName() { return this.name; } // No need to test
}
// ❌ Don't test third-party libraries
import bcrypt from 'bcryptjs';
it('should hash password', async () => {
// Don't test bcrypt itself
const hash = await bcrypt.hash('password', 12);
expect(hash).toBeDefined(); // Pointless
});
// ✅ DO test your business logic
it('should reject weak passwords', async () => {
await expect(userService.createUser({ password: '123' }))
.rejects.toThrow('Password too weak');
});
Part 9: Common Testing Pitfalls
9.1 Flaky Tests
// ❌ Flaky: Depends on timing
it('should expire cache', async () => {
await cache.set('key', 'value', 1);
await sleep(1000); // Exact timing!
expect(await cache.get('key')).toBeNull();
});
// ✅ Stable: Buffer for timing
it('should expire cache', async () => {
await cache.set('key', 'value', 1);
await sleep(1500); // Buffer
expect(await cache.get('key')).toBeNull();
});
9.2 Over-Mocking
// ❌ Over-mocked: Testing mocks, not code
it('should call repository', async () => {
const mockRepo = { findById: vi.fn().mockResolvedValue({}) };
const service = new UserService(mockRepo as any);
await service.getUser('123');
expect(mockRepo.findById).toHaveBeenCalledWith('123'); // Pointless
});
// ✅ Real integration
it('should retrieve user from database', async () => {
const user = await userRepo.create({ email: 'test@example.com', ... });
const found = await userService.getUser(user.id);
expect(found.email).toBe(user.email);
});
9.3 Test Data Management
// ❌ Hardcoded test data
const user = { email: 'test@example.com', ... }; // What if it already exists?
// ✅ Factory pattern
function createUserFactory(overrides = {}) {
return {
email: `user-${Date.now()}@test.com`, // Unique
password: 'SecurePass123!',
name: 'Test User',
...overrides,
};
}
it('should create user', async () => {
const userData = createUserFactory({ name: 'Custom Name' });
const user = await userService.createUser(userData);
expect(user.name).toBe('Custom Name');
});
Conclusion 🎯
Testing isn’t about perfection—it’s about confidence. The goal isn’t 100% coverage; it’s catching bugs before users do.
Key Takeaways:
- Start with unit tests for business logic—they’re fast and easy
- Add integration tests for API endpoints and database operations
- Write E2E tests for critical user journeys only
- Use Testcontainers for realistic, isolated database tests
- Mock external dependencies but test real databases
- Run tests in CI on every push and PR
- Keep tests maintainable—they’re code too
Your Testing Stack:
Vitest # Test runner
Supertest # API testing
Testcontainers # Real databases in tests
Redis # Cache testing
GitHub Actions # CI/CD
The best time to start testing was yesterday. The second best time is now. Pick one pattern from this guide and implement it today. Your future self will thank you. 🚀
Further Reading
- Vitest Documentation
- Testcontainers
- Supertest
- Testing Library
- lite-ims Repository - Real-world testing examples