Unit Testing
Testing individual functions and modules in isolation - the foundation of your test suite.
6 min read
What is Unit Testing?#
Unit testing means testing the smallest pieces of code in isolation. One function, one test, one behavior.
javascript
// The unit (function)
function calculateDiscount(price, percentage) {
if (percentage < 0 || percentage > 100) {
throw new Error('Invalid percentage');
}
return price * (percentage / 100);
}
// The unit test
test('calculates 20% discount on $100', () => {
expect(calculateDiscount(100, 20)).toBe(20);
});
test('throws error for invalid percentage', () => {
expect(() => calculateDiscount(100, -10)).toThrow('Invalid percentage');
});
Writing Tests with Different Frameworks#
Vitest#
javascript
// src/utils/math.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { Calculator } from './math.js';
describe('Calculator', () => {
let calc;
beforeEach(() => {
calc = new Calculator();
});
it('adds two numbers', () => {
expect(calc.add(2, 3)).toBe(5);
});
it('subtracts two numbers', () => {
expect(calc.subtract(5, 3)).toBe(2);
});
it('throws on division by zero', () => {
expect(() => calc.divide(10, 0)).toThrow('Cannot divide by zero');
});
});
Jest#
javascript
// Same syntax! Jest and Vitest are compatible
describe('Calculator', () => {
let calc;
beforeEach(() => {
calc = new Calculator();
});
it('adds two numbers', () => {
expect(calc.add(2, 3)).toBe(5);
});
});
Node.js Test Runner#
javascript
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert';
import { Calculator } from './math.js';
describe('Calculator', () => {
let calc;
beforeEach(() => {
calc = new Calculator();
});
it('adds two numbers', () => {
assert.strictEqual(calc.add(2, 3), 5);
});
it('throws on division by zero', () => {
assert.throws(() => calc.divide(10, 0), /Cannot divide by zero/);
});
});
Common Assertions#
Vitest/Jest#
javascript
// Equality
expect(value).toBe(5); // Strict equality (===)
expect(value).toEqual({ a: 1 }); // Deep equality
expect(value).not.toBe(5); // Negation
// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
// Numbers
expect(value).toBeGreaterThan(3);
expect(value).toBeGreaterThanOrEqual(3);
expect(value).toBeLessThan(5);
expect(value).toBeCloseTo(0.3, 5); // Floating point
// Strings
expect(string).toMatch(/pattern/);
expect(string).toContain('substring');
expect(string).toHaveLength(5);
// Arrays
expect(array).toContain('item');
expect(array).toHaveLength(3);
expect(array).toEqual(expect.arrayContaining(['a', 'b']));
// Objects
expect(obj).toHaveProperty('key');
expect(obj).toHaveProperty('nested.key', 'value');
expect(obj).toMatchObject({ key: 'value' });
// Errors
expect(() => fn()).toThrow();
expect(() => fn()).toThrow('message');
expect(() => fn()).toThrow(ErrorClass);
// Async
await expect(asyncFn()).resolves.toBe('value');
await expect(asyncFn()).rejects.toThrow('error');
Node.js Assert#
javascript
import assert from 'node:assert';
assert.strictEqual(value, 5);
assert.deepStrictEqual(obj, { a: 1 });
assert.notStrictEqual(value, 5);
assert.ok(value); // Truthy
assert.throws(() => fn(), /error/); // Throws
// Async
await assert.rejects(asyncFn(), /error/);
await assert.doesNotReject(asyncFn());
Testing Real Services#
Testing a User Service#
javascript
// src/services/users.js
export class UserService {
constructor(userRepository, emailService) {
this.userRepository = userRepository;
this.emailService = emailService;
}
async createUser(data) {
// Validate
if (!data.email || !data.password) {
throw new Error('Email and password required');
}
// Check existing
const existing = await this.userRepository.findByEmail(data.email);
if (existing) {
throw new Error('Email already registered');
}
// Create
const user = await this.userRepository.create(data);
// Send welcome email
await this.emailService.sendWelcome(user.email);
return user;
}
}
javascript
// src/services/users.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UserService } from './users.js';
describe('UserService', () => {
let userService;
let mockUserRepo;
let mockEmailService;
beforeEach(() => {
// Create mocks
mockUserRepo = {
findByEmail: vi.fn(),
create: vi.fn(),
};
mockEmailService = {
sendWelcome: vi.fn(),
};
userService = new UserService(mockUserRepo, mockEmailService);
});
describe('createUser', () => {
it('creates user and sends welcome email', async () => {
mockUserRepo.findByEmail.mockResolvedValue(null);
mockUserRepo.create.mockResolvedValue({
id: '123',
email: 'test@test.com',
});
const user = await userService.createUser({
email: 'test@test.com',
password: 'password123',
});
expect(user.email).toBe('test@test.com');
expect(mockUserRepo.create).toHaveBeenCalledWith({
email: 'test@test.com',
password: 'password123',
});
expect(mockEmailService.sendWelcome).toHaveBeenCalledWith('test@test.com');
});
it('throws if email already exists', async () => {
mockUserRepo.findByEmail.mockResolvedValue({ id: 'existing' });
await expect(
userService.createUser({
email: 'existing@test.com',
password: 'password123',
})
).rejects.toThrow('Email already registered');
expect(mockUserRepo.create).not.toHaveBeenCalled();
});
it('throws if email missing', async () => {
await expect(
userService.createUser({ password: 'password123' })
).rejects.toThrow('Email and password required');
});
});
});
Testing Utility Functions#
javascript
// src/utils/string.js
export function slugify(text) {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
export function truncate(text, length, suffix = '...') {
if (text.length <= length) return text;
return text.slice(0, length - suffix.length).trim() + suffix;
}
javascript
// src/utils/string.test.js
import { describe, it, expect } from 'vitest';
import { slugify, truncate } from './string.js';
describe('slugify', () => {
it('converts to lowercase', () => {
expect(slugify('Hello World')).toBe('hello-world');
});
it('removes special characters', () => {
expect(slugify('Hello! World?')).toBe('hello-world');
});
it('handles multiple spaces', () => {
expect(slugify('Hello World')).toBe('hello-world');
});
it('trims leading/trailing spaces', () => {
expect(slugify(' Hello World ')).toBe('hello-world');
});
it('handles empty string', () => {
expect(slugify('')).toBe('');
});
});
describe('truncate', () => {
it('truncates long text', () => {
expect(truncate('Hello World', 8)).toBe('Hello...');
});
it('keeps short text unchanged', () => {
expect(truncate('Hi', 10)).toBe('Hi');
});
it('uses custom suffix', () => {
expect(truncate('Hello World', 8, '…')).toBe('Hello W…');
});
});
Testing Async Code#
javascript
// src/services/api.js
export async function fetchUser(id) {
const response = await fetch(`https://api.example.com/users/${id}`);
if (!response.ok) {
throw new Error('User not found');
}
return response.json();
}
javascript
// src/services/api.test.js
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { fetchUser } from './api.js';
describe('fetchUser', () => {
beforeEach(() => {
// Mock global fetch
global.fetch = vi.fn();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('returns user data on success', async () => {
const mockUser = { id: '123', name: 'John' };
global.fetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockUser),
});
const user = await fetchUser('123');
expect(user).toEqual(mockUser);
expect(fetch).toHaveBeenCalledWith('https://api.example.com/users/123');
});
it('throws on 404', async () => {
global.fetch.mockResolvedValue({
ok: false,
status: 404,
});
await expect(fetchUser('999')).rejects.toThrow('User not found');
});
});
Test Organization Patterns#
Arrange-Act-Assert (AAA)#
javascript
it('creates order with correct total', async () => {
// Arrange - set up test data
const items = [
{ productId: '1', price: 10, quantity: 2 },
{ productId: '2', price: 15, quantity: 1 },
];
// Act - perform the action
const order = await orderService.create(items);
// Assert - verify the result
expect(order.total).toBe(35);
expect(order.items).toHaveLength(2);
});
Given-When-Then (BDD)#
javascript
describe('OrderService', () => {
describe('given items in cart', () => {
const items = [{ productId: '1', price: 10, quantity: 2 }];
describe('when creating order', () => {
it('then calculates correct total', async () => {
const order = await orderService.create(items);
expect(order.total).toBe(20);
});
});
});
});
Running Tests#
bash
# Run all tests
npm test
# Run specific file
npm test src/services/users.test.js
# Run tests matching pattern
npm test -- --grep "createUser"
# Watch mode (rerun on changes)
npm run test:watch
# With coverage
npm run test:coverage
Key Takeaways#
- Test one thing per test - Clear, focused tests
- Use descriptive names - Tests are documentation
- Follow AAA pattern - Arrange, Act, Assert
- Mock external dependencies - Keep tests isolated
- Test edge cases - Empty strings, nulls, errors
The Rule
If a function has logic, it should have tests. Start with the happy path, then add edge cases. A test that runs is better than a perfect test you haven't written.
Continue Learning
Ready to level up your skills?
Explore more guides and tutorials to deepen your understanding and become a better developer.