Writing Good Unit Tests
Software Testing Strategy
Chapter 3 · Writing Good Unit Tests
Chapters 1 and 2 established why unit tests sit at the base of the pyramid — fast, and precise at naming a fault. Neither property survives a badly written unit test. This chapter verifies two ways a unit test can quietly stop doing its job: sharing state with other tests, and asserting on how a function works instead of what it produces.
Arrange-Act-Assert
A well-structured unit test has three visible parts, in order: Arrange (set up the exact inputs needed), Act (call the one thing under test), Assert (check the result). Keeping these visually separate — even with nothing more than a blank line or a comment — makes a test readable at a glance: a reader can find the assertion without re-deriving the setup logic first. Every example in this chapter follows that shape.
Test Independence: A Bug Reproduced Fresh
test_first_order_is_order_number_one then test_second_order_is_order_number_one_too: the first passes, the second fails ("expected 1, got 2"). Clearing state and running the exact same two tests in the opposite order: now the second one passes and the first one fails. Neither test's own code changed at all — only which ran first.
sell_item_impure returning 70 then 20 instead of the correct 50, purely from call order. This chapter's own finding is the identical failure mode, one layer further out — it isn't only application code that can accidentally share mutable state; test code can do it to itself.
The Fix: Each Test Owns Its Own State
processed_orders created fresh inside each test instead of shared at module level, both tests pass regardless of order — verified running first-then-second and second-then-first, both orders producing all-green results.
Brittleness: Testing How, Not What
[1, 2, 3, 4], exactly 6 comparisons made by the bubble-sort implementation. After refactoring the internals to use Python's built-in sorted() instead — same public behavior, same correct output — test_behavior_only still passes. test_implementation_detail fails: "expected 6, got 0," because the new implementation never tracks a comparison count at all.
Sorter is completely correct — verified by the behavior-only test, and by direct inspection of its output. The implementation-detail test failed anyway, for a reason that has nothing to do with correctness. A team that trusts this test will spend real time investigating a "regression" that was never a regression, or worse, will feel pressured to keep an implementation detail unchanged purely to keep an unrelated test green.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| Identical tests passing or failing purely by execution order | Design Patterns Chapter 2's own Singleton race condition, and Clean Code Chapter 3's own sell_item_impure bug — the same shared-state failure mode, applied to test code itself |
| A correct refactor breaking a test that checked internals, not output | Clean Code, SOLID & Refactoring's own entire capstone — refactoring safely depends on tests that only fail when behavior actually changes |
Hands-On Exercises
Add a third test, test_third_order_is_order_number_one_too, to this chapter's own shared-state example, following the same (buggy) pattern as the first two. Run all three in three different orders and verify exactly one of the three passes in each run — never zero, never more than one.
Apply this chapter's own isolated-state fix to your Exercise 1 answer (a fresh processed_orders list per test). Verify all three tests now pass regardless of which of the six possible orderings they run in.
Refactor this chapter's own Sorter a second time — from the built-in-sorted() version to a manual insertion sort that DOES track a comparison count again, but a different count than bubble sort's own 6. Verify test_behavior_only still passes unmodified, and determine the new comparison count an implementation-detail test would need to assert to pass against this version.
Chapter 3 Quick Reference
- Arrange-Act-Assert: keep the three parts visually separate so a reader can find the assertion without re-deriving the setup
- Verified: two tests sharing module-level state passed or failed purely based on execution order — reproducing Design Patterns' Singleton bug and Clean Code's
sell_item_impurebug one layer further out, in test code itself - The fix: give each test its own fresh state — verified removing the order dependency entirely, both orders all-green
- Verified: a test asserting on an internal comparison count failed after a behavior-preserving refactor, while a test asserting on output alone survived unchanged
- The rule: assert on what a function produces, not how it produces it — a passing behavior test and a failing implementation test after the same refactor is a false alarm, not a caught bug
- Next chapter: Test Doubles — dummies, stubs, fakes, mocks & spies, and when each is the right tool