Skip to main content

🔬 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() and beforeEach
  • 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.

graph TD A[A Good Unit Test] --> B[Tests one unit] A --> C[Isolates dependencies] A --> D[Runs fast] A --> E[Is deterministic] B --> B1[a function or method] C --> C1[no real network] C --> C2[no real database] D --> D1[milliseconds, not seconds] E --> E1[same input, same result]

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.

LetterPrincipleWhat it means
FFastRun in milliseconds so you run them constantly
IIsolatedNo test depends on another test's state or order
RRepeatableSame result on any machine, any time of day
SSelf-validatingPass or fail with no human eyeballing output
TTimelyWritten 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.

Arrange, Act, Assert flowing left to right with example lines Arrange const cart = new ShoppingCart(); Act cart.addItem(item); Assert expect(cart.items) .toHaveLength(1);
Keep the three phases visually distinct and any reader can follow the test in seconds.
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.

A unit under test with real dependencies swapped for mocks Unit under test mock API mock database assertions
Mocks replace the messy outside world so the test can focus on one unit's logic.

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.each for 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 describe blocks to read like sentences
  • Pure functions are the easiest to test — use test.each for 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.