Your safety net for changes
Imagine youre a surgeon and you have to operate without anesthesia or monitors. Every cut is a risk, and you dont know if something went wrong until its too late. Programming without tests is exactly like that.
Tests are not for finding bugs. They are for giving you the confidence to make changes without fear of breaking everything.
The cost of bugs
| Stage where bug is found | Relative cost |
|---|---|
| During development | 1x |
| During code review | 2x |
| During QA | 10x |
| In production | 100x |
| After losing customers | 1000x |
The testing pyramid
/\
/E2E\ Few, slow, fragile
/โโโโโโ\ (5% of your tests)
/Integration\ Balance of cost/value
/โโโโโโโโโโโโโโ\ (15% of your tests)
/ Unit Tests \ Many, fast, stable
/โโโโโโโโโโโโโโโโโโ\ (80% of your tests)
| Type | What it tests | Speed | Fragility |
|---|---|---|---|
| Unit | A single isolated function | ms | Very stable |
| Integration | Multiple modules together | seconds | Moderate |
| E2E | Complete flow as a user | minutes | Fragile |
Unit Testing with Vitest
Basic setup
npm install -D vitest @vitest/coverage-v8
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
},
},
});
AAA Pattern (Arrange, Act, Assert)
// src/utils/price.ts
export function calculateTotal(items: Item[], taxRate: number): number {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return subtotal * (1 + taxRate);
}
// src/utils/price.test.ts
import { describe, it, expect } from 'vitest';
import { calculateTotal } from './price';
describe('calculateTotal', () => {
it('calculates total with tax', () => {
// Arrange - Prepare data
const items = [
{ price: 100, quantity: 2 },
{ price: 50, quantity: 1 },
];
const taxRate = 0.16;
// Act - Execute function
const result = calculateTotal(items, taxRate);
// Assert - Verify result
expect(result).toBe(290); // (200 + 50) * 1.16 = 290
});
it('returns 0 for empty cart', () => {
expect(calculateTotal([], 0.16)).toBe(0);
});
it('handles 0 tax rate', () => {
const items = [{ price: 100, quantity: 1 }];
expect(calculateTotal(items, 0)).toBe(100);
});
});
Mocking: When and how
// src/services/user.ts
import { db } from './database';
import { sendEmail } from './email';
export async function createUser(email: string, name: string) {
const user = await db.user.create({ data: { email, name } });
await sendEmail(email, 'Welcome!', `Hello ${name}`);
return user;
}
// src/services/user.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createUser } from './user';
import { db } from './database';
import { sendEmail } from './email';
// Mock external modules
vi.mock('./database', () => ({
db: {
user: {
create: vi.fn(),
},
},
}));
vi.mock('./email', () => ({
sendEmail: vi.fn(),
}));
describe('createUser', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('creates user and sends email', async () => {
// Arrange
const mockUser = { id: 1, email: 'test@example.com', name: 'Test' };
vi.mocked(db.user.create).mockResolvedValue(mockUser);
vi.mocked(sendEmail).mockResolvedValue(undefined);
// Act
const result = await createUser('test@example.com', 'Test');
// Assert
expect(db.user.create).toHaveBeenCalledWith({
data: { email: 'test@example.com', name: 'Test' },
});
expect(sendEmail).toHaveBeenCalledWith(
'test@example.com',
'Welcome!',
'Hello Test'
);
expect(result).toEqual(mockUser);
});
});
Integration Testing
API Testing with Supertest
// src/app.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { app } from './app';
import { db } from './database';
describe('API /users', () => {
beforeAll(async () => {
// Setup: clean and populate test DB
await db.user.deleteMany();
await db.user.create({
data: { email: 'existing@test.com', name: 'Existing' },
});
});
afterAll(async () => {
await db.$disconnect();
});
it('GET /users returns list of users', async () => {
const response = await request(app)
.get('/users')
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toHaveLength(1);
expect(response.body[0].email).toBe('existing@test.com');
});
it('POST /users creates new user', async () => {
const response = await request(app)
.post('/users')
.send({ email: 'new@test.com', name: 'New User' })
.expect(201);
expect(response.body.email).toBe('new@test.com');
});
it('POST /users with duplicate email returns 400', async () => {
const response = await request(app)
.post('/users')
.send({ email: 'existing@test.com', name: 'Duplicate' })
.expect(400);
expect(response.body.error).toContain('already exists');
});
});
Testcontainers: Real databases
// src/db.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { PrismaClient } from '@prisma/client';
describe('Database Integration', () => {
let container;
let prisma;
beforeAll(async () => {
// Start real PostgreSQL container
container = await new PostgreSqlContainer()
.withDatabase('testdb')
.start();
// Connect Prisma to the container
prisma = new PrismaClient({
datasources: {
db: { url: container.getConnectionUri() },
},
});
// Run migrations
await prisma.$executeRaw`CREATE TABLE users (id SERIAL, email TEXT, name TEXT)`;
}, 60000); // Long timeout to start container
afterAll(async () => {
await prisma.$disconnect();
await container.stop();
});
it('inserts and reads user from real PostgreSQL', async () => {
await prisma.$executeRaw`INSERT INTO users (email, name) VALUES ('test@db.com', 'DB Test')`;
const users = await prisma.$queryRaw`SELECT * FROM users WHERE email = 'test@db.com'`;
expect(users).toHaveLength(1);
expect(users[0].name).toBe('DB Test');
});
});
E2E Testing with Playwright
Setup
npm init playwright@latest
Basic E2E test
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Login Flow', () => {
test('user can log in', async ({ page }) => {
// Navigate to the page
await page.goto('/login');
// Fill form
await page.fill('[data-testid="email"]', 'user@example.com');
await page.fill('[data-testid="password"]', 'password123');
// Click submit
await page.click('[data-testid="submit"]');
// Verify redirect to dashboard
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('h1')).toContainText('Welcome');
});
test('shows error with invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="email"]', 'wrong@example.com');
await page.fill('[data-testid="password"]', 'wrongpass');
await page.click('[data-testid="submit"]');
// Verify error message
await expect(page.locator('[data-testid="error"]')).toBeVisible();
await expect(page.locator('[data-testid="error"]')).toContainText('Invalid credentials');
});
});
Page Object Pattern
// e2e/pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.locator('[data-testid="email"]');
this.passwordInput = page.locator('[data-testid="password"]');
this.submitButton = page.locator('[data-testid="submit"]');
this.errorMessage = page.locator('[data-testid="error"]');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}
// e2e/login.spec.ts (using Page Object)
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
test('successful login', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'password123');
await expect(page).toHaveURL('/dashboard');
});
TDD: Test Driven Development
The Red-Green-Refactor cycle
โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ
โ RED โ โโโ โ GREEN โ โโโ โ REFACTOR โ โโโ
โ โ โ โ โ โ โ
โ Write โ โ Write โ โ Improve โ โ
โ a test โ โ minimum โ โ code โ โ
โ that โ โ code to โ โ without โ โ
โ fails โ โ pass โ โ breaking โ โ
โ โ โ โ โ tests โ โ
โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
TDD Example: Password validator
// Step 1: RED - Failing test
// password.test.ts
describe('validatePassword', () => {
it('rejects password shorter than 8 characters', () => {
expect(validatePassword('short')).toBe(false);
});
});
// validatePassword doesnt exist yet โ test fails โ
// Step 2: GREEN - Minimum code
// password.ts
export function validatePassword(password: string): boolean {
return password.length >= 8;
}
// Test passes โ
// Step 3: RED - New requirement
it('rejects password without uppercase letters', () => {
expect(validatePassword('lowercase123')).toBe(false);
});
// Test fails โ
// Step 4: GREEN
export function validatePassword(password: string): boolean {
if (password.length < 8) return false;
if (!/[A-Z]/.test(password)) return false;
return true;
}
// Step 5: REFACTOR
export function validatePassword(password: string): boolean {
const rules = [
(p: string) => p.length >= 8,
(p: string) => /[A-Z]/.test(p),
];
return rules.every(rule => rule(password));
}
// Tests still pass โ
Coverage: Dont obsess
100% coverage is a trap
// This code has 100% coverage...
function add(a, b) {
return a + b;
}
// With this test...
test('adds numbers', () => {
expect(add(1, 2)).toBe(3);
});
// But what about add('1', '2')? โ '12' (bug!)
// Coverage doesnt guarantee quality
Useful coverage vs vanity
| Code type | Recommended coverage |
|---|---|
| Business logic | 80-90% |
| Utilities | 90%+ |
| UI components | 60-70% |
| Glue code | 30-50% |
Testing in CI/CD
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: test
options: >-
--health-cmd pg_isready
--health-interval 10s
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run unit tests
run: npm run test:unit
- name: Run integration tests
run: npm run test:integration
env:
DATABASE_URL: postgres://postgres:test@localhost:5432/test
- name: Run E2E tests
run: npx playwright test
- name: Upload coverage
uses: codecov/codecov-action@v3
Testing checklist
- Unit tests for business logic (80%+ coverage)
- Integration tests for APIs and DB
- E2E tests for critical user flows
- Tests run in CI before merge
- Mocks only for external dependencies
- Tests are independent (dont depend on order)
- Tests are deterministic (not flaky)
Practice
-> Fullstack Testing with Vitest + Playwright -> API Testing with Testcontainers