🔬 Writing Unit Tests
Anyone can write a test that passes. The skill worth learning is writing tests that stay fast, stay honest, and still make sense six months from now. In this lesson you'll turn "I wrote a test" into "I write good tests" — with a shared vocabulary, a repeatable structure, and the mocking techniques that keep a unit truly isolated.
Week 3 · Day 5 (Friday: Testing Fundamentals) · Lecture 2
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Describe what makes a unit test good using the FIRST principles
- Structure every test with a clear Arrange-Act-Assert body
- Test pure functions exhaustively, including with table-driven cases
- Test classes through their public behavior, not their internals
- Isolate a unit by mocking its dependencies with
jest.fn()andbeforeEach - Deliberately cover edge cases and error paths, not just the happy path
Estimated Time: 70 minutes
Practice: Fully test a BankAccount class and a mock-backed ShoppingCart.
In This Lesson
The Art of Unit Testing
Writing unit tests is a bit like being a detective. You imagine every scenario in which your code might misbehave, then set up a controlled experiment to see whether it actually does. But great tests do more than catch bugs — they document behavior, they enable fearless refactoring, and they define the shape of the code they cover.
A unit is the smallest testable slice of your program: usually a single function or a single class method. The defining trait of a unit test is isolation — it exercises that one piece with everything else swapped out for predictable stand-ins.
What Makes a Good Test? The FIRST Principles
The qualities of a trustworthy unit test spell the handy acronym FIRST. Keep these five in mind and most testing problems disappear.
| Letter | Principle | What it means |
|---|---|---|
| F | Fast | Run in milliseconds so you run them constantly |
| I | Isolated | No test depends on another test's state or order |
| R | Repeatable | Same result on any machine, any time of day |
| S | Self-validating | Pass or fail with no human eyeballing output |
| T | Timely | Written alongside the code, not months later |
Bad vs. good, side by side
// ❌ Bad: slow, hits a real API, not isolated or repeatable
test('user service fetches data from API', async () => {
const userService = new UserService();
const user = await userService.getUser(1); // real network call!
expect(user.name).toBe('John Doe'); // breaks when the API changes
});
// ✅ Good: fast, isolated, repeatable
test('user service returns the user from its data source', async () => {
const mockApi = {
fetchUser: jest.fn().mockResolvedValue({ id: 1, name: 'John Doe' }),
};
const userService = new UserService(mockApi);
const user = await userService.getUser(1);
expect(user.name).toBe('John Doe');
expect(mockApi.fetchUser).toHaveBeenCalledWith(1);
});
The good version injects a fake API. It never touches the network, so it's fast and repeatable, and it also verifies the collaboration — that fetchUser was called with the right id.
Anatomy of a Unit Test
You met Arrange-Act-Assert in the last lesson. It's worth internalizing because it makes any test readable at a glance. Arrange the world, Act by calling the unit, Assert the outcome.
describe('ShoppingCart', () => {
describe('addItem', () => {
it('adds an item to the cart', () => {
// Arrange
const cart = new ShoppingCart();
const item = { id: 1, name: 'Book', price: 29.99 };
// Act
cart.addItem(item);
// Assert
expect(cart.items).toHaveLength(1);
expect(cart.items[0]).toEqual(item);
});
it('increments quantity when adding an existing item', () => {
const cart = new ShoppingCart();
const item = { id: 1, name: 'Book', price: 29.99 };
cart.addItem(item, 1);
cart.addItem(item, 2);
expect(cart.items).toHaveLength(1);
expect(cart.items[0].quantity).toBe(3);
});
});
});
💡 Nest describe blocks to tell a story
Reading the nesting top to bottom gives you a sentence: "ShoppingCart → addItem → increments quantity when adding an existing item." That structure is free documentation for whoever reads the failing test.
Testing Pure Functions
A pure function returns the same output for the same input and has no side effects. These are the easiest and most rewarding things to test — no setup, no mocks, just input in and output out.
// discount.js
export function calculateDiscount(price, discountPercentage) {
if (price < 0 || discountPercentage < 0 || discountPercentage > 100) {
throw new Error('Invalid input');
}
const discount = price * (discountPercentage / 100);
return Number((price - discount).toFixed(2)); // round to cents
}
// discount.test.js
import { calculateDiscount } from './discount';
describe('calculateDiscount', () => {
it('applies a percentage discount', () => {
expect(calculateDiscount(100, 20)).toBe(80);
expect(calculateDiscount(50, 10)).toBe(45);
});
it('handles the 0% and 100% boundaries', () => {
expect(calculateDiscount(100, 0)).toBe(100);
expect(calculateDiscount(100, 100)).toBe(0);
});
it('rejects invalid input', () => {
expect(() => calculateDiscount(-50, 10)).toThrow('Invalid input');
expect(() => calculateDiscount(100, 150)).toThrow('Invalid input');
});
});
Table-driven tests with test.each
When a function has many input/output pairs, don't copy-paste a dozen tests. Jest's test.each runs one test body over a table of cases and gives each a descriptive name:
describe('calculateDiscount (table-driven)', () => {
test.each([
{ price: 100, discount: 20, expected: 80 },
{ price: 50, discount: 10, expected: 45 },
{ price: 200, discount: 15, expected: 170 },
{ price: 100, discount: 0, expected: 100 },
{ price: 100, discount: 100, expected: 0 },
])('$price at $discount% → $expected', ({ price, discount, expected }) => {
expect(calculateDiscount(price, discount)).toBe(expected);
});
});
✅ Why table-driven tests shine
Adding a new case is a one-line change, each row reports its own pass/fail, and the pattern makes gaps in your coverage obvious. Reach for test.each whenever you catch yourself duplicating a test with only the numbers changed.
Testing Classes and Objects
When testing a class, aim at its public interface and observable behavior — not its private fields. If you test what the class does rather than how, you can refactor the internals freely without rewriting tests.
// bankAccount.js
export class BankAccount {
constructor(initialBalance = 0) {
this._balance = initialBalance;
this._transactions = [];
}
get balance() {
return this._balance;
}
deposit(amount) {
if (amount <= 0) throw new Error('Deposit amount must be positive');
this._balance += amount;
this._transactions.push({ type: 'deposit', amount, date: new Date() });
return this._balance;
}
withdraw(amount) {
if (amount <= 0) throw new Error('Withdrawal amount must be positive');
if (amount > this._balance) throw new Error('Insufficient funds');
this._balance -= amount;
this._transactions.push({ type: 'withdrawal', amount, date: new Date() });
return this._balance;
}
getTransactionHistory() {
return [...this._transactions]; // return a copy, not the internal array
}
}
// bankAccount.test.js
import { BankAccount } from './bankAccount';
describe('BankAccount', () => {
let account;
// beforeEach gives every test a fresh, identical starting point.
beforeEach(() => {
account = new BankAccount(100);
});
describe('deposit', () => {
it('increases the balance', () => {
account.deposit(50);
expect(account.balance).toBe(150);
});
it('records the transaction', () => {
account.deposit(50);
const [tx] = account.getTransactionHistory();
expect(tx).toMatchObject({ type: 'deposit', amount: 50, date: expect.any(Date) });
});
it('rejects a non-positive amount', () => {
expect(() => account.deposit(0)).toThrow('Deposit amount must be positive');
expect(() => account.deposit(-50)).toThrow('Deposit amount must be positive');
});
});
describe('withdraw', () => {
it('decreases the balance', () => {
account.withdraw(30);
expect(account.balance).toBe(70);
});
it('rejects an overdraft', () => {
expect(() => account.withdraw(150)).toThrow('Insufficient funds');
});
});
describe('getTransactionHistory', () => {
it('returns a copy, so callers cannot mutate internal state', () => {
account.deposit(50);
const history = account.getTransactionHistory();
history.push({ type: 'fake', amount: 1000 }); // tamper with the copy
expect(account.getTransactionHistory()).toHaveLength(1); // original unchanged
});
});
});
💡 beforeEach keeps tests isolated
Rebuilding the object in beforeEach means no test can leak state into the next. That single habit satisfies the I and R in FIRST for free. Jest also gives you beforeAll, afterEach, and afterAll for setup/teardown that runs once or after each test.
Mocking Dependencies
Isolation is the heart of unit testing, and mocking is how you achieve it. A mock is a stand-in — like a stunt double in a movie — that you control completely. It lets a test run without the real database, network, clock, or random-number generator.
Mock functions with jest.fn()
const mockCallback = jest.fn();
mockCallback('first');
mockCallback('second');
expect(mockCallback).toHaveBeenCalled();
expect(mockCallback).toHaveBeenCalledTimes(2);
expect(mockCallback).toHaveBeenCalledWith('first');
// Program return values:
const mockAdd = jest.fn((a, b) => a + b); // custom implementation
const mockFetch = jest.fn().mockResolvedValue({ data: 'ok' }); // async success
const mockFail = jest.fn().mockRejectedValue(new Error('down')); // async failure
Injecting a mock into the unit
The cleanest, most testable design passes dependencies into a class (dependency injection). Then a test simply hands over a mock:
// userService.js
export class UserService {
constructor(apiClient) {
this.apiClient = apiClient;
this.cache = new Map();
}
async getUser(id) {
if (this.cache.has(id)) return this.cache.get(id); // serve from cache
const user = await this.apiClient.fetchUser(id);
this.cache.set(id, user);
return user;
}
}
// userService.test.js
import { UserService } from './userService';
describe('UserService', () => {
let service;
let mockApiClient;
beforeEach(() => {
mockApiClient = { fetchUser: jest.fn() };
service = new UserService(mockApiClient);
});
it('fetches a user from the API', async () => {
const mockUser = { id: 1, name: 'John Doe' };
mockApiClient.fetchUser.mockResolvedValue(mockUser);
const user = await service.getUser(1);
expect(user).toEqual(mockUser);
expect(mockApiClient.fetchUser).toHaveBeenCalledWith(1);
});
it('caches the user after the first fetch', async () => {
mockApiClient.fetchUser.mockResolvedValue({ id: 1, name: 'John Doe' });
await service.getUser(1);
await service.getUser(1); // second call should hit the cache
expect(mockApiClient.fetchUser).toHaveBeenCalledTimes(1);
});
it('propagates API errors', async () => {
mockApiClient.fetchUser.mockRejectedValue(new Error('API Error'));
await expect(service.getUser(1)).rejects.toThrow('API Error');
});
});
⚠️ Reset mocks between tests
Mocks remember every call. Without a reset, call counts leak from one test into the next and cause phantom failures. Recreate mocks in beforeEach (as above) or call jest.clearAllMocks(). You can also set clearMocks: true in your Jest config to do it automatically.
💡 Don't over-mock
Mock the things you don't own or that are slow/unpredictable — APIs, databases, the clock. If you mock the very logic you're supposed to be testing, the test proves nothing. A calculator's arithmetic should run for real; only its I/O should be faked.
Testing Edge & Error Cases
The happy path is the easy 20%. Bugs live at the boundaries: empty strings, zero, negative numbers, the very first and very last element, and every way input can be wrong. Good tests go looking for trouble there.
// truncate.js
export function truncate(str, maxLength, suffix = '...') {
if (typeof str !== 'string') {
throw new TypeError('Input must be a string');
}
if (typeof maxLength !== 'number' || maxLength < 0) {
throw new TypeError('Max length must be a non-negative number');
}
if (str.length <= maxLength) return str;
if (maxLength === 0) return '';
const room = maxLength - suffix.length;
if (room <= 0) return suffix.substring(0, maxLength);
return str.substring(0, room) + suffix;
}
// truncate.test.js
import { truncate } from './truncate';
describe('truncate', () => {
// Normal cases
it('truncates a long string and adds the suffix', () => {
expect(truncate('Hello World', 8)).toBe('Hello...');
});
it('leaves a short string untouched', () => {
expect(truncate('Hello', 10)).toBe('Hello');
});
// Edge cases
it('handles an empty string', () => {
expect(truncate('', 5)).toBe('');
});
it('handles a zero max length', () => {
expect(truncate('Hello', 0)).toBe('');
});
it('handles a max length shorter than the suffix', () => {
expect(truncate('Hello World', 2)).toBe('..');
});
it('respects a custom suffix', () => {
expect(truncate('Hello World', 8, '…')).toBe('Hello W…');
});
// Error cases
it('throws for non-string input', () => {
expect(() => truncate(123, 5)).toThrow(TypeError);
expect(() => truncate(null, 5)).toThrow(TypeError);
});
it('throws for an invalid max length', () => {
expect(() => truncate('Hello', -1)).toThrow(TypeError);
});
});
📖 A checklist for edge cases
For any input, ask: what about empty? zero? negative? the maximum? the wrong type? null/undefined? boundaries (exactly at the limit, one over, one under)? A few minutes with this list catches the bugs users would have found for you.
Practice & Quiz
🏋️ Exercise 1: Test a Stack class
Goal: Write a suite for this class. Cover push/pop behavior, the size getter, and the error when popping an empty stack. Use beforeEach for a fresh instance.
// stack.js
export class Stack {
#items = [];
push(x) { this.#items.push(x); return this.size; }
pop() {
if (this.#items.length === 0) throw new Error('Stack is empty');
return this.#items.pop();
}
get size() { return this.#items.length; }
}
💡 Hint
Test behavior, not the private #items array. Verify order with a push-push-pop sequence (LIFO), and wrap the empty-pop call in a function for toThrow.
✅ Solution
import { Stack } from './stack';
describe('Stack', () => {
let stack;
beforeEach(() => { stack = new Stack(); });
it('starts empty', () => {
expect(stack.size).toBe(0);
});
it('pushes and reports the new size', () => {
expect(stack.push('a')).toBe(1);
expect(stack.push('b')).toBe(2);
});
it('pops in last-in-first-out order', () => {
stack.push('a');
stack.push('b');
expect(stack.pop()).toBe('b');
expect(stack.pop()).toBe('a');
expect(stack.size).toBe(0);
});
it('throws when popping an empty stack', () => {
expect(() => stack.pop()).toThrow('Stack is empty');
});
});
🏋️ Exercise 2: Mock a dependency
Goal: Notifier depends on an emailClient. Write a test proving that notify calls send with the right arguments — without sending a real email.
// notifier.js
export class Notifier {
constructor(emailClient) { this.emailClient = emailClient; }
notify(user, message) {
return this.emailClient.send(user.email, message);
}
}
✅ Solution
import { Notifier } from './notifier';
test('notify sends an email to the user', () => {
const emailClient = { send: jest.fn().mockReturnValue(true) };
const notifier = new Notifier(emailClient);
const result = notifier.notify({ email: 'a@b.com' }, 'Hi');
expect(result).toBe(true);
expect(emailClient.send).toHaveBeenCalledWith('a@b.com', 'Hi');
expect(emailClient.send).toHaveBeenCalledTimes(1);
});
🎯 Quick Quiz
Question 1: What does the "I" in the FIRST principles stand for?
Question 2: Why put object creation inside beforeEach?
Question 3: Which is an example of over-mocking?
Best Practices & Pitfalls
✅ Do
- Write descriptive names:
'returns null when the user is not found' - Test one behavior per test, and follow Arrange-Act-Assert
- Test public behavior; treat private fields as invisible
- Cover the happy path and edge/error cases
- Use
test.eachfor repetitive input/output tables - Mock only external, slow, or nondeterministic dependencies
❌ Don't
- Share mutable state between tests (leaked
counter++bugs) - Assert on private internals like
service._cache - Cram five behaviors into one "kitchen-sink" test
- Mock everything until the test proves nothing real
- Test the framework — trust that React or Jest itself works
⚠️ Order-dependent tests
// ❌ Fragile: relies on running in a specific order
let counter = 0;
test('a', () => { counter++; expect(counter).toBe(1); });
test('b', () => { counter++; expect(counter).toBe(2); }); // breaks if reordered
// ✅ Independent: each test owns its state
test('a', () => { let n = 0; expect(++n).toBe(1); });
test('b', () => { let n = 0; expect(++n).toBe(1); });
Jest can run tests in any order (and in parallel across files). Any test that assumes an order is a flaky test waiting to happen.
Summary
🎉 Key Takeaways
- Good unit tests are FIRST: Fast, Isolated, Repeatable, Self-validating, Timely
- Structure every test as Arrange-Act-Assert and nest
describeblocks to read like sentences - Pure functions are the easiest to test — use
test.eachfor many cases - Test classes through their public behavior, refreshing state in
beforeEach - Mock external dependencies with
jest.fn(); reset them between tests; never over-mock - Deliberately hunt edge and error cases — that's where bugs hide
📚 Additional Resources
🚀 What's Next?
So far we've written code first and tested it after. The next lesson flips that around: with Test-Driven Development you write the failing test first and let it drive the design, cycling through Red, Green, Refactor.
🎉 You write good tests now!
Isolated, readable, honest tests are the foundation every senior engineer relies on. Next, let tests lead the way.