Skip to content
Software Atlas

Why & What to Test

What tests buy you, what makes a test good, unit vs integration vs end-to-end, the pyramid vs the trophy, what not to test, coverage and its limits, regression tests, TDD honestly — and what a bad test suite really costs.

⏱ ~40 min read13 sections9 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

You change one line. Did you just break checkout? Without tests, the honest answer is "I don't know until a customer tells me." Tests turn that into a ten-second answer.

In one sentenceA test is a small program that checks your program. It runs your code with a known input and complains if the output isn't what you expected — so you can change code without fear.

A test calls one of your functions, compares the result with the answer you wrote down, and reports pass or fail. A tool called a Test runner finds all your tests, runs them in seconds and prints the results. All the tests of a project together are its Test suite.

1

Confidence to change

With good tests, you can rename, restructure and upgrade, then press one key to learn whether anything broke. Without them, every change is a gamble, so people stop improving the code.
2

Living documentation

A test named "SAVE10 gives 10% off at exactly $50" tells the next developer the rule — and unlike a comment, it fails the moment it stops being true.
3

Design feedback

If a function is painful to test (it needs a database, the clock and three globals), that pain is telling you the function does too much.

But tests aren't free. A suite that is slow, flaky (fails at random) or brittle (breaks on every harmless change) costs more than it saves: people wait for it, re-run it, and learn to ignore it. This topic is about which tests are worth writing, and what makes them good:

§You'll learn
02–03What a test looks like in Go and JavaScript, then break real code and watch tests catch it
04What makes a test good: fast, deterministic, checks behaviour
05–07Unit vs integration vs end-to-end, the test pyramid vs the testing trophy, and what not to test
08–10Coverage and its limits, regression tests and names, TDD honestly
11What a bad test suite really costs a team
Foundations§02

Anatomy of a test

We'll use one small function through the whole topic: the total of a shopping cart. Prices are stored in cents (whole numbers), because decimal numbers like 0.1 can't be stored exactly in binary and money must add up exactly.

The rules: add up price × quantity for every item. The coupon SAVE10 gives 10% off, but only for orders of $50 or more (5000 cents).

cart/cart.go
package cart

type Item struct {
	PriceCents int
	Qty        int
}

func Total(items []Item, coupon string) int {
	total := 0
	for _, item := range items {
		total += item.PriceCents * item.Qty
	}
	if coupon == "SAVE10" && total >= 5000 {
		total -= total / 10 // 10% off orders of $50+
	}
	return total
}

New to Go? The Go primer in Async / Await §02 covers func, := and for … range. In Go, total / 10 on two ints already rounds down (it drops the remainder); in JavaScript we need Math.floor for the same result.

The tests

Now two tests: one for the adding-up rule, one for the coupon. Read the comments in the first test — they mark the three parts every test has.

cart/cart_test.go
package cart

import "testing"

func TestTotalAddsPriceTimesQty(t *testing.T) {
	// Arrange (given): a cart with two lines
	items := []Item{
		{PriceCents: 1000, Qty: 2},
		{PriceCents: 500, Qty: 1},
	}
	// Act (when): we compute the total
	got := Total(items, "")
	// Assert (then): it is 2 × $10 + $5
	if got != 2500 {
		t.Errorf("Total() = %d, want 2500", got) // ← marks the test as failed
	}
}

func TestTotalSave10AtExactly50(t *testing.T) {
	got := Total([]Item{{PriceCents: 2500, Qty: 2}}, "SAVE10")
	if got != 4500 {
		t.Errorf("Total() = %d, want 4500", got)
	}
}
Every test has the same three beats
1. Arrange · Given
Set up the situation
items = [2 × $10, 1 × $5]
2. Act · When
Do the one thing you're testing
got = cartTotal(items)
3. Assert · Then
Check the result
got should be 2500
Two names for the same idea: Arrange–Act–Assert (common in code) and Given–When–Then (common when talking about behaviour). If a test doesn't have a clear "Act", it's probably testing too many things at once.

Running them

$ go test ./...
ok  	example.com/shop/cart	0.002s

./... means "this folder and every folder below it". example.com/shop/cart is the package's full name: the module path from the project's go.mod file plus the folder cart. Green and quiet is what you want. Now the interesting part: we change >= to > in the code and run again (real output from Go 1.24 and Vitest 5.0.5, measured on this machine; newer versions print the same thing, give or take formatting):

$ go test ./...
--- FAIL: TestTotalSave10AtExactly50 (0.00s)
    cart_test.go:22: Total() = 5000, want 4500
FAIL
FAIL	example.com/shop/cart	0.002s

A good failure message answers three questions at once: which rule broke (the test name), what happened (got 5000) and what should have happened (want 4500). Object.is equality just means toBe compares with strict sameness, like ===. Below the diff, Vitest prints a code frame: the file and position (cart.test.js:18:65 = line 18, column 65), a few lines of your source, and a ^ under the exact failing call. In Go, the cart_test.go:22 prefix plays the same role.

Foundations§03

What tests buy you

In one sentenceA test suite is a net of tripwires. Each test guards one rule; when a change breaks that rule, that exact wire goes off and its name tells you what broke.

Below is our cartTotal from §02 (the JavaScript version) with five tests this time, and five realistic bugs you can introduce. Each bug changes one line — the kind of slip anyone makes on a Friday afternoon. Pick a bug, run the tests, and see which tripwires fire.

Break the code, run the tests
Pick a version of cartTotal — the correct one or one with a bug — then run the five tests. They really execute, in your browser.
1function cartTotal(items, coupon) {
2 let total = 0;
3 for (const item of items) {
4 total += item.priceCents * item.qty;
5 }
6 if (coupon === "SAVE10" && total >= 5000) {
7 total -= Math.floor(total / 10); // 10% off
8 }
9 return total;
10}

This is the code as it should be. Every test should pass.

cart.test.jsvitest run
· returns 0 for an empty cart
expect(cartTotal([])).toBe(0)
· adds up price × quantity
cart of 2 × $10 + 1 × $5 → 2500
· SAVE10 gives 10% off at exactly $50
2 × $25 with SAVE10 → 4500
· SAVE10 is ignored below $50
$49.99 with SAVE10 → 4999
· unknown coupons are ignored
$60 with BOGUS → 6000

Try every bug and you'll notice a pattern:

  • Bug 1 (>= became >) is only caught by the test for exactly $50. That input is a Boundary value: right at the edge of a rule. Off-by-one bugs hide at boundaries, so boundaries get their own tests.
  • Bugs 4 and 5 are each caught by exactly one test. Delete "unknown coupons are ignored" and Bug 4 ships. Every test earns its place by guarding something no other test guards.
  • Bugs 1 and 3 are also caught by exactly one test — the same one, "exactly $50". Delete that single test and both ship. One well-chosen boundary test can guard several rules at once.
  • "returns 0 for an empty cart" catches none of these five bugs. It still documents an edge case, but it shows that test count says little; what matters is which rules are checked.

What you actually get

1. Confidence to change. Say you want to rewrite the loop, or upgrade a library, or move this code into another file. Without tests you'd click through checkout a few times and hope. With them, you run the Test suite and know within seconds. That's what makes Refactoring — improving structure without changing behaviour — safe enough that people actually do it.

2. Documentation that can't go stale. Read the five test names in the widget again: they are the spec of cartTotal. A comment saying "10% off at $50+" can drift from the code for years. A test saying it fails on the next run.

3. Design feedback. Testing cartTotal is easy because it is a Pure function: data in, number out, nothing else touched. If the price came from a database call inside the function, every test would need a database. That friction is a hint to pass the data in instead — the idea behind dependency injection.

4. Bugs caught early, when they're cheap. A bug caught by a test on your laptop costs a minute. The same bug found by a customer costs a support ticket, an investigation, a hot-fix, a deploy and some trust.

Foundations§04

What makes a good test

In one sentenceA good test fails when the behaviour breaks — and only then. Every other kind of failure is noise that teaches people to ignore red.

A test can be wrong in two directions. It can stay green while the code is broken (a missed bug), or go red while the code is fine (a False alarm). The second sounds harmless. It isn't: after a few false alarms, people stop reading failures and just re-run or delete the test.

The four properties

1

Fast

Milliseconds, not seconds. Fast tests get run on every save; a suite that takes 20 minutes gets run "later", which means after the bug is already in.
2

Deterministic

Same code, same result, every time. A deterministic test doesn't read the real clock, random numbers or the network. One that sometimes fails is a Flaky test.
3

Checks behaviour

It asserts what callers can observe (outputs, saved data, errors), not how the code gets there. Then you can rewrite the inside freely.
4

Isolated

Each test sets up its own data and doesn't depend on another test having run first. That's Test isolation; without it, tests pass alone but fail together, or the other way round.

Isolation problems are one of the main causes of flaky tests; Flaky Tests digs into them.

Behaviour vs implementation

Behaviour is what the code does as seen from outside: for this cart, the total is 2500. An Implementation detail is how it does it: it calls a helper named lineTotal once per item. Users only care about the first. Tests that check the second break every time you tidy up.

Here are two tests for the same small cart object. Test A uses a Spy to replace the lineTotal helper and counts how often it's called. Test B just checks the answer. Change the code and watch where each test lands:

Which test survives?
Change the code, then see where each test lands. Test A spies on a helper; Test B only looks at the answer.
cart.js
export const cart = {
  lineTotal(item) {
    return item.priceCents * item.qty;
  },
  total(items) {
    let sum = 0;
    for (const item of items) sum += cart.lineTotal(item);
    return sum;
  },
};
cart.test.js
import { it, expect, vi, afterEach } from "vitest";
import { cart } from "./cart.js";

const items = [{ priceCents: 1000, qty: 2 }, { priceCents: 500, qty: 1 }];
afterEach(() => vi.restoreAllMocks()); // put the real lineTotal back

// ✕ Test A checks HOW total works
it("calls lineTotal once per item", () => {
  const spy = vi.spyOn(cart, "lineTotal").mockReturnValue(100);
  expect(cart.total(items)).toBe(200);
  expect(spy).toHaveBeenCalledTimes(2);
});

// ✓ Test B checks WHAT total returns
it("adds up price × quantity", () => {
  expect(cart.total(items)).toBe(2500);
});

Nothing changed yet. Both tests pass.

✓ Test A: calls lineTotal once per item
✓ Test B: adds up price × quantity
test passes
test fails
code fine
✓ Correct pass
Test ATest B
⚠ False alarm
code broken
✕ Missed the bug
✓ Caught the bug

Look at the matrix for each change:

  • Refactor (either one): Test A lands on ⚠ false alarm. Nothing a user could notice changed, but the build is red.
  • Real bug: Test A lands on ✕ missed bug. It replaced lineTotal with a fake, so the broken lineTotal never ran. Test B catches it.

So Test A is worse in both directions: it screams when nothing is wrong and stays silent when something is. That's the typical result of testing implementation details.

The same mistake in Go usually looks like a test reading a private field:

cart.go + cart_test.go (shown together)
type Cart struct {
	lines []Item // lowercase = unexported: only this package can see it
}

func (c *Cart) Add(it Item) { c.lines = append(c.lines, it) }
func (c *Cart) Total() int  { return Total(c.lines, "") }

// ✕ Checks HOW the cart stores things
func TestAddAppendsToLines(t *testing.T) {
	c := &Cart{}
	c.Add(Item{PriceCents: 1000, Qty: 2})
	c.Add(Item{PriceCents: 1000, Qty: 1})
	if len(c.lines) != 2 { // ← reads a private field
		t.Errorf("len(lines) = %d, want 2", len(c.lines))
	}
}

// ✓ Checks WHAT the cart does
func TestCartTotal(t *testing.T) {
	c := &Cart{}
	c.Add(Item{PriceCents: 1000, Qty: 2})
	c.Add(Item{PriceCents: 1000, Qty: 1})
	if got := c.Total(); got != 3000 {
		t.Errorf("Total() = %d, want 3000", got)
	}
}

Both tests add the same $10 item twice. Now say tomorrow someone improves Cart so that adding an item that's already in the cart just raises its quantity: one line with Qty: 3 instead of two lines. The first test breaks (len(lines) = 1, want 2) and the second doesn't, because the total is still 3000. Nothing a caller can see changed.

Foundations§05

Unit, integration, end-to-end

In one sentenceThe levels differ in how much real code runs in one test. More real code means more realism — and more time, more flakiness, and vaguer failures.

Back to the building from §01. Picture a checkout feature with four parts: a browser page with a "Pay" button, an HTTP handler on the server that receives the request, our cartTotal() logic, and a database that stores the order. Each kind of test switches on a different set of those parts.

One word you'll see in the widget: CI (continuous integration) — a server that builds your code and runs the tests automatically on every push, so nobody has to remember to.

What does each kind of test run?
Same checkout feature, three kinds of test. Lit boxes are real code running in the test; dim boxes aren't involved at all.
Browser page
checkout button
HTTP handler
POST /checkout
cartTotal()
pure logic
Database
orders table
🔔
Smoke detector
One small device, one room, always on, beeps within seconds. It can't tell you the fire exits are blocked.
Speed
~1 ms each (approx.)
Usually run
on every save
Catches
Wrong maths, missed edge cases, broken rules inside one function.
Misses
Wrong SQL, a handler that never calls cartTotal, a button wired to the wrong URL.
When red
Points at one function. You usually know the bug's line within seconds.
Deep dive
Unit tests →

The same bug, three ways

Remember Bug 1 from §03 (>= became >). All three levels could catch it:

Unit testIntegration testEnd-to-end test
What it doesCalls cartTotal with 2 × $25 and SAVE10Sends POST /checkout to the real handler, with a real database started just for the testOpens a real browser, adds two $25 items, types SAVE10, clicks Pay
Time (approx.)~1 ms~50–500 ms~5–20 s
When it fails, it says"cartTotal returned 5000, want 4500""response total was 50.00, want 45.00""expected text 'Total: $45.00', got 'Total: $50.00'"
Where's the bug?Right thereSomewhere in handler, logic or SQLAnywhere from the button to the database

For this bug the unit test is the clear winner: 10,000× faster, and the message points at the line. But a unit test can't notice that the handler forgot to call cartTotal at all, or that the "Pay" button posts to the wrong URL. Those bugs live between the parts, and only bigger tests can see between parts.

A few more words you'll hear

  • A Test double is a stand-in for a real part — e.g. a fake payment client that records charges instead of making them. Unit tests use them to avoid slow or unpredictable parts. Mocks, Stubs & Fakes covers when that helps and when it lies.
  • A Contract test checks that two services still agree on the shape of the messages between them, without running both at once.
  • Integration tests usually get their real database from Docker, which starts a fresh copy in a few seconds and throws it away afterwards.
  • "Unit" doesn't have to mean "one function". Many teams treat a small group of functions that work together, with no network, disk or database as one unit. The line people care about is: does this test need real infrastructure?
Practitioner§06

Pyramid vs trophy

In one sentenceThere's no perfect ratio. Each kind of test trades speed and precision against realism; the shape of your suite is how you spend that budget.

The classic answer is the Test pyramid: a wide base of unit tests, a smaller middle of integration tests and a thin top of end-to-end tests. A popular alternative is the Testing trophy, which argues that integration tests give the most confidence per test and should be the biggest group, with static checks (types and a Linter) underneath catching typos for free.

Three shapes of test suite
Test pyramid
Lots of unit tests, fewer integration, a handful of E2E.
Testing trophy
Static checks at the base, a fat middle of integration tests.
Ice-cream cone
The anti-pattern: mostly slow E2E and manual tests.
Width = how many tests of that kind. Top = slow, realistic, few; bottom = fast, narrow, many. The cone isn't a strategy anyone picks — it's what a codebase drifts into when tests are only added by clicking through the app.

Why the pyramid

Unit tests are fast (milliseconds) and Deterministic, and a failure points at one function. So you can have thousands, run them on every save, and trust a red result. The pyramid says: push each check down to the lowest level that can catch it.

The risk: lots of green unit tests while the parts don't fit together. Each piece works alone; the handler still sends cents where the page expects dollars.

Why the trophy

Much modern code — a typical web backend, a React app — has little tricky logic. Most of its bugs are in the wiring: the SQL, the JSON shape, the React component that forgot to pass a value down to its child. Unit tests with every collaborator faked can't see those. The trophy says: test the way the software is used, mostly through real collaborators, and keep unit tests for genuinely tricky logic.

The risk: integration tests are slower, and when one fails, the bug could be in any of several parts.

Play with the trade-off

The calculator below uses a toy model: each kind of test has an assumed run time, an assumed chance of failing randomly, and an assumed reach for three kinds of bugs. The numbers are illustrative — compare the shapes, don't quote the figures.

Shape your test suite
Drag the sliders or pick a shape. A toy model with made-up but realistic-feeling numbers — compare shapes, don't quote the figures.
CI time
one after another: 1.8 min · split over 8 machines: 13 s
Red build, no real bug
9.1% of runs fail only because some test was flaky
Confidence per kind of bug
Logic bugs · wrong maths, edge cases100%
Wiring bugs · SQL, JSON, config, parts that don't fit59%
Journey bugs · the page or flow a user clicks through39%

Things to try:

  • Pyramid: fast (under 2 minutes) and very good on logic bugs, but weak on wiring and journey bugs. Even here about 1 build in 11 (9%) goes red for no reason — the 80 integration tests and 10 E2E tests supply almost all of that.
  • Trophy: double the CI time and double the false-red rate (about 20%, 1 build in 5), but far better on wiring bugs — the bugs most web apps actually have. More realistic tests buy confidence and pay for it in flakiness; Flaky Tests is about shrinking that bill.
  • Ice-cream cone: catches journey bugs, but CI takes ~16 minutes when the tests run one after another and nearly half the runs go red because of a flaky end-to-end test. Teams in this state stop trusting CI.
Practitioner§07

What not to test

In one sentenceEvery test is code you must run and maintain forever. Write one where a bug is likely or expensive; skip it where the test would only repeat the code.

A useful rough score for any piece of code: how likely is a bug here × how bad would it be? A discount rule with a boundary scores high on both. A getter that returns a field scores near zero on the first. Make the call for each card:

Test it or skip it?
For each piece of code, make the call. The answer and the reasoning appear after you choose.
0/8
The SAVE10 rule: 10% off orders of $50 or more
A getter: func (u User) Name() string { return u.name }
That Postgres returns rows in ORDER BY order
What checkout does when the payment API times out
const MaxRetries = 3
A bug you fixed this morning
A private helper that your exported function calls
Parsing a date or money amount typed by a user

The usual "skip" list

  • Code you didn't write. The Go standard library, React, Postgres and your HTTP framework have their own tests. Test your use of them (your query, your component), not their promises.
  • Code with no decisions in it. Getters, setters, a struct with fields, a function that only passes its arguments on. A test of it re-states the code. It still runs as part of other tests anyway.
  • Constants and configuration values. expect(MAX_RETRIES).toBe(3) changes whenever the code changes and catches nothing. Test the behaviour that depends on it ("gives up after the last retry").
  • Private helpers, directly. Test them through the exported function that uses them, so you can rename, merge or delete helpers freely (the §04 rule).
  • Generated code. Code produced by a tool — types generated from Protocol Buffers message definitions, the model classes an ORM writes for you — is tested by the generator's authors.
  • Exact looks. Asserting a button's exact pixel colour or a whole HTML blob breaks on every design tweak. If looks really matter, use a dedicated visual or Snapshot test setup and review the diffs on purpose.

The usual "definitely test" list

  • Business rules and their boundaries — prices, permissions, limits, dates.
  • Error paths — the timeout, the missing record, the invalid input. They're rarely hit when clicking around, so bugs survive there longest.
  • Parsing and formatting of anything a human typed or another system sent.
  • Every bug you fix — as a Regression test (§09).
  • Code you're afraid to touch. That fear is the signal. A few tests around it (even rough ones that just record what it does today) make it safe to change.

When code is hard to test, move the logic out

Often the reason something "can't be tested" is that the logic is tangled with the hard part. Here the discount rule is buried inside an HTTP handler that reads a database. (It is an Express-style route: app.post(path, fn) runs fn for every POST request to that path; req is the request, res the response.)

✕ Logic buried in the handler
app.post("/checkout", async (req, res) => {
  const items = await db.cartItems(req.userId);
  let total = 0;
  for (const i of items) total += i.priceCents * i.qty;
  if (req.body.coupon === "SAVE10" && total >= 5000)
    total -= Math.floor(total / 10);
  await db.saveOrder(req.userId, total);
  res.json({ total });
});
// To test the discount you need HTTP + a database.
✓ Thin handler, pure logic
app.post("/checkout", async (req, res) => {
  const items = await db.cartItems(req.userId);
  const total = cartTotal(items, req.body.coupon); // ← pure
  await db.saveOrder(req.userId, total);
  res.json({ total });
});
// cartTotal gets fast unit tests.
// The handler gets one or two integration tests.

The rule lives in a pure function with many quick unit tests. The handler is left so simple ("load, compute, save, reply") that one or two integration tests cover it. Clean Architecture builds a whole design style around this, and Unit Testing shows more ways to structure code so it's easy to test.

Practitioner§08

Coverage, and what it can't tell you

In one sentenceCoverage tells you which code your tests ran. It cannot tell you whether they checked anything. Low coverage is a useful warning; high coverage is not a guarantee.

Code coverage works like a highlighter: while the tests run, the tool marks every line that executes. Afterwards it reports the share of lines that got marked. Branch coverage is stricter: for every decision it asks "did the tests take every way through it?". For our if (coupon === "SAVE10" && total >= 5000) that means four branches, counted the way Vitest's report counts them:

  • the if went true once, and false once;
  • each side of && ran at least once. The left side always runs; the right side (total >= 5000) only runs when the coupon is "SAVE10", because && stops early when the left side is false.

Loops don't count as branches in these reports. Go's built-in go test -cover has no branch measure at all: it reports statements only.

The widget runs the real test cases against our cartTotal and records lines and branches exactly that way. It also runs the same tests against the five bugs from §03, so you can compare coverage with bugs actually caught:

Coverage vs. catching bugs
Tick tests to include. Green lines ran at least once; red lines never ran. Then look at the bottom-right box: how many of the five bugs from §03 would these tests catch?
1function cartTotal(items, coupon) {
2 let total = 0;
3 for (const item of items) {
4 total += item.priceCents * item.qty;
5 }
6 if (coupon === "SAVE10" && total >= 5000) {
7 total -= Math.floor(total / 10); // 10% off
8 }
9 return total;
10}
line 6: if → true ✗line 6: if → false ✓line 6: && left side ran ✓line 6: && right side ran ✗
Tests included
Line coverage
83%
5 of 6 statements ran
Branch coverage
50%
2 of 4 paths taken
Bugs these tests catch1 / 5
missed Bug 1: >= became >
caught Bug 2: forgot the quantity
missed Bug 3: 1% instead of 10%
missed Bug 4: any coupon works
missed Bug 5: forgot the $50 minimum

Click the presets in order:

  1. Only "checks nothing": one test that calls cartTotal and has no assertion. Every line runs — 100% line coverage, 75% branch coverage (the if never went false) — and it catches 0 of 5 bugs.
  2. Add "empty cart": with no coupon, the if also goes false. 100% line and 100% branch coverage. Still 0 of 5 bugs.
  3. The five real tests: same 100% / 100%, and now 5 of 5. The coverage numbers didn't move at all; the protection went from nothing to everything.

That's real tool behaviour, not a quirk of the widget. Here is preset 2 as one real test — it calls Total with a $60 cart and with an empty one, and checks nothing. Below it are the real coverage reports (measured on this machine). Go's is run twice: on the correct code, then after we break the discount to 1%.

cart_test.go
// Runs every line. Checks nothing.
func TestTotalRuns(t *testing.T) {
	Total([]Item{{PriceCents: 6000, Qty: 1}}, "SAVE10")
	Total(nil, "")
}
$ go test -count=1 -cover ./cart
ok  	example.com/shop/cart	0.002s	coverage: 100.0% of statements

# …now break the discount: total / 10  →  total / 100
$ go test -count=1 -cover ./cart
ok  	example.com/shop/cart	0.002s	coverage: 100.0% of statements

-cover asks go test to measure coverage; -count=1 forces a fresh run instead of reusing a cached result. In Vitest, --coverage needs the @vitest/coverage-v8 package installed. Total(nil, "") passes nil, Go's "nothing" value. A nil slice has length 0, so range over it does nothing and the total stays 0.

So what is coverage good for?

Finding what's untested. The red lines are the useful output. A red catch block or a red if (err != nil) branch says "no test has ever seen this error path" — go and write one. Most tools can show this as a coloured HTML page: in Go, go test -coverprofile=cover.out ./... then go tool cover -html=cover.out; in Vitest, the HTML coverage report it writes to coverage/.

Not as a target. When a team must hit "90% coverage" to merge, people write tests like "checks nothing" to make the number go up. A measure that becomes a target stops measuring anything (this is often called Goodhart's law).

Practitioner§09

Regression tests and good names

In one sentenceWhen you fix a bug, first write a test that fails because of it. Then fix the code. The test stays forever, so that bug can only ship once.

A Regression test guards against a regression: something that used to work (or was fixed) breaking again. Bugs come back more often than you'd think — someone tidies the code, reverts a commit, or copies the old version into a new place. Step through the workflow:

Turning a bug report into a regression test
Reproduce → fail → fix → pass → keep it forever.
Code
1// Bug #482: "SAVE10 didn't work on my $50.00 order"
2it("applies SAVE10 at exactly $50 (bug #482)", () => {
3 const items = [{ priceCents: 2500, qty: 2 }];
4 expect(cartTotal(items, "SAVE10")).toBe(4500);
5});
cart.js, line 6
if (coupon === "SAVE10" && total > 5000)
test run
(no test for this yet)
outside world / CI
🐞 Customer email: "$50.00 order, SAVE10, charged $50.00"
A bug report arrives. The code on line 6 of cart.js uses >, so exactly $50.00 doesn't qualify. Don't fix it yet. (The code panel shows the test we're about to write in the next step.)
1 / 6

Why "fail first" matters

It's tempting to fix the bug and then write a test. But a test written after the fix has never been seen failing. Maybe it uses a cart that doesn't trigger the bug (say $60 instead of exactly $50) and would have passed with the old code too. Watching it go red first, with the message you expected, proves it really can see the bug.

The same reason applies to every new test you write, not just regression tests: if you've never seen it fail, you don't know that it can.

Names that explain the failure

When a test fails in CI, its name is often the only thing a teammate sees at first. Compare:

✕ Names that say nothing
× test1
× TestTotal2
× it works
× discount test
✓ Names that state a rule
× SAVE10 gives 10% off at exactly $50
× unknown coupons are ignored
× returns 0 for an empty cart
× rejects a negative quantity

On the right, you know which rule broke before opening a single file. A good name says the situation and the expected outcome, which is exactly the Given–When–Then shape from §02 in one line.

cart_test.go
// Go: the function name is the test name — keep it a short summary…
func TestTotalSave10(t *testing.T) {
	// …and use t.Run for a sentence per case.
	t.Run("gives 10% off at exactly $50", func(t *testing.T) {
		if got := Total([]Item{{PriceCents: 2500, Qty: 2}}, "SAVE10"); got != 4500 {
			t.Errorf("Total() = %d, want 4500", got)
		}
	})
	t.Run("is ignored below $50", func(t *testing.T) {
		if got := Total([]Item{{PriceCents: 4999, Qty: 1}}, "SAVE10"); got != 4999 {
			t.Errorf("Total() = %d, want 4999", got)
		}
	})
}

Some teams write names in an explicit Given–When–Then style: "given a $50 cart, when SAVE10 is applied, then 10% is taken off". It's wordier, but useful when the setup really matters. Pick one style per codebase and stick to it.

Practitioner§10

TDD, honestly

In one sentenceTDD means writing the test before the code, in tiny loops: a failing test (red), the simplest code that passes (green), then tidy up (refactor). It's a tool for some jobs, not a religion.

TDD flips the usual order. Instead of "write code, then maybe test it", each small piece of behaviour starts life as a failing test. Watch one short session. We're building slugify, which turns a title into a Slug like hello-world:

One TDD session, step by step
Building slugify("Hello, World!") → "hello-world" (the readable part of a page URL). The test results are computed live from the code shown.
REDGREENREFACTOR
slugify.jsstep 1 / 9
export function slugify(title) {
  // not written yet
}
slugify.test.jswrite a failing test
× lowercases
slugify("Hello") → "hello"
expected undefined to be "hello"
Write the first test before any code: slugify("Hello") should be "hello". Run it. It fails (the function returns nothing). Seeing it fail proves the test can fail.

The rhythm in the widget:

  1. Red: write one small test for the next bit of behaviour. Run it, see it fail, and check the failure message is the one you expect.
  2. Green: write the least code that makes it pass — even if it looks naïve.
  3. Refactor: improve names and structure while all tests stay green. This is the step people skip, and it's where the design actually gets better.

Here is the finished result in both languages. The Go version uses a table of cases — the usual Go style (Unit Testing explains table-driven tests in detail):

slug.go + slug_test.go
var nonSlug = regexp.MustCompile(`[^a-z0-9 ]`)

func Slugify(title string) string {
	s := strings.ToLower(title)
	s = nonSlug.ReplaceAllString(s, "") // keep letters, digits, spaces
	return strings.Join(strings.Fields(s), "-")
}

func TestSlugify(t *testing.T) {
	cases := []struct{ in, want string }{
		{"Hello", "hello"},
		{"Hello World", "hello-world"},
		{"Hello, World!", "hello-world"},
		{"  Go   is fun ", "go-is-fun"},
	}
	for _, c := range cases {
		if got := Slugify(c.in); got != c.want {
			t.Errorf("Slugify(%q) = %q, want %q", c.in, got, c.want)
		}
	}
}

Where TDD shines

  • Well-understood logic with clear inputs and outputs: parsers, pricing, validation, state machines. The tests are easy to write first because you already know the answers.
  • Bug fixes: "write the failing test first" from §09 is TDD, applied to one bug.
  • Design pressure: writing the call before the function exists makes you design it from the caller's side. Awkward-to-call functions show up immediately.

Where it struggles

  • Exploring. When you don't yet know what you're building — a UI layout, a new library, a prototype — tests written first get thrown away with the code. Many people write a quick throw-away experiment first (often called a spike), then rebuild it test-first once the shape is clear.
  • Code that's mostly wiring. For "read from the database, call an API, render" there's little logic to drive; an integration test written after is often more useful than ten mocks written before.
  • As a rule for everyone, always. Teams that mandate strict TDD everywhere tend to get rituals; teams that never try it miss its best use cases.
Staff-level§11

The cost of a bad test suite

In one sentenceA test suite is a product your team uses many times a day. If it's slow, flaky or brittle, people route around it — and a suite nobody trusts is worse than a small one everybody does.

Tests have a running cost that never shows up on the day you write them. Here are the four ways a suite goes bad, from most to least visible.

1. Flaky: red that doesn't mean anything

A Flaky test fails sometimes with no code change. One flaky test looks harmless. A suite of them isn't, because the build is green only if every test passes.

Worked example: 500 tests, each flaking on just 0.1% of runs (1 in 1,000). The chance all 500 pass is 0.999500 ≈ 0.61. So about 39% of builds are red for no reason. Play with it:

The flake tax
Each test is green almost every time. What happens to the whole build?
Chance a run is red with no bug
39.4%
1 − (1 − 0.001)500
Every day
≈ 15.7 false-red builds → about 3.9 h of waiting for re-runs
At this level people stop believing red. They click "re-run" without reading — and one day a real failure gets re-run into green.

The real cost isn't the re-run minutes. It's what people learn: red usually means nothing. They click "re-run" without reading, and one day a real failure gets re-run until it's green by luck. Flaky Tests covers causes, diagnosis and quarantine.

2. Slow: the feedback loop stretches

A suite that takes 2 seconds runs on every save. One that takes 20 minutes runs when you push, so you've already switched to something else when it fails, and coming back costs another 15 minutes of context. Developers start batching changes into bigger commits, which makes failures harder to pin down. Slowness is a tax on every single change.

Typical causes: end-to-end tests doing a unit test's job (§05), tests that sleep instead of waiting for a condition, every test rebuilding a database from scratch, and nothing running in parallel.

3. Brittle: every refactor breaks 30 tests

Tests coupled to implementation details (§04) fail whenever the inside changes. Each refactor then costs an afternoon of "fixing" tests that found no bug. Teams respond rationally: they stop refactoring. The tests meant to make change safe end up making change expensive.

A tell-tale sign: in a Pull request that changes no behaviour, half the diff is test files.

4. Falsely reassuring: green that doesn't mean anything

The quietest failure. Tests with weak assertions (expect(result).toBeDefined()), tests that mock away the very thing they claim to test, coverage padded to hit a target (§08). Everything is green, the dashboard says 92%, and bugs still reach users. Nobody notices until the meeting after an outage (an incident review) asks "why didn't a test catch this?".

🎲
FlakySmall per-test flake rates multiply across a suite. 500 × 0.1% ≈ 39% red builds.
🐢
SlowSlow suites run less often, so bugs are found later and further from their cause.
🧱
BrittleTests that check implementation make refactoring expensive, so people stop doing it.
🙈
Falsely greenWeak assertions and over-mocking give confidence without protection.
Practitioner§12

Check yourself

Commit to an answer before you click. Every one is covered above: Q1 in §03, Q2 in §08, Q3 in §04, Q4 in §11, Q5 in §09 and Q6 in §10.

QUESTION 1 / 6
Which tests fail?

The suite has three tests: (1) 2 × $10 + 1 × $5 → 2500, (2) 2 × $25 with SAVE10 → 4500, (3) $60 with BOGUS → 6000. Someone changes one line:

// before
if (coupon === "SAVE10" && total >= 5000) {
// after
if (coupon === "SAVE10" && total > 5000) {
QUESTION 2 / 6
100% coverage

A team's CI reports 100% line coverage. A bug in the discount maths still reaches production. How is that possible?

QUESTION 3 / 6
The red build after a rename

You rename a private helper from lineTotal to subtotal. The app behaves exactly as before. One test now fails because it spied on lineTotal. What is this failure?

QUESTION 4 / 6
Do the maths

A suite has 400 tests. Each one, on its own, fails at random 0.2% of the time (1 run in 500), even with no bug. Roughly how often is the whole build red for no reason?

QUESTION 5 / 6
A bug report arrives

A customer reports that SAVE10 didn't apply to their $50.00 order. What should you do first?

QUESTION 6 / 6
Why start with red?

In TDD you run a new test before writing the code, and watch it fail. Why bother?

Answered 0 / 6
Foundations§13

Cheat sheet

🛟
Why testConfidence to change, documentation that can't go stale, design feedback, and bugs caught while they're cheap.
✅
A good testFast, deterministic, isolated, and checks behaviour — fails when the behaviour breaks, and only then.
🧩
Arrange · Act · AssertGiven a situation, when you do one thing, then check the result. One clear "Act" per test.
🔔
Three levelsUnit = smoke detector. Integration = building inspection. End-to-end = fire drill. More realism costs speed and precision.
🔺
Pyramid or trophyPush each check to the lowest level that can catch it. Logic-heavy → pyramid; wiring-heavy → trophy. E2E only for critical journeys.
🚫
Don't testLibraries, getters, constants, generated code, private helpers directly, exact pixels.
📊
CoverageShows what ran, not what was checked. Read the red lines; don't make the number a target.
🐞
Every bug → a testReproduce it in a test, watch it fail, fix, watch it pass, keep it forever.
🔴
TDDRed → green → refactor. Great for clear logic and bug fixes; spike first when exploring. Always see a test fail once.
💸
Bad suites costFlaky, slow, brittle, falsely green. Measure CI time, flake rate and escaped bugs.
I want to…Do this
Know if my change broke anythingRun the suite: go test ./... or npx vitest run
Test a rule like "10% off at $50+"Unit tests at, just below and just above the boundary
Check my SQL or HTTP handlerAn integration test with a real database — Integration Testing
Protect the sign-up or payment flowA few end-to-end tests — End-to-End Testing
Find untested codego test -coverprofile=cover.out ./... then go tool cover -html=cover.out, or vitest run --coverage
Check my tests actually assert thingsBreak a line on purpose (or run a mutation-testing tool) and see if a test fails
Fix a bug for goodFailing test first, then the fix, committed together
Replace a slow or unpredictable dependencyA test double — Mocks, Stubs & Fakes
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in TestingUnit Testing
Read →