Testing Fundamentals
Writing Reliable, Maintainable Tests
Explanation
Why Test?
Tests catch bugs early, document behavior, enable refactoring, and give you confidence when deploying. They're an investment that pays dividends.
Types of Tests
- Unit Tests: Test individual functions/classes
- Integration Tests: Test components working together
- E2E Tests: Test complete user flows
- Snapshot Tests: Detect unexpected changes
Testing Pyramid
/\
/ \
/ E2E \ Few, slow, expensive
/______\
/ \
/Integration\ Some, medium speed
/__________\
/ \
/ Unit Tests \ Many, fast, cheap
/________________\
Demonstration
Example 1: Unit Testing (Jest)
// calculator.js
class Calculator {
add(a, b) {
return a + b;
}
subtract(a, b) {
return a - b;
}
multiply(a, b) {
return a * b;
}
divide(a, b) {
if (b === 0) {
throw new Error('Cannot divide by zero');
}
return a / b;
}
}
module.exports = Calculator;
// calculator.test.js
const Calculator = require('./calculator');
describe('Calculator', () => {
let calc;
beforeEach(() => {
calc = new Calculator();
});
describe('add', () => {
test('adds two positive numbers', () => {
expect(calc.add(2, 3)).toBe(5);
});
test('adds negative numbers', () => {
expect(calc.add(-1, -2)).toBe(-3);
});
test('adds zero', () => {
expect(calc.add(5, 0)).toBe(5);
});
});
describe('divide', () => {
test('divides two numbers', () => {
expect(calc.divide(10, 2)).toBe(5);
});
test('throws on division by zero', () => {
expect(() => calc.divide(10, 0)).toThrow('Cannot divide by zero');
});
test('handles decimal results', () => {
expect(calc.divide(5, 2)).toBe(2.5);
});
});
});
Example 2: Testing Async Code
// userService.js
class UserService {
constructor(api) {
this.api = api;
}
async getUser(id) {
const response = await this.api.get(`/users/${id}`);
return response.data;
}
async createUser(userData) {
const response = await this.api.post('/users', userData);
return response.data;
}
async getUserPosts(userId) {
const [user, posts] = await Promise.all([
this.api.get(`/users/${userId}`),
this.api.get(`/users/${userId}/posts`)
]);
return { user: user.data, posts: posts.data };
}
}
// userService.test.js
const UserService = require('./userService');
describe('UserService', () => {
let service;
let mockApi;
beforeEach(() => {
mockApi = {
get: jest.fn(),
post: jest.fn()
};
service = new UserService(mockApi);
});
describe('getUser', () => {
test('fetches user by id', async () => {
const mockUser = { id: 1, name: 'Arthur' };
mockApi.get.mockResolvedValue({ data: mockUser });
const user = await service.getUser(1);
expect(mockApi.get).toHaveBeenCalledWith('/users/1');
expect(user).toEqual(mockUser);
});
test('handles API errors', async () => {
mockApi.get.mockRejectedValue(new Error('Network error'));
await expect(service.getUser(1)).rejects.toThrow('Network error');
});
});
describe('getUserPosts', () => {
test('fetches user and posts in parallel', async () => {
const mockUser = { id: 1, name: 'Arthur' };
const mockPosts = [{ id: 1, title: 'Post 1' }];
mockApi.get
.mockResolvedValueOnce({ data: mockUser })
.mockResolvedValueOnce({ data: mockPosts });
const result = await service.getUserPosts(1);
expect(result).toEqual({
user: mockUser,
posts: mockPosts
});
expect(mockApi.get).toHaveBeenCalledTimes(2);
});
});
});
Example 3: Integration Testing (API)
// app.test.js
const request = require('supertest');
const app = require('./app');
const db = require('./db');
describe('User API', () => {
beforeAll(async () => {
await db.connect();
});
afterAll(async () => {
await db.disconnect();
});
beforeEach(async () => {
await db.clear();
});
describe('POST /users', () => {
test('creates a new user', async () => {
const userData = {
name: 'Arthur',
email: 'art@bpc.com'
};
const response = await request(app)
.post('/users')
.send(userData)
.expect(201);
expect(response.body).toMatchObject({
name: 'Arthur',
email: 'art@bpc.com'
});
expect(response.body.id).toBeDefined();
});
test('validates required fields', async () => {
const response = await request(app)
.post('/users')
.send({ name: 'Arthur' })
.expect(400);
expect(response.body.error).toContain('email');
});
test('prevents duplicate emails', async () => {
const userData = { name: 'Arthur', email: 'art@bpc.com' };
await request(app).post('/users').send(userData);
const response = await request(app)
.post('/users')
.send(userData)
.expect(400);
expect(response.body.error).toContain('exists');
});
});
describe('GET /users/:id', () => {
test('returns user by id', async () => {
// Setup
const createResponse = await request(app)
.post('/users')
.send({ name: 'Arthur', email: 'art@bpc.com' });
const userId = createResponse.body.id;
// Test
const response = await request(app)
.get(`/users/${userId}`)
.expect(200);
expect(response.body.name).toBe('Arthur');
});
test('returns 404 for non-existent user', async () => {
await request(app)
.get('/users/nonexistent')
.expect(404);
});
});
});
Example 4: Mocking and Spies
// emailService.test.js
const EmailService = require('./emailService');
describe('EmailService', () => {
let service;
let mockTransporter;
beforeEach(() => {
mockTransporter = {
sendMail: jest.fn().mockResolvedValue({ messageId: '123' })
};
service = new EmailService(mockTransporter);
});
test('sends welcome email', async () => {
await service.sendWelcome('art@bpc.com', 'Arthur');
expect(mockTransporter.sendMail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'art@bpc.com',
subject: expect.stringContaining('Welcome')
})
);
});
test('retries on failure', async () => {
mockTransporter.sendMail
.mockRejectedValueOnce(new Error('Network error'))
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValue({ messageId: '123' });
await service.sendWelcome('art@bpc.com', 'Arthur');
expect(mockTransporter.sendMail).toHaveBeenCalledTimes(3);
});
});
// Spying on existing methods
describe('Logger', () => {
test('logs to console', () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
logger.info('Test message');
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Test message')
);
consoleSpy.mockRestore();
});
});
// Timer mocks
describe('Scheduler', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('executes after delay', () => {
const callback = jest.fn();
scheduler.delay(callback, 1000);
expect(callback).not.toHaveBeenCalled();
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalled();
});
});
Example 5: Test Coverage and Best Practices
// Good test structure
describe('Feature', () => {
describe('when condition A', () => {
test('should do X', () => {});
test('should do Y', () => {});
});
describe('when condition B', () => {
test('should do Z', () => {});
});
});
// Test data builders
function buildUser(overrides = {}) {
return {
id: 1,
name: 'Test User',
email: 'test@example.com',
role: 'user',
...overrides
};
}
test('admin user can delete posts', () => {
const admin = buildUser({ role: 'admin' });
expect(canDeletePost(admin)).toBe(true);
});
// Custom matchers
expect.extend({
toBeValidUser(received) {
const hasId = typeof received.id === 'number';
const hasEmail = received.email?.includes('@');
return {
pass: hasId && hasEmail,
message: () => `Expected valid user, got ${JSON.stringify(received)}`
};
}
});
test('creates valid user', async () => {
const user = await createUser({ name: 'Art', email: 'art@bpc.com' });
expect(user).toBeValidUser();
});
// package.json scripts
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:ci": "jest --ci --coverage --maxWorkers=2"
}
}
Key Takeaways:
- Write tests before or alongside code
- Test behavior, not implementation
- Mock external dependencies
- Keep tests fast and independent
- Aim for meaningful coverage, not 100%
Imitation
Challenge 1: Test a Shopping Cart
Task: Write tests for a shopping cart class.
Solution
describe('ShoppingCart', () => {
let cart;
beforeEach(() => {
cart = new ShoppingCart();
});
describe('addItem', () => {
test('adds new item to cart', () => {
cart.addItem({ id: 1, name: 'Book', price: 20 });
expect(cart.items).toHaveLength(1);
expect(cart.items[0].name).toBe('Book');
});
test('increases quantity for existing item', () => {
cart.addItem({ id: 1, name: 'Book', price: 20 });
cart.addItem({ id: 1, name: 'Book', price: 20 });
expect(cart.items).toHaveLength(1);
expect(cart.items[0].quantity).toBe(2);
});
});
describe('getTotal', () => {
test('returns 0 for empty cart', () => {
expect(cart.getTotal()).toBe(0);
});
test('calculates total correctly', () => {
cart.addItem({ id: 1, name: 'Book', price: 20 });
cart.addItem({ id: 2, name: 'Pen', price: 5 }, 3);
expect(cart.getTotal()).toBe(35);
});
});
describe('applyDiscount', () => {
test('applies percentage discount', () => {
cart.addItem({ id: 1, name: 'Book', price: 100 });
cart.applyDiscount(10);
expect(cart.getTotal()).toBe(90);
});
test('rejects invalid discount', () => {
expect(() => cart.applyDiscount(101)).toThrow();
expect(() => cart.applyDiscount(-5)).toThrow();
});
});
});
Challenge 2: Test Async Pagination
Task: Test a paginated API endpoint.
Solution
describe('GET /posts', () => {
beforeEach(async () => {
// Create 25 posts
await Promise.all(
Array.from({ length: 25 }, (_, i) =>
Post.create({ title: `Post ${i + 1}` })
)
);
});
test('returns first page by default', async () => {
const response = await request(app)
.get('/posts')
.expect(200);
expect(response.body.data).toHaveLength(10);
expect(response.body.meta.page).toBe(1);
expect(response.body.meta.total).toBe(25);
});
test('returns requested page', async () => {
const response = await request(app)
.get('/posts?page=2')
.expect(200);
expect(response.body.data).toHaveLength(10);
expect(response.body.meta.page).toBe(2);
});
test('returns last page with remaining items', async () => {
const response = await request(app)
.get('/posts?page=3')
.expect(200);
expect(response.body.data).toHaveLength(5);
});
test('returns empty array for page beyond total', async () => {
const response = await request(app)
.get('/posts?page=10')
.expect(200);
expect(response.body.data).toHaveLength(0);
});
test('respects custom page size', async () => {
const response = await request(app)
.get('/posts?per_page=5')
.expect(200);
expect(response.body.data).toHaveLength(5);
expect(response.body.meta.total_pages).toBe(5);
});
});
Practice
Exercise 1: Test a Form Validator
Difficulty: Intermediate
Write tests for a form validator:
- Email validation
- Password strength
- Required fields
- Custom rules
Exercise 2: E2E Test Suite
Difficulty: Advanced
Create E2E tests for a login flow:
- Navigate to login page
- Fill in credentials
- Submit form
- Verify redirect
- Check session state
Summary
What you learned:
- Unit vs integration vs E2E tests
- Jest testing patterns
- Mocking and spying
- Async testing
- Test organization
Next Steps:
- Read: CI/CD Pipelines
- Practice: Add tests to existing project
- Explore: Playwright for E2E testing
