Unit tests
There was a time, not that long ago, when tests were something you wrote once, ran by hand a few times to convince yourself the code worked, and then threw away. Test Driven Development changed that: tests became permanent, automated, and as much a part of the codebase as the code they check. This chapter is about what happens once that's true - namely, that test code needs the same care as production code, because a test suite that rots is worse than no test suite at all.
The Three Laws of TDD
TDD can be reduced to three rules, and the interesting thing about them is how small a loop they force you into:
- You may not write production code until you've written a failing unit test for it.
- You may not write more of a unit test than is sufficient to fail (and a compile failure counts as a failure).
- You may not write more production code than is sufficient to pass the currently failing test.
Followed literally, this means you're rewriting test and production code in a cycle that repeats every thirty seconds or so. The immediate consequence is that you end up with close to 100% test coverage, simply because it's structurally impossible to write untested production code under these rules - there's no code without a preceding failing test to justify it.
Test code needs to stay clean too
It's tempting to hold test code to a lower bar than production code - after all, it doesn't ship, and it doesn't run in front of users. That reasoning quietly destroys the value of having tests at all. If tests are sloppy, duplicated, and hard to follow, they get harder to maintain than the production code they're supposed to be checking. Once a test suite gets expensive enough to touch, developers start skipping updates to it when they change the production code, and once a few tests start failing "for reasons unrelated to my change," people begin ignoring red test runs altogether. From there it degrades fast: a red suite that nobody trusts is functionally the same as no suite, except it also costs CI minutes.
The standard for test code isn't "the same amount of polish as production code" - it can be less elaborate, since it doesn't need to handle every edge case a production API does. But it does need to be readable at a glance, because the whole point of a test is that a future reader (possibly you, in six months) can look at it and understand what behavior it's pinning down, without archaeology.
Tests are what let you change things
The core argument for keeping tests around at all: they're what makes it safe to change code later. Without an automated suite, every change to a nontrivial codebase risks silently breaking something far away from where you're working, so people become afraid to change anything, and the design calcifies. With a trustworthy suite, you can restructure, rename, and refactor as aggressively as the design demands, because the tests will tell you within seconds if you broke something. Clean production code is what makes a system pleasant to read; clean, trustworthy tests are what make it safe to keep improving. Lose the tests and you lose the courage to change the code, no matter how good the code looks today.
One concept per test
A common early rule of thumb is "one assert per test method." Taken as an absolute law it's overly strict - sometimes a single logical concept genuinely requires checking two or three related values that all describe one behavior. The more useful version of the rule is: test one concept per test. If a test's name can't describe what broke in a single clear sentence, it's probably checking multiple unrelated things, and a failure there won't tell you much beyond "something is wrong somewhere in this list of asserts."
Splitting by concept also means shared setup logic naturally wants to move into a fixture or helper, which is a good sign - it's evidence the tests were about behavior, not incidentally sharing a block of code.
F.I.R.S.T.
A useful acronym for what makes a test suite worth trusting:
- Fast. Tests that take minutes to run get skipped. A team that stops running the suite because it's slow has, in effect, stopped having a suite.
- Independent. One test's outcome should never depend on another test having run first, or on the order tests happen to execute in. Order-dependent tests fail in confusing ways and make it hard to run a single test in isolation while debugging.
- Repeatable. A test should pass or fail the same way in any environment - your laptop, a teammate's laptop, CI, offline on a train. A test that only works when it happens to be connected to a particular network is a test you can't run when you actually need it.
- Self-validating. The test itself produces a boolean pass/fail. If verifying the result means a human has to read a log and decide whether it looks right, it isn't really automated yet.
- Timely. Write the test just before the production code it covers, not weeks after. Code written without a preceding test has a way of turning out hard to test after the fact, which is itself a sign the design could be better.