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.
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.
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.
Confidence to change
Living documentation
Design feedback
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–03 | What a test looks like in Go and JavaScript, then break real code and watch tests catch it |
| 04 | What makes a test good: fast, deterministic, checks behaviour |
| 05–07 | Unit vs integration vs end-to-end, the test pyramid vs the testing trophy, and what not to test |
| 08–10 | Coverage and its limits, regression tests and names, TDD honestly |
| 11 | What a bad test suite really costs a team |
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).
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.
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)
}
}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.002sA 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.
What tests buy you
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.
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% off8 }9 return total;10}
This is the code as it should be. Every test should pass.
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.
What makes a good test
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
Fast
Deterministic
Checks behaviour
Isolated
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:
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;
},
};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.
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
lineTotalwith a fake, so the brokenlineTotalnever 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:
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.
Unit, integration, end-to-end
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.
- 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 test | Integration test | End-to-end test | |
|---|---|---|---|
| What it does | Calls cartTotal with 2 × $25 and SAVE10 | Sends POST /checkout to the real handler, with a real database started just for the test | Opens 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 there | Somewhere in handler, logic or SQL | Anywhere 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?
Pyramid vs trophy
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.
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.
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.
What not to test
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:
func (u User) Name() string { return u.name }ORDER BY orderconst MaxRetries = 3The 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.)
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.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.
Coverage, and what it can't tell you
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
ifwent 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:
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% off8 }9 return total;10}
Click the presets in order:
- Only "checks nothing": one test that calls
cartTotaland has no assertion. Every line runs — 100% line coverage, 75% branch coverage (theifnever went false) — and it catches 0 of 5 bugs. - Add "empty cart": with no coupon, the
ifalso goes false. 100% line and 100% branch coverage. Still 0 of 5 bugs. - 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%.
// 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).
Regression tests and good names
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:
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});
if (coupon === "SAVE10" && total > 5000)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.)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:
× test1
× TestTotal2
× it works
× discount test× SAVE10 gives 10% off at exactly $50
× unknown coupons are ignored
× returns 0 for an empty cart
× rejects a negative quantityOn 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.
// 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.
TDD, honestly
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:
export function slugify(title) {
// not written yet
}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:
- 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.
- Green: write the least code that makes it pass — even if it looks naïve.
- 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):
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.
The cost of a bad test suite
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 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?".
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.
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) {A team's CI reports 100% line coverage. A bug in the discount maths still reaches production. How is that possible?
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?
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?
A customer reports that SAVE10 didn't apply to their $50.00 order. What should you do first?
In TDD you run a new test before writing the code, and watch it fail. Why bother?
Cheat sheet
| I want to… | Do this |
|---|---|
| Know if my change broke anything | Run 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 handler | An integration test with a real database — Integration Testing |
| Protect the sign-up or payment flow | A few end-to-end tests — End-to-End Testing |
| Find untested code | go test -coverprofile=cover.out ./... then go tool cover -html=cover.out, or vitest run --coverage |
| Check my tests actually assert things | Break a line on purpose (or run a mutation-testing tool) and see if a test fails |
| Fix a bug for good | Failing test first, then the fix, committed together |
| Replace a slow or unpredictable dependency | A test double — Mocks, Stubs & Fakes |