Skip to main content

✍️ Writing Effective Unit Tests

You know the principles and your runner is configured. Now the craft: what separates a test that catches real bugs and survives refactors from one that breaks every time someone renames a variable. The difference isn't effort — it's testing behavior instead of implementation, and structuring each test so its intent is obvious at a glance.

Week 12 · Day 1 (Monday: Unit Testing) · Lecture 3

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Structure every test with the Arrange-Act-Assert pattern for instant readability
  • Test observable behavior rather than internal implementation details
  • Recognize and rewrite brittle tests that break on harmless changes
  • Collapse repetitive cases into table-driven tests with test.each
  • Mock external dependencies deliberately — and know when not to
  • Name tests so the suite reads like a specification

Estimated Time: 60 minutes

Practice: Rewrite a brittle, over-mocked test into a behavior-focused, table-driven one.

In This Lesson

Arrange, Act, Assert

The single most useful habit in testing is giving every test the same three-part shape. Arrange the inputs and world, Act by calling the one thing under test, then Assert the outcome. A reader should be able to find each phase in a heartbeat.

The Arrange-Act-Assert cycle: set up inputs, call the code once, then check the result Arrange set up inputs & state Act call the code once Assert check the result
Three phases, in order, once each. When a test needs a second "Act," it's usually two tests wearing a trench coat.

Here it is on a shopping cart. Notice the blank lines between phases — they do more for readability than any comment:

test('applies a percentage discount to the cart total', () => {
  // Arrange
  const cart = new ShoppingCart();
  cart.addItem({ id: 1, name: 'Widget', price: 10 }, 2); // 2 × $10
  cart.addItem({ id: 2, name: 'Gadget', price: 5 }, 1);  // 1 × $5

  // Act
  cart.applyDiscount({ code: 'SAVE20', percentage: 20 });
  const total = cart.calculateTotal();

  // Assert
  expect(total).toBe(20); // (20 + 5) − 20% = 20
});

✅ One act, focused asserts

Keep the Act to a single call so a failure points at one behavior. Multiple asserts are fine when they all describe that one outcome (the total, and that the discount was recorded) — just don't smuggle a second unrelated action in between.

Test Behavior, Not Implementation

This is the idea that makes tests durable. A test should verify what your code does from the outside — its observable behavior — not how it does it inside. Tests glued to internal details break the moment you refactor, even when the behavior is perfectly correct. That's the worst kind of failure: noise that trains your team to ignore red.

👎 Coupled to implementation

test('saveUser formats then stores the user', () => {
  const formatSpy = jest.spyOn(userFormatter, 'format');
  const dbSpy = jest.spyOn(database, 'save');

  userService.saveUser({ name: 'John', email: 'john@example.com' });

  // These assert HOW the work is done, step by step:
  expect(formatSpy).toHaveBeenCalled();
  expect(dbSpy).toHaveBeenCalled();
});

Swap userFormatter for a different helper, or save via a repository instead of database directly, and this test goes red — despite the user still being saved correctly.

👍 Coupled to behavior

test('saveUser persists a user that can be read back', async () => {
  await userService.saveUser({ name: 'John', email: 'john@example.com' });

  // Asserts WHAT is true afterward, not the steps taken:
  const saved = await userService.findByEmail('john@example.com');
  expect(saved.name).toBe('John');
});

This survives any internal refactor as long as saving-then-reading still works — which is the only thing a caller actually cares about.

💡 The litmus test: ask "if I rewrote the internals but kept the same public behavior, would this test still pass?" If no, it's testing implementation. The exception: when an interaction is the behavior — e.g. "does not charge the card when validation fails" — asserting the call (or its absence) is exactly right.

Spotting Brittle Tests

A brittle test breaks in response to changes that don't affect behavior. Brittle tests are worse than no tests: they cost maintenance time and erode trust until people stop reading failures. Learn to smell them.

Asserting on exact structure

👎 Brittle

// Breaks if anyone adds a class, whitespace, or wraps an element
expect(wrapper.html()).toBe(
  '<div class="profile"><h2>John Doe</h2><p>john@example.com</p></div>'
);

👍 Robust

// Asserts the meaningful content, not the exact markup
expect(screen.getByRole('heading')).toHaveTextContent('John Doe');
expect(screen.getByText('john@example.com')).toBeInTheDocument();

Other common brittleness sources

SmellWhy it breaksFix
Exact-match on a whole objectAn added, irrelevant field fails the testAssert only the fields you care about, or use expect.objectContaining
Depends on test orderShared state leaks between testsFresh state per test; clearMocks: true
Real clock or random valuesPasses today, fails at midnightFake timers; inject the random source
Asserting private internalsRenaming a private method breaks itTest through the public API only
// Robust partial match — extra fields on the result won't fail this
expect(profile).toEqual(
  expect.objectContaining({ id: '123', name: 'John Doe', isAdmin: true })
);

Table-Driven Tests with test.each

When the same logic needs checking against many input/output pairs, copy-pasting a test per case is noise. Jest's test.each runs one test body over a table of rows, producing a separate, individually-named result for each — so a failure tells you exactly which row broke.

// Instead of five near-identical tests:
describe('isValidEmail', () => {
  test.each([
    // [input,                       expected]
    ['valid@example.com',            true],
    ['first.last@sub.domain.com',    true],
    ['',                             false],
    ['missing-at.com',               false],
    ['spaces in@email.com',          false],
  ])('isValidEmail(%s) === %s', (input, expected) => {
    expect(isValidEmail(input)).toBe(expected);
  });
});

Output

✓ isValidEmail(valid@example.com) === true
✓ isValidEmail(first.last@sub.domain.com) === true
✓ isValidEmail() === false
✓ isValidEmail(missing-at.com) === false
✓ isValidEmail(spaces in@email.com) === false

For readability with many columns, use the tagged-template form with named headings:

test.each`
  a    | b    | expected
  ${1} | ${2} | ${3}
  ${5} | ${5} | ${10}
  ${-1}| ${1} | ${0}
`('add($a, $b) returns $expected', ({ a, b, expected }) => {
  expect(add(a, b)).toBe(expected);
});

⚠️ Don't over-collapse

test.each is for cases that exercise the same behavior with different data. Genuinely different behaviors — "accepts valid input" vs. "throws on null" — deserve their own named tests, because they document different things and often need different setup.

Mocking Without Over-Mocking

You met the test doubles in the principles lesson. Here's the practical discipline: mock at the boundary — the network, the clock, the filesystem, third-party services — and use real code for everything inside your own module. Mocking your own collaborators is what leads to tests that only prove your mocks were called.

graph LR A["Test"] --> B["Your code under test (real)"] B --> C["Your own helpers (real)"] B --> D["Boundary: HTTP, DB, clock"] D -.mock here.-> E["Test double"]

A clean example: inject the dependency so the test can pass a stub, and assert on the transformed result, not the internal wiring:

// userService.js — the API client is injected, so it's easy to substitute
class UserService {
  constructor(apiClient) { this.apiClient = apiClient; }

  async getProfile(userId) {
    try {
      const res = await this.apiClient.get(`/users/${userId}`);
      return { id: res.data.id, name: res.data.name, isAdmin: res.data.role === 'admin' };
    } catch (err) {
      if (err.response?.status === 404) return null;
      throw new Error(`Failed to get user: ${err.message}`);
    }
  }
}
// userService.test.js
describe('UserService.getProfile', () => {
  let apiClient, service;
  beforeEach(() => {
    apiClient = { get: jest.fn() };          // stub only the boundary
    service = new UserService(apiClient);
  });

  test('maps the raw API shape to our profile shape', async () => {
    apiClient.get.mockResolvedValue({ data: { id: '1', name: 'Ada', role: 'admin' } });

    const profile = await service.getProfile('1');

    expect(profile).toEqual({ id: '1', name: 'Ada', isAdmin: true });
  });

  test('returns null when the user does not exist', async () => {
    const err = new Error('Not found');
    err.response = { status: 404 };
    apiClient.get.mockRejectedValue(err);

    await expect(service.getProfile('999')).resolves.toBeNull();
  });

  test('wraps unexpected errors with context', async () => {
    apiClient.get.mockRejectedValue(new Error('Network down'));

    await expect(service.getProfile('1')).rejects.toThrow('Failed to get user: Network down');
  });
});

💡 Reset between tests

Mocks remember every call, which leaks state across tests. Set clearMocks: true in your config (from last lesson) or call jest.clearAllMocks() in a global afterEach so each test starts with a clean slate. A stray remembered call is a classic source of a test that only passes when run after another.

Names That Read Like a Spec

A test's name is documentation that runs. Read top to bottom, a good suite's names describe the component's contract. Aim for the shape "does X when Y."

// 👎 Vague — tells you nothing when it fails in CI
test('works', () => { /* ... */ });
test('test 2', () => { /* ... */ });

// 👍 A readable specification
describe('withdraw', () => {
  test('subtracts the amount from the balance', () => { /* ... */ });
  test('throws when the amount exceeds the balance', () => { /* ... */ });
  test('rejects a negative amount', () => { /* ... */ });
});

Nest describe blocks by unit and then by scenario, and the console output becomes a table of contents for how your code behaves:

Output

withdraw
  ✓ subtracts the amount from the balance
  ✓ throws when the amount exceeds the balance
  ✓ rejects a negative amount

Practice & Quiz

🏋️ Exercise 1: Rewrite a brittle test

Goal: The test below is coupled to implementation and asserts exact markup. Rewrite it to test behavior robustly.

test('renders discount badge', () => {
  const formatSpy = jest.spyOn(priceFormatter, 'format');
  const { container } = render(<Price value={20} discount={0.2} />);

  expect(formatSpy).toHaveBeenCalled();
  expect(container.innerHTML).toBe(
    '<span class="price">$16.00</span><span class="badge">-20%</span>'
  );
});
💡 Hint

Drop the spy entirely — the formatter is an internal detail. Assert the visible text the user sees, using role/text queries instead of an exact HTML string.

✅ Solution
test('shows the discounted price and the percent off', () => {
  render(<Price value={20} discount={0.2} />);

  expect(screen.getByText('$16.00')).toBeInTheDocument();
  expect(screen.getByText('-20%')).toBeInTheDocument();
});

No implementation coupling, no exact-markup match — it passes as long as the user sees the right numbers, and survives any restyling of the component.

🏋️ Exercise 2: Collapse into a table

Goal: Turn these three repetitive tests for a clamp(value, min, max) function into a single test.each.

test('returns value when in range', () => { expect(clamp(5, 0, 10)).toBe(5); });
test('clamps to min', () => { expect(clamp(-3, 0, 10)).toBe(0); });
test('clamps to max', () => { expect(clamp(99, 0, 10)).toBe(10); });
✅ Solution
describe('clamp', () => {
  test.each([
    // [value, min, max, expected]
    [5,   0, 10, 5],   // in range
    [-3,  0, 10, 0],   // below min
    [99,  0, 10, 10],  // above max
  ])('clamp(%i, %i, %i) === %i', (value, min, max, expected) => {
    expect(clamp(value, min, max)).toBe(expected);
  });
});

🎯 Quick Quiz

Question 1: In the Arrange-Act-Assert pattern, what belongs in the "Act" phase?

Question 2: Which test is least likely to break during a harmless internal refactor?

Question 3: Where should you place your mocks?

Best Practices & Pitfalls

✅ Do

  • Give every test a clear Arrange / Act / Assert shape with one Act
  • Assert observable behavior — inputs, outputs, and side effects a caller can see
  • Use expect.objectContaining and role/text queries to avoid brittle exact matches
  • Collapse same-behavior cases with test.each; keep different behaviors as named tests
  • Mock only at the boundary and reset mocks between tests
  • Name tests as "does X when Y" so the suite reads like a spec

❌ Don't

  • Spy on private methods or assert the exact sequence of internal calls
  • Match whole objects or full HTML strings when you only care about a few fields
  • Share mutable state across tests or depend on execution order
  • Over-mock until the test only verifies the mocks were called
  • Chase a coverage number with assertion-free tests

⚠️ Prove the test can fail

Before you trust a new test, break the code on purpose and confirm the test goes red. A test you've only ever seen pass might be asserting nothing at all — the same warning from the principles lesson, and the cheapest insurance in testing.

Summary

🎉 Key Takeaways

  • Structure every test as Arrange, Act, Assert with a single Act
  • Test observable behavior, not implementation — ask "would this survive an internal rewrite?"
  • Brittle tests (exact markup, whole-object matches, shared state) cost more than no tests — rewrite them
  • test.each collapses same-behavior cases into individually-named rows
  • Mock at the boundary only, and reset mocks between tests
  • Name tests so the suite reads like a living specification

📚 Additional Resources

🚀 What's Next?

You can now write unit tests that catch real bugs and survive refactors. Next we climb the pyramid: Integration Testing Strategies — wiring real components (and a test database) together to verify the seams between units that unit tests can't reach.

✍️ Tests worth keeping

Behavior over implementation, clarity over cleverness. These habits turn a test suite from a chore into the safety net that lets you ship fast.