A 38-minute working session on tests that earn their keep: the pyramid, the anatomy of a good test, the red-green-refactor loop, test doubles, the tools that run it all, and how to write tests that catch bugs instead of cementing them.
Tests aren't about proving you're right today. They're about the next person — often future-you — being able to change this code without holding their breath. A bug is cheapest to catch the moment you write it, and the cost only climbs from there.
cost to fix a bug caught while you're typing it.
…once it's merged and someone builds on it.
…once it's in production, paged at 2 a.m.
the bug that ships silently and erodes trust in the data.
The goal isn't 100% coverage. It's confidence per minute spent.
Not all tests cost the same. The pyramid is a budget: push most of your checks down to the cheap, fast layer, and reserve the slow, brittle layer for the handful of journeys that truly need it.
A common rule of thumb is roughly 70% unit, 20% integration, 10% end-to-end — most of your budget at the fast base, thinning out toward the slow, full-system top.
Higher up = more realistic, but slower, flakier, and harder to debug. Confidence has a price; the pyramid keeps the bill sane.
Most teams drift into the inverted pyramid: a thick layer of slow end-to-end tests, almost no unit tests. The suite takes 40 minutes, fails randomly, and nobody trusts the red.
Push logic down: test the pricing rule as a unit, not by clicking through checkout. Keep E2E for a few critical paths — sign-up, checkout, the money path.
Like proofreading: spell-check every sentence, but only read the whole essay aloud a couple of times.
A test you can read in five seconds is a test people will keep passing. Structure it the same way every time, name it after the behavior, and assert one thing.
test("test1", () => { const c = new Cart() c.add(book); c.add(pen) expect(c.total()).toBe(30) c.applyCoupon("SAVE10") // second behavior… expect(c.total()).toBe(27) expect(c.items.length).toBe(2) // …and a third }) // what broke? the name won't tell you.
test("applies a 10% coupon to the cart total", () => { // Arrange const cart = cartWith(book30) // Act cart.applyCoupon("SAVE10") // Assert expect(cart.total()).toBe(27) })
The test name is documentation. A reader should know what broke without opening the body. Describe the scenario and the expected outcome — never test1.
Break any one and the suite gets slower, flakier, or quietly useless. Isolated and Repeatable are the ones teams violate most — usually via shared databases and the clock.
TDD inverts the usual order: write the failing test first, make it pass with the simplest code, then clean up. The test isn't an afterthought — it's the spec you're coding toward.
Small loops, minutes each. The test goes red before the code exists, then drives it green.
The discipline forces tiny steps. You're never more than a few minutes from a known-good state.
// written, then "how do I even test this?" function checkout() { const now = Date.now() // hidden clock const db = new Postgres() // hidden dependency // 60 lines of mixed I/O + rules… } // untestable without a real DB and the right date.
// the test you wished you could write FIRST: test("10% off orders over $100", () => { const price = discount(120) expect(price).toBe(108) }) // forces a pure fn: inputs in, result out. function discount(total) { /* no clock, no DB */ }
TDD's real gift isn't the tests — it's that hard-to-test code is hard-to-test for a reason, and writing the test first surfaces that pain while it's still cheap to fix.
To test a unit in isolation you replace its real dependencies — the database, the payment gateway, the clock — with controllable stand-ins. "Mock" gets used for all of them, but the five types do different jobs.
Inject the double in place of the real gateway. The SUT can't tell the difference — that's what dependency injection buys you.
// passed only to satisfy a signature; never called const logger = {} as Logger new Invoice(items, logger) // this test never logs
// pre-programmed responses, no logic of its own const rates: RateApi = { usdTo: () => 0.8 // always returns 0.8, whatever the input } expect(convert(100, rates)).toBe(80)
const sent: string[] = [] const mailer: Mailer = { send: (to, body) => { sent.push(to) } // records } notifyAll(users, mailer) expect(sent).toEqual(["a@x.io", "b@x.io"])
// the expectation IS the assertion const gateway = mock<Payment>() expect(gateway.charge).toHaveBeenCalledWith(2000, "usd") // the test fails if charge() wasn't called exactly so
// working logic, just not production-grade class InMemoryUserRepo implements UserRepo { private rows = new Map() save(u) { this.rows.set(u.id, u) } findById(id) { return this.rows.get(id) } }
A green 100% can still ship bugs, and a flaky suite is worse than no suite. Knowing what not to test is as important as knowing what to.
test("runs without error", () => { calculateTax(order) // 100% line coverage… }) // no assertion → tax could be -∞ and this stays green.
test("charges 8% tax, rounded to the cent", () => { expect(calculateTax({ subtotal: 49.99 })) .toBe(4.00) }) // same line covered — but now it actually checks the rule.
A flaky test passes and fails without any code change. Each false alarm teaches the team to ignore red — and a suite nobody trusts is dead weight.
Date.now(), timezones, sleeps. Inject a clock.sleep(500) instead of waiting for a condition.Like a smoke alarm that chirps at random — people rip the battery out, and then it can't warn you.
Test observable behavior at a stable boundary: given these inputs and this state, the unit produces this output or this visible effect. If a correctness-preserving refactor turns a test red, that test was checking how, not what.
Aim coverage at a floor that catches obvious gaps (say 70–80% on changed code), then spend the saved energy on better assertions and edge cases — boundaries, empties, errors — not chasing the last untestable percent.
The tools split along the pyramid. A test runner is the workhorse for the unit and integration layers; an end-to-end (E2E) framework drives a real browser for the thin top. You generally pick one of each and move on — don't agonize.
The same runner usually covers your unit and integration tests; an E2E framework sits on top for the few full-journey tests.
The long-time default for JS/TS. One install gives you the runner, assertions, mocking, and coverage together.
The modern challenger, built on the Vite bundler. Its API mirrors Jest, so moving over is mostly a find-and-replace.
The de-facto standard for Java (JUnit 5). Every IDE and build tool (Maven, Gradle) speaks it natively.
The go-to for Python. You write a plain assert and it produces a detailed failure message for you.
Microsoft's browser-automation tool. It controls the browser from the outside and waits for elements automatically, which keeps tests steadier.
Runs your test inside the browser, with a live time-travel view of every step — loved for its developer experience.
The most dangerous test is the one that asserts the current wrong behavior. It turns a bug into a "requirement" nobody dares to change. Assert the intended behavior.
// the code is wrong; the test "documents" the wrong number test("shipping for 0 items", () => { expect(shippingFee([])).toBe(5) }) // empty carts shouldn't ship at all — but now green // "proves" the bug. Fixing it breaks the suite.
// a failing test that names the intended behavior test("empty cart has no shipping fee", () => { expect(shippingFee([])).toBe(0) }) // red now → fix shippingFee → green. The test is the spec.
The regression-test rule: every bug fix starts with a test that fails because of the bug. Reproduce, then fix — so it can never come back unnoticed.
"Test until fear turns to boredom."
— Kent Beck
Five quick questions on the pyramid, AAA, TDD, test doubles, and coverage — instant feedback, no sign-in.
Navigate with ← → or scroll · Part 2: Advanced Testing → · back to library