Testing Strategy
A solid testing strategy follows the testing pyramid — many fast unit tests at the base, fewer integration tests in the middle, and a handful of E2E tests at the top.
Jest Setup
// jest.config.js
module.exports = {
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.test.js', '**/*.spec.js'],
collectCoverageFrom: [
'src/**/*.js',
'!src/**/__tests__/**',
'!src/index.js',
],
coverageThresholds: {
global: { branches: 75, functions: 80, lines: 80, statements: 80 },
},
setupFilesAfterSetup: ['./jest.setup.js'],
};Unit Tests — Pure Logic
Unit tests target individual functions and classes in isolation.
// src/services/pricing.js
function calculateDiscount(price, quantity, memberTier) {
if (price <= 0 || quantity <= 0) {
throw new Error('Price and quantity must be positive');
}
let discount = 0;
// Volume discount
if (quantity >= 100) discount += 0.15;
else if (quantity >= 50) discount += 0.10;
else if (quantity >= 10) discount += 0.05;
// Member tier discount
if (memberTier === 'gold') discount += 0.10;
else if (memberTier === 'silver') discount += 0.05;
// Cap at 25%
discount = Math.min(discount, 0.25);
return {
subtotal: price * quantity,
discount: parseFloat((price * quantity * discount).toFixed(2)),
total: parseFloat((price * quantity * (1 - discount)).toFixed(2)),
};
}
// src/services/__tests__/pricing.test.js
const { calculateDiscount } = require('../pricing');
describe('calculateDiscount', () => {
it('applies volume discount for 50+ items', () => {
const result = calculateDiscount(10, 50, 'none');
expect(result.discount).toBe(50); // 10%
expect(result.total).toBe(450);
});
it('stacks volume and member discounts', () => {
const result = calculateDiscount(100, 100, 'gold');
// 15% volume + 10% gold = 25%
expect(result.discount).toBe(2500);
expect(result.total).toBe(7500);
});
it('caps total discount at 25%', () => {
const result = calculateDiscount(100, 100, 'gold');
// 15% + 10% = 25% (already at cap)
expect(result.discount).toBe(2500);
});
it('throws for invalid price', () => {
expect(() => calculateDiscount(-1, 10, 'none')).toThrow('Price and quantity must be positive');
});
it('returns zero discount for small orders without membership', () => {
const result = calculateDiscount(10, 5, 'none');
expect(result.discount).toBe(0);
expect(result.total).toBe(50);
});
});Mocking with Jest
// src/services/order.service.js
class OrderService {
constructor(db, emailService, paymentGateway) {
this.db = db;
this.emailService = emailService;
this.paymentGateway = paymentGateway;
}
async createOrder(userId, items) {
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const payment = await this.paymentGateway.charge(userId, total);
const order = await this.db.orders.create({
userId,
items,
total,
paymentId: payment.id,
status: 'confirmed',
});
await this.emailService.sendOrderConfirmation(userId, order);
return order;
}
}
// __tests__/order.service.test.js
describe('OrderService', () => {
let service;
let mockDb, mockEmail, mockPayment;
beforeEach(() => {
mockDb = {
orders: {
create: jest.fn().mockResolvedValue({ id: 'order-1', status: 'confirmed' }),
},
};
mockEmail = {
sendOrderConfirmation: jest.fn().mockResolvedValue(true),
};
mockPayment = {
charge: jest.fn().mockResolvedValue({ id: 'pay-1', status: 'succeeded' }),
};
service = new OrderService(mockDb, mockEmail, mockPayment);
});
it('creates order and sends confirmation', async () => {
const items = [{ productId: 'p1', price: 25, quantity: 2 }];
const order = await service.createOrder('user-1', items);
expect(mockPayment.charge).toHaveBeenCalledWith('user-1', 50);
expect(mockDb.orders.create).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'user-1', total: 50, status: 'confirmed' })
);
expect(mockEmail.sendOrderConfirmation).toHaveBeenCalledWith('user-1', order);
});
it('does not create order if payment fails', async () => {
mockPayment.charge.mockRejectedValue(new Error('Card declined'));
await expect(service.createOrder('user-1', [{ price: 50, quantity: 1 }]))
.rejects.toThrow('Card declined');
expect(mockDb.orders.create).not.toHaveBeenCalled();
expect(mockEmail.sendOrderConfirmation).not.toHaveBeenCalled();
});
});Integration Tests — API Routes
Integration tests verify that routes, middleware, services, and database work together.
const request = require('supertest');
const { createApp } = require('../app');
const { setupTestDb, teardownTestDb } = require('./helpers/db');
describe('Users API', () => {
let app;
let db;
beforeAll(async () => {
db = await setupTestDb();
app = createApp(db);
});
afterAll(async () => {
await teardownTestDb(db);
});
beforeEach(async () => {
await db.query('DELETE FROM users');
});
describe('POST /api/users', () => {
it('creates a new user', async () => {
const res = await request(app)
.post('/api/users')
.send({ name: 'Alice', email: '[email protected]', password: 'securePass123' })
.expect(201);
expect(res.body).toMatchObject({
name: 'Alice',
email: '[email protected]',
});
expect(res.body).not.toHaveProperty('password');
expect(res.body).toHaveProperty('id');
});
it('returns 400 for invalid email', async () => {
const res = await request(app)
.post('/api/users')
.send({ name: 'Alice', email: 'not-an-email', password: 'securePass123' })
.expect(400);
expect(res.body.error.code).toBe('VALIDATION_ERROR');
});
it('returns 409 for duplicate email', async () => {
await request(app)
.post('/api/users')
.send({ name: 'Alice', email: '[email protected]', password: 'pass1234' });
await request(app)
.post('/api/users')
.send({ name: 'Bob', email: '[email protected]', password: 'pass5678' })
.expect(409);
});
});
describe('GET /api/users/:id', () => {
it('returns user by id', async () => {
const created = await request(app)
.post('/api/users')
.send({ name: 'Alice', email: '[email protected]', password: 'pass1234' });
const res = await request(app)
.get(`/api/users/${created.body.id}`)
.expect(200);
expect(res.body.name).toBe('Alice');
});
it('returns 404 for non-existent user', async () => {
await request(app)
.get('/api/users/99999')
.expect(404);
});
});
});Database Testing with Testcontainers
const { PostgreSqlContainer } = require('@testcontainers/postgresql');
let container;
let db;
beforeAll(async () => {
container = await new PostgreSqlContainer()
.withDatabase('test_db')
.start();
db = new Pool({
connectionString: container.getConnectionUri(),
});
// Run migrations
await runMigrations(db);
}, 30000); // 30s timeout for container startup
afterAll(async () => {
await db.end();
await container.stop();
});E2E Tests — Critical Flows
describe('Order Flow E2E', () => {
let authToken;
beforeAll(async () => {
// Register and login
await request(app)
.post('/api/auth/register')
.send({ name: 'Test User', email: '[email protected]', password: 'test1234' });
const loginRes = await request(app)
.post('/api/auth/login')
.send({ email: '[email protected]', password: 'test1234' });
authToken = loginRes.body.accessToken;
});
it('completes full order lifecycle', async () => {
// Create order
const orderRes = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${authToken}`)
.send({ items: [{ productId: 'prod-1', quantity: 2 }] })
.expect(201);
const orderId = orderRes.body.id;
// Check order status
const statusRes = await request(app)
.get(`/api/orders/${orderId}`)
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(statusRes.body.status).toBe('confirmed');
// List user orders
const listRes = await request(app)
.get('/api/orders')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(listRes.body.items).toHaveLength(1);
});
});Coverage and CI Integration
// package.json scripts
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:unit": "jest --testPathPattern='unit'",
"test:integration": "jest --testPathPattern='integration' --runInBand"
}
}# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: test_db
POSTGRES_PASSWORD: test
ports: ['5432:5432']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm run test:coverage
- uses: codecov/codecov-action@v3What to Test vs What Not to Test
Test: Business logic, edge cases, error paths, input validation, auth flows, data transformations.
Skip: Framework internals, simple CRUD with no logic, third-party library behavior, getters/setters.
Focus testing effort on code with high complexity and high business impact.
