Skip to main content

πŸ›‘οΈ Testing Principles

Week 12: Testing & CI/CD β€” course module banner illustration

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.

The testing pyramid: many fast unit tests at the base, fewer integration tests, few slow end-to-end tests at the top E2E Integration Unit few Β· slow many Β· fast confidence & realism speed & isolation β–²
The wider the layer, the more tests of that kind you should have. Confidence climbs as you go up; speed and the number of tests fall.
LayerWhat it checksSpeedHow many
UnitOne function or class in isolation, no network or databaseMillisecondsThe most β€” the foundation
IntegrationSeveral units together, or a unit plus a real dependency (a test database, an HTTP route)Tens to hundreds of msFewer
End-to-end (E2E)The whole app through a real browser or API, as a user wouldSecondsOnly 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.

LetterPrincipleWhy it matters
FFastYou'll run the suite hundreds of times a day. A slow suite gets run less, and a suite that isn't run protects nothing.
IIsolatedEach test sets up its own world and depends on no other test. Order must never change the outcome.
RRepeatableSame inputs, same result β€” every machine, every time. No reliance on the clock, random values, or a shared database.
SSelf-validatingThe test decides pass or fail on its own via assertions. No human squinting at logged output.
TTimelyWritten 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.

graph LR A["Red: write a failing test"] --> B["Green: write the least code to pass"] B --> C["Refactor: clean up, tests stay green"] C --> A
  1. 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.
  2. Green. Write the simplest code that makes it pass. Ugly is fine here; correctness first.
  3. 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.

DoubleWhat it doesReach for it when…
DummyFiller passed to satisfy a signature, never actually usedA parameter is required but irrelevant to this test
StubReturns canned answers to callsYou need a dependency to return a specific value
SpyRecords how it was called, optionally runs the real codeYou want to verify a call happened without changing behavior
MockPre-programmed with expectations it verifiesThe interaction itself is the thing under test
FakeA real but simplified working implementationThe 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.

  1. Calling formatCurrency(1999) and asserting it returns "$19.99".
  2. Spinning up the Express app plus a test Postgres database, POSTing to /orders, and checking a row appears.
  3. Driving a real browser with Playwright to sign in, add an item to the cart, and check out.
  4. 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

πŸš€ 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.