✍️ 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.
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
| Smell | Why it breaks | Fix |
|---|---|---|
| Exact-match on a whole object | An added, irrelevant field fails the test | Assert only the fields you care about, or use expect.objectContaining |
| Depends on test order | Shared state leaks between tests | Fresh state per test; clearMocks: true |
| Real clock or random values | Passes today, fails at midnight | Fake timers; inject the random source |
| Asserting private internals | Renaming a private method breaks it | Test 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.
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.objectContainingand 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.eachcollapses 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
- Jest — Globals API (test.each, describe, hooks)
- Jest — Mock Functions
- Testing Library — Guiding Principles (test like a user)
- Martin Fowler — Test Pyramid
🚀 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.