π‘οΈ Testing Principles
Back in Week 3 you wrote your first Jest tests and watched a green checkmark appear. This week we go up a level: why we test, which tests to write and in what proportion, and the discipline β the pyramid, the FIRST principles, and test-driven development β that separates a suite you trust from one you fight with every sprint.
Week 12 · Day 1 (Monday: Unit Testing) · Lecture 1
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain the concrete payoffs of an automated test suite β regression safety, living documentation, and design feedback
- Describe the testing pyramid and choose the right proportion of unit, integration, and end-to-end tests
- Apply the FIRST principles to judge whether a unit test is worth keeping
- Run the red-green-refactor cycle of test-driven development on a small problem
- Distinguish the five test doubles β dummy, stub, spy, mock, and fake β and know when each fits
Estimated Time: 60 minutes
Practice: Classify a set of tests by pyramid layer, then drive a small validator with a TDD loop.
In This Lesson
Why We Really Test
Every developer eventually learns the hard way that "it works on my machine" is not the same as "it works." A test suite is the machine that repeats the boring, careful checking you would never do by hand on every save. But the value goes deeper than catching bugs β a good suite pays you back in four distinct currencies.
- Regression safety. The whole point of a growing codebase is that you keep changing it. Tests are the tripwire that fires the moment a change quietly breaks something three modules away.
- Living documentation. A well-named test reads like a spec:
rejects a withdrawal larger than the balance. Unlike a wiki page, it can never drift out of date, because it either passes or it doesn't. - Design feedback. Code that is painful to test is usually telling you something β too many dependencies, hidden global state, one function doing five jobs. The friction is a design smell you can act on.
- Confidence to refactor. With tests green, you can restructure aggressively and know within seconds whether behavior still holds. Without them, refactoring is a leap in the dark.
β οΈ The cost of skipping tests
In 2012, the trading firm Knight Capital deployed code that reused an old, repurposed feature flag. Untested, it sent a storm of erroneous orders and lost roughly $440 million in about 45 minutes. No single test would have prevented every problem that day β but the episode is the canonical reminder that unverified code shipped to production is a bet, not a plan.
π‘ Reframe: Tests are not a tax you pay after writing code. They are the feedback loop that lets you write code faster, because you stop re-verifying old behavior by hand.
The Testing Pyramid
Not all tests are equal. They trade off along two axes: speed and isolation versus realism and confidence. The testing pyramid, popularized by Mike Cohn, is a rule of thumb for balancing them: write many small, fast tests at the base, fewer tests that wire components together in the middle, and only a handful of slow, full-system tests at the top.
| Layer | What it checks | Speed | How many |
|---|---|---|---|
| Unit | One function or class in isolation, no network or database | Milliseconds | The most β the foundation |
| Integration | Several units together, or a unit plus a real dependency (a test database, an HTTP route) | Tens to hundreds of ms | Fewer |
| End-to-end (E2E) | The whole app through a real browser or API, as a user would | Seconds | Only the critical paths |
Why not just write E2E tests for everything, since they're the most realistic? Because they are slow, flaky, and terrible at telling you where a failure is. A red unit test points at one function; a red E2E test could be anything from a CSS change to a down database. This week lives at the base of the pyramid β unit tests β and next lesson's Jest configuration is what makes running hundreds of them a sub-second affair.
π‘ Pyramid, trophy, or honeycomb?
You'll hear about the "testing trophy" (Kent C. Dodds) which fattens the integration layer for UI-heavy apps, and other shapes besides. Don't treat any diagram as scripture. The durable idea underneath them all: prefer the cheapest test that still gives you real confidence, and lean on slow tests only where cheaper ones can't reach.
FIRST: What Makes a Good Unit Test
A green checkmark is not the goal β a trustworthy green checkmark is. The FIRST acronym is a checklist for whether a unit test earns its place in the suite.
| Letter | Principle | Why it matters |
|---|---|---|
| F | Fast | You'll run the suite hundreds of times a day. A slow suite gets run less, and a suite that isn't run protects nothing. |
| I | Isolated | Each test sets up its own world and depends on no other test. Order must never change the outcome. |
| R | Repeatable | Same inputs, same result β every machine, every time. No reliance on the clock, random values, or a shared database. |
| S | Self-validating | The test decides pass or fail on its own via assertions. No human squinting at logged output. |
| T | Timely | Written close to the code β ideally just before it (TDD) β so the code stays testable by design. |
Two of these break most often in practice, so let's make them concrete.
Isolated & Repeatable in code
// β Not repeatable: the assertion depends on today's date
test('greeting mentions the year', () => {
expect(buildGreeting()).toContain('2026'); // passes only in 2026
});
// β
Repeatable: freeze the clock so the test controls time
test('greeting mentions the current year', () => {
jest.useFakeTimers().setSystemTime(new Date('2030-01-01'));
expect(buildGreeting()).toContain('2030');
jest.useRealTimers(); // restore, so later tests stay isolated
});
// β Not isolated: tests share one mutable array, so order matters
const users = [];
test('adds a user', () => {
users.push({ name: 'Ada' });
expect(users).toHaveLength(1); // breaks if another test ran first
});
// β
Isolated: build fresh state inside each test (or a beforeEach)
test('adds a user', () => {
const users = [];
users.push({ name: 'Ada' });
expect(users).toHaveLength(1);
});
β The one-reason rule
A good unit test has exactly one reason to fail. If you can't finish the sentence "this test fails when ___" with a single clear behavior, it's probably testing too much β split it.
Test-Driven Development
Test-driven development (TDD) inverts the usual order: you write a failing test first, then just enough code to pass it, then clean up. It sounds backwards until you try it β writing the test first forces you to design the interface from the caller's point of view before you're distracted by the implementation.
- Red. Write a small test for behavior that doesn't exist yet. Run it and watch it fail β this proves the test can fail, so a later pass means something.
- Green. Write the simplest code that makes it pass. Ugly is fine here; correctness first.
- Refactor. Now improve the code β rename, extract, simplify β with the safety net of a passing test underneath you.
A tiny TDD loop
Say we need isValidZip(str) that accepts a US 5-digit ZIP. We start with the test that doesn't compile yet:
// zip.test.js β RED
const { isValidZip } = require('./zip');
test('accepts a plain 5-digit zip', () => {
expect(isValidZip('90210')).toBe(true);
});
test('rejects anything that is not exactly 5 digits', () => {
expect(isValidZip('9021')).toBe(false); // too short
expect(isValidZip('902100')).toBe(false); // too long
expect(isValidZip('9021a')).toBe(false); // not digits
});
// zip.js β GREEN: the simplest thing that passes
function isValidZip(str) {
return /^\d{5}$/.test(str);
}
module.exports = { isValidZip };
The regex passes both tests immediately, so there's little to refactor here β but the loop is the point. Each new requirement (ZIP+4? leading-zero ZIPs like '01234'?) becomes a new red test before a line of implementation.
π TDD is a design tool, not a religion
You don't have to write every line test-first to benefit. Many teams use TDD for gnarly logic (parsers, pricing, validators) where the tests double as a spec, and write tests after for straightforward glue code. What matters is that the tested behavior is pinned down before you consider the feature done.
The Test Doubles Family
To keep a unit test fast and isolated, you often have to replace a real dependency β a database, a payment gateway, the network β with a stand-in. Gerard Meszaros called these stand-ins test doubles (like a stunt double in film). There are five, and using the right name for the right job keeps your tests honest.
| Double | What it does | Reach for it when⦠|
|---|---|---|
| Dummy | Filler passed to satisfy a signature, never actually used | A parameter is required but irrelevant to this test |
| Stub | Returns canned answers to calls | You need a dependency to return a specific value |
| Spy | Records how it was called, optionally runs the real code | You want to verify a call happened without changing behavior |
| Mock | Pre-programmed with expectations it verifies | The interaction itself is the thing under test |
| Fake | A real but simplified working implementation | The dependency is complex and a canned value won't do (e.g. an in-memory DB) |
Jest gives you tools that cover all five β jest.fn() for stubs and mocks, jest.spyOn() for spies β and you'll write plain objects and classes for dummies and fakes. Here are the two you'll reach for most:
// STUB β jest.fn() returning a canned value
const db = { getUser: jest.fn().mockReturnValue({ id: 1, name: 'Ada' }) };
const service = new ProfileService(db);
expect(service.displayName(1)).toBe('Ada'); // db.getUser is stubbed
// SPY β watch a real method without replacing it
const logger = { warn(msg) { /* real impl */ } };
const spy = jest.spyOn(logger, 'warn');
chargeCard(logger, -5); // should log a warning
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore(); // put the real method back
// FAKE β a working in-memory stand-in for a real repository
class FakeUserRepo {
#store = new Map();
async save(user) { this.#store.set(user.id, user); return user; }
async findById(id) { return this.#store.get(id) ?? null; }
}
test('updateName persists the change', async () => {
const repo = new FakeUserRepo();
await repo.save({ id: '1', name: 'Old' });
const service = new UserService(repo);
await service.updateName('1', 'New');
expect((await repo.findById('1')).name).toBe('New');
});
β οΈ Don't over-mock
Every double you add is a copy of reality that can drift out of sync with the real thing. A test drowning in five mocks often verifies that your mocks were called in the order you wrote them β not that the feature works. When a fake or a real (test) database is cheap enough, prefer it. We'll return to this trade-off in Writing Effective Unit Tests.
Practice & Quiz
ποΈ Exercise 1: Sort tests into pyramid layers
Goal: For each test below, decide whether it is a unit, integration, or end-to-end test, and say why.
- Calling
formatCurrency(1999)and asserting it returns"$19.99". - Spinning up the Express app plus a test Postgres database, POSTing to
/orders, and checking a row appears. - Driving a real browser with Playwright to sign in, add an item to the cart, and check out.
- Verifying
OrderService.total()sums line items, with the repository replaced by a fake.
π‘ Hint
Ask two questions: how many real moving parts are involved, and does anything cross a process boundary (network, disk, browser)?
β Solution
1 β Unit. One pure function, no dependencies. 2 β Integration. Several units plus a real database, but not the whole user-facing app. 3 β End-to-end. The entire stack through a real browser. 4 β Unit. A single class in isolation; the fake keeps it off the database, so it stays fast and isolated.
ποΈ Exercise 2: Drive a validator with TDD
Goal: Using red-green-refactor, build isStrongPassword(pwd) that requires at least 8 characters, one letter, and one digit. Write the failing tests first, then the implementation.
π‘ Hint
Start with one behavior β "rejects a 5-character password" β get it red, then green. Add the letter rule as a new test, then the digit rule. Only generalize the regex once all three are green.
β Solution
// password.test.js
const { isStrongPassword } = require('./password');
test.each([
['abc12', false], // too short
['abcdefgh', false], // no digit
['12345678', false], // no letter
['abcd1234', true], // long enough, has both
])('isStrongPassword(%s) === %s', (pwd, expected) => {
expect(isStrongPassword(pwd)).toBe(expected);
});
// password.js
function isStrongPassword(pwd) {
return pwd.length >= 8 && /[a-zA-Z]/.test(pwd) && /\d/.test(pwd);
}
module.exports = { isStrongPassword };
Notice how the test file reads as a spec of exactly what "strong" means β that's the documentation payoff in action.
π― Quick Quiz
Question 1: According to the testing pyramid, which kind of test should you have the most of?
Question 2: In the FIRST principles, what does the R stand for?
Question 3: Which test double is a real, simplified working implementation β such as an in-memory database?
Best Practices & Pitfalls
β Do
- Weight your suite toward fast, isolated unit tests; add integration and E2E only where they earn their cost
- Build fresh state inside each test (or in
beforeEach) so tests stay isolated and order-independent - Give each test one reason to fail and a name that reads like a sentence
- Freeze the clock and control randomness so tests are repeatable
- Reach for the cheapest double that does the job β often a fake beats a pile of mocks
β Don't
- Chase 100% coverage by testing trivial getters β coverage is a hint, not a goal (more on this next lesson)
- Let tests share mutable module-level state
- Assert on the clock,
Math.random(), or a live external API in a unit test - Mock so heavily that the test only proves your mocks were called
β οΈ Green does not mean correct
A test that never asserts anything meaningful passes forever. Before trusting a new test, make it fail on purpose once β break the code, confirm the test goes red, then fix it. A test you've never seen fail is a test you can't trust.
Summary
π Key Takeaways
- Tests buy you regression safety, documentation, design feedback, and refactoring confidence β not just bug-catching
- The testing pyramid: many fast unit tests, fewer integration tests, few slow end-to-end tests
- FIRST β Fast, Isolated, Repeatable, Self-validating, Timely β is the checklist for a unit test worth keeping
- TDD runs red β green β refactor, designing the interface before the implementation
- The five test doubles β dummy, stub, spy, mock, fake β replace real dependencies to keep tests fast and isolated
π Additional Resources
- Jest β Getting Started
- Martin Fowler β The Practical Test Pyramid
- Martin Fowler β Test Double
- Kent C. Dodds β Write Tests. Not Too Many. Mostly Integration.
π What's Next?
You know what to test and why. Next we make running those tests effortless: Jest Configuration β the jest.config.js options that control the test environment, coverage thresholds, and setup files (and where the Vite-native Vitest fits in).
π― Principles in place
With the pyramid, FIRST, TDD, and test doubles in your toolkit, you're ready to configure a test runner that makes the good habits cheap to keep.