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

processed_orders = [] # module-level shared state def process_order(order_id, amount): processed_orders.append(order_id) return {'order_id': order_id, 'total_processed': len(processed_orders)} def test_first_order_is_order_number_one(): # Arrange/Act combined here - process_order IS the thing under test result = process_order('A1', 50) # Assert assert result['total_processed'] == 1
Verified directly — the identical two tests pass or fail depending purely on execution order
Running 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.
This is the same bug shape as Design Patterns' Singleton and Clean Code's own sell_item_impure
Design Patterns Chapter 2 found a race condition producing 8 distinct Singleton instances from 20 threads that should have shared one. Clean Code, SOLID & Refactoring Chapter 3 found sequential calls to 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

def test_first_order_is_order_number_one(): processed_orders = [] # fresh state, owned by this test alone - Arrange result = process_order('A1', 50, processed_orders) # Act assert result['total_processed'] == 1 # Assert
Verified directly — isolating state removed the order dependency entirely
With 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

class Sorter: def sort(self, items): # bubble sort, tracking comparisons made ... self.comparisons_made += 1 ... def test_behavior_only(): r = Sorter().sort([4, 2, 3, 1]) assert r == [1, 2, 3, 4] # checks WHAT the function produced def test_implementation_detail(): s = Sorter(); s.sort([4, 2, 3, 1]) assert s.comparisons_made == 6 # checks HOW it got there
Verified directly — a behavior-preserving refactor broke only the implementation-detail test
Before refactoring: both tests pass — sorted output [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 outputtest_behavior_only still passes. test_implementation_detail fails: "expected 6, got 0," because the new implementation never tracks a comparison count at all.
A brittle test failure is a false alarm, not a caught bug
The refactored 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 findingWhat it connects to
Identical tests passing or failing purely by execution orderDesign 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 outputClean Code, SOLID & Refactoring's own entire capstone — refactoring safely depends on tests that only fail when behavior actually changes

Hands-On Exercises

Exercise 1

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.

📄 View solution
Exercise 2

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.

📄 View solution
Exercise 3

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.

📄 View solution

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_impure bug 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