Software Testing Strategy
A Complete 10-Chapter Software Development Course
Table of Contents
- Why a Testing Strategy Matters
- The Testing Pyramid: Unit, Integration & End-to-End
- Writing Good Unit Tests
- Test Doubles: Dummies, Stubs, Fakes, Mocks & Spies
- Integration Testing: Contracts & Boundaries
- End-to-End & System Testing
- Test-Driven Development: Red-Green-Refactor
- Behavior-Driven Development & Specification by Example
- Testing Legacy & Untested Code
- Capstone — Designing a Test Strategy for a Real System
Why a Testing Strategy Matters
Software Testing Strategy
Chapter 1 · Why a Testing Strategy Matters
"Write tests" is not a strategy — it's an instruction with no shape to it. This course is about the shape: how much to test at each level, with what kind of test, and why the mix matters as much as the total count. This chapter makes the case with two real, measured findings: a test mix has a real, dramatic cost in runtime, and a fully green test suite can still ship a genuinely broken system.
The Real Cost of the Wrong Test Mix
"All Tests Passing" Doesn't Mean "It Works"
calculate_price_in_cents passes all 3 of its own unit tests. apply_discount_dollars passes all 3 of its own unit tests. Both have complete, correct, 100%-passing unit test coverage — and both are individually correct: the first genuinely does return cents, the second genuinely does apply a percentage discount to a dollar amount. Composed the way real application code actually calls them — apply_discount_dollars(calculate_price_in_cents(item), 10) on a $19.99 item — the result is $1799.10, not the correct $17.99. A real integration test calling the composed checkout_total() function directly catches the bug immediately; no unit test, however thorough, structurally could.
Testing Strategy vs. "Writing Tests"
Clean Code, SOLID & Refactoring opened with a test: how much of the codebase does changing your mind touch? A testing strategy needs its own version of that question — for a given amount of time spent writing and running tests, how much genuine confidence does that time buy? The two findings above show why the answer isn't just "more tests": a suite can grow in test count while getting slower to run (the mix problem) and can grow in coverage while still missing real bugs (the composition problem). A strategy is the deliberate choice of what to test at which level, made with both of those costs in view — not the accumulated result of writing a test any time one occurs to you.
Scope: What This Course Covers, and What It Doesn't
| This course | Not this course |
|---|---|
| Language-agnostic strategy: what to test, at which level, and why | ft1 Frontend Testing — specific JS tools (Jest, Testing Library, Cypress) |
| The judgment behind a test mix, TDD, BDD, and test doubles | api-testing1 API Testing & Tooling — protocol-level tooling and specific API test clients |
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| 147,104x per-test slowdown crossing a real boundary | Software Architecture Fundamentals Chapter 4's own ~11,661x network-call finding — the same order-of-magnitude gap, applied to test execution |
| "How much confidence per unit of cost" as the real strategy question | Clean Code, SOLID & Refactoring Chapter 1's own "how much of the codebase does changing your mind touch" test |
Hands-On Exercises
Using this chapter's own measured per-test costs (unit: 0.34 microseconds, e2e: 50.27 milliseconds, integration: 0.01 seconds as given for the extrapolation), compute the total runtime for a third suite of 330 tests split 100/100/130 (unit/integration/e2e) and compare it to both Suite A and Suite B.
📄 View solutionWrite a third function, format_receipt_line(price_dollars), that also expects a dollar amount, and compose it directly with this chapter's own calculate_price_in_cents (which returns cents). Verify the same class of bug reproduces, and verify a corrected composition (converting cents to dollars before calling either downstream function) fixes it.
This chapter's own checkout_total() integration test caught the cents/dollars bug. Write a second integration test for a corrected version of the two functions (one now genuinely returning dollars throughout), and verify it passes while the original buggy composition's own integration test still correctly fails.
Chapter 1 Quick Reference
- Verified: a real e2e-style test ran 147,104x slower per test than a real unit test — a genuine, measured order-of-magnitude gap, not an estimate
- Verified: the same total test count (330) ran 26.1x slower with an ice-cream-cone mix than a pyramid mix
- Verified: two functions with 6/6 passing unit tests and 100% line coverage each still produced a $1799.10 charge instead of $17.99 when composed — a bug only an integration test could catch
- The real strategy question: how much confidence does a given amount of test-writing and test-running time actually buy, not how many tests exist
- Scope: language-agnostic strategy, not
ft1's JS tooling orapi-testing1's protocol tooling - Next chapter: The Testing Pyramid — giving this chapter's own cost/confidence tradeoff a concrete shape
The Testing Pyramid: Unit, Integration & End-to-End
Software Testing Strategy
Chapter 2 · The Testing Pyramid: Unit, Integration & End-to-End
Chapter 1 measured that a boundary-crossing test is dramatically slower than a pure one, and that unit tests alone can miss a real composition bug. The pyramid is the classic answer to both findings: many fast unit tests at the base, fewer integration tests in the middle, and a small number of expensive end-to-end tests at the top. This chapter verifies what each level actually buys you — and gives equal, honest time to a real, debated alternative shape.
What Each Level Actually Optimizes For
Speed and cost were Chapter 1's own finding (unit tests measured 147,104× faster per test than e2e tests). The pyramid's other, less-discussed justification is fault localization — when a test fails, how much work is required to find out why.
apply_tax's own test fails immediately with "expected 108.0, got 180.0" — one targeted check, exact fault named. Running a single e2e test against the same buggy pipeline: it correctly fails too (expected $26.60, got $41.00), but the failure message says only that the total is wrong — nothing about which of the 4 stages caused it.
The Ice-Cream-Cone Anti-Pattern
Chapter 1 already measured the cost side of this directly: a suite with the same 330 total tests ran 26.1× slower when weighted toward e2e tests instead of unit tests. This chapter's own fault-localization finding adds the other half of why an ice-cream-cone-shaped suite (many slow e2e tests, few fast unit tests) is a genuine anti-pattern rather than just a style preference: it's simultaneously the slowest shape to run and the slowest shape to debug when something breaks.
An Honest Alternative: The Testing Trophy
The pyramid is not the only shape taken seriously in the industry. Kent C. Dodds' "testing trophy" argues for a different distribution — a wide layer of integration tests as the largest investment, with unit tests and e2e tests both playing smaller, more targeted roles, on the reasoning that a test exercising several real, un-mocked units together tends to catch more real bugs per test written than an isolated unit test does.
calculate_price_in_cents wired in: $17.99, matching the expected value — pass. With the buggy version wired in instead — a completely different bug class than Chapter 1's own composition bug — the same integration test correctly failed: $1.80 instead of the expected $17.99. No dedicated unit test for calculate_price_in_cents was needed to catch this; the one integration test caught it as a side effect of exercising the real code path.
calculate_price_in_cents, the cents-to-dollars conversion, or apply_discount_dollars. The trophy trades some of the pyramid's own fault-localization precision for fewer total tests and broader real-code coverage per test. Neither shape is free of tradeoffs; the honest choice is which cost your own project can better afford.
| Shape | Largest layer | Optimizes for | Weakest at |
|---|---|---|---|
| Pyramid | Unit tests | Speed, precise fault localization | Composition bugs (Chapter 1's own $1799.10 finding) |
| Testing Trophy | Integration tests | Real-code coverage per test written, catching both bug classes | Fault localization — a failure names the scenario, not the exact line |
| Ice-cream cone | End-to-end tests | Nothing — a genuine anti-pattern | Both speed (26.1x slower at equal test count, Chapter 1) and localization (this chapter) |
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| One integration test catching both a unit bug and a composition bug | Chapter 1's own cents/dollars composition finding — the trophy's own direct answer to that exact gap |
| Fault localization as a real, measurable cost | Chapter 1's own runtime-cost finding — together, the full pyramid-vs-trophy tradeoff |
Hands-On Exercises
Move this chapter's own injected bug to stage 1 (validate_cart) instead of stage 3. Verify the unit suite still localizes it in exactly 1 targeted check, and determine how many stages a blind e2e bisection now needs to check before finding it.
Introduce a second, independent bug into this chapter's own apply_discount_dollars (e.g., applying the discount as an addition instead of a multiplication) alongside the existing buggy price function. Verify the single integration test still fails, and confirm it cannot distinguish "one bug" from "two bugs" from its result alone.
Using this chapter's own 4-stage pipeline, extend it to 8 stages (duplicate the existing 4 stages into a second identical pass) and re-run the blind e2e bisection with the bug still in the original stage 3 position. Determine whether the number of stages checked before finding the bug changed.
📄 View solutionChapter 2 Quick Reference
- Verified: unit tests localized an injected bug in 1 targeted check; blind e2e bisection needed 3 of 4 stages
- Verified: a single integration test caught a unit-level bug (wrong multiplier, $1.80 vs. expected $17.99) with no dedicated unit test
- The pyramid's real justification: speed (Chapter 1) plus precise fault localization (this chapter)
- The testing trophy, honestly: more real-code coverage per test, at the cost of the pyramid's own fault-localization precision — not a free upgrade
- Ice-cream cone: the worst of both worlds — slowest to run (Chapter 1) and slowest to debug (this chapter)
- Next chapter: Writing Good Unit Tests — the base of whichever shape you choose
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
Test Doubles: Dummies, Stubs, Fakes, Mocks & Spies
Software Testing Strategy
Chapter 4 · Test Doubles: Dummies, Stubs, Fakes, Mocks & Spies
"Mock" is the word most people reach for regardless of which of five genuinely different tools they mean. The distinction isn't pedantry — each category makes a different tradeoff, and picking the wrong one produces either a slower test than necessary or a test that can pass while production breaks. This chapter builds all five against one real system, then verifies both of those failure modes directly.
The Real Taxonomy, Applied to One System
UserRegistrationService depends on a NotificationSender to email a new user. Five genuinely different test doubles stand in for it below — same collaborator, five different jobs.
| Type | What it does | Verified in this chapter |
|---|---|---|
| Dummy | Passed to satisfy a signature, never actually called | DummyAuditLogger — zero methods, never invoked |
| Stub | Returns a canned answer, no real logic | StubNotificationSender.send() — always returns "SENT" |
| Fake | A real, working implementation — just not production-grade | FakeNotificationSender — a genuine in-memory inbox |
| Mock | Pre-programmed with an expectation, verified was met | MockNotificationSender.expect_call(), set before the action |
| Spy | Records what happened, inspected after the fact | SpyNotificationSender.calls — no expectation set up front |
Why Fakes Exist: A Measured Speedup
fsync, standing in for a real database write) took 277.41ms total — 1,387.04 microseconds per call. The identical 200 calls through FakeNotificationSender (a genuine in-memory list, no disk access) took 0.63ms total — 3.13 microseconds per call. 443× faster, measured directly.
The Real Danger: Mocks Can Drift From Reality
mock.expectation_met is True — test passes. Running the identical register() call against the real, updated RealNotificationSenderV2 instead: crashed with TypeError: RealNotificationSenderV2.send() missing 1 required positional argument: 'body'. The test suite never noticed anything was wrong, because it never once exercised the real class — only a mock that had quietly stopped matching it.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| 443x measured speedup from a fake over real I/O | Software Architecture Fundamentals Chapter 7's own ~18,111x fake-adapter finding — same principle, different scale of boundary avoided |
| A green mock test alongside a crashing real dependency | Chapter 1's own "100% passing, still broken" composed-bug finding — the same failure mode, specific to test doubles going stale |
Hands-On Exercises
Write a sixth test double for NotificationSender — a stub that simulates a failure by always returning None instead of "SENT" — and use it to test that UserRegistrationService.register() still correctly adds the user to its own list even when the notification "fails" (this chapter's own register() doesn't check the return value of send() at all).
Re-run this chapter's own fake-vs-real-I/O timing comparison with N = 1000 instead of 200. Verify the measured ratio stays in the same broad order of magnitude as the chapter's own 443x finding, and report the new per-call costs for both.
Fix this chapter's own mock-drift bug two ways: (a) update MockNotificationSender.send() to accept the new 3-argument signature, and (b) update UserRegistrationService.register() to actually call the new 3-argument signature. Verify the mock-based test still passes after both changes, and verify it now genuinely matches what RealNotificationSenderV2 expects.
Chapter 4 Quick Reference
- Dummy: satisfies a signature, never called — Stub: canned answer, no logic — Fake: real logic, shortcut implementation — Mock: expectation set before, verified after — Spy: no expectation, inspected after
- Verified: a fake ran 443x faster than real file I/O for the same 200 calls — the same principle as Software Architecture Fundamentals' own ~18,111x fake-adapter finding, at a smaller scale
- Verified: a mock-based test stayed green while the identical call against the real, updated dependency crashed with a TypeError — mocks verify internal consistency, not continued resemblance to reality
- The real risk: mocks and fakes need to be kept in sync with whatever they stand in for, or they silently stop testing anything real
- Next chapter: Integration Testing — testing where two real components actually meet, instead of doubling the seam away
Integration Testing: Contracts & Boundaries
Software Testing Strategy
Chapter 5 · Integration Testing: Contracts & Boundaries
Chapter 4 ended on a real bug: a mock-based test stayed green while the actual dependency it stood in for had drifted incompatible, and production crashed. Integration testing is the direct answer — testing where two real components genuinely meet, instead of doubling the seam away. This chapter verifies that fix, then extends it to contract testing, a way to catch the same class of bug without needing the whole system running at once.
Testing the Real Seam
expectation_met = True. The identical scenario run as an integration test — UserRegistrationService wired directly to the real, updated RealNotificationSenderV2, no double anywhere — fails immediately: TypeError: RealNotificationSenderV2.send() missing 1 required positional argument: 'body'. Same bug, same code, one test catches it in CI before deploy and the other doesn't.
Contract Testing: Catching Drift Without the Whole System Running
A full integration test needs both real components running together. That's not always practical — especially across service boundaries, where a "provider" and its "consumers" might be developed, deployed, and owned by different teams entirely. Contract testing solves a narrower version of the same problem: define the shape both sides agree on, and check each side against that shape independently.
UserServiceV1 (returns {'id', 'email', 'active'}): contract test passes, zero errors. Weeks later, UserServiceV2 renames active to is_active during an unrelated provider-side refactor. The same contract test, run against UserServiceV2 alone: fails — ["missing field 'active'"]. The consumer, OrderService, was never started for this check.
OrderService.summarize_user() against UserServiceV1: "User 1 active: True", works correctly. Against UserServiceV2: crashed with KeyError: 'active'. The contract test catches the exact same class of break the consumer would eventually hit — but at the provider's own deploy time, independent of whether the consumer's own test suite happens to run soon enough to notice.
OrderService handles every value the contract allows correctly (an active: False user, for instance). A genuine end-to-end integration test still has a job the contract can't do — the two are complementary, not interchangeable, matching the exact tradeoff already established between the pyramid's own levels in Chapter 2.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| Integration test catching the exact bug the mock missed | Chapter 4's own mock-drift finding — this chapter is the direct fix |
| A shared schema checked independently on each side of a boundary | Software Architecture Fundamentals Chapter 5's own service-boundary material, and Chapter 6's event-driven architecture, where a message schema plays exactly this same contract role |
Hands-On Exercises
Extend this chapter's own USER_SERVICE_CONTRACT with a new required field, 'created_at': str. Verify the contract test now fails against both UserServiceV1 and UserServiceV2 (neither currently returns this field), then add the field to UserServiceV1 and verify the contract test passes again.
Write a corrected UserRegistrationService that calls the new 3-argument send() signature, and a corresponding integration test wiring it to RealNotificationSenderV2. Verify the integration test now passes, confirming the fix rather than just detecting the break.
Add a type-mismatch case to this chapter's own contract test: create a UserServiceV3 that returns 'active' as the string "true" instead of the boolean True. Verify validate_against_contract correctly reports a type error for this case, distinct from the missing-field error found for UserServiceV2.
Chapter 5 Quick Reference
- Verified: an integration test wiring the real seam together caught Chapter 4's own mock-drift bug immediately, where the mock-based test stayed green
- Verified: a contract test using only a shared schema caught a provider-side field rename with the consumer never even running
- Contract testing's real value: an early, cheap warning at the provider's own deploy time, independent of the consumer's own test schedule
- Contract testing's real limit: proves the shape matches, not that every value in that shape is handled correctly — still needs real integration/e2e coverage alongside it
- Where this lives architecturally: Software Architecture Fundamentals' own service-boundary and event-driven material — a contract is the same kind of shared agreement, tested rather than just documented
- Next chapter: End-to-End & System Testing — the top of the pyramid, and the practical problem of flakiness
End-to-End & System Testing
Software Testing Strategy
Chapter 6 · End-to-End & System Testing
The top of the pyramid tests the whole system the way a real user actually experiences it — every layer wired together, nothing doubled. That's exactly what makes it valuable, and exactly what makes it fragile. This chapter verifies both: a real, measured flakiness problem, a real bug only a full flow catches, and a real blind spot even a full flow can't touch.
Flakiness: A Measured, Not Anecdotal, Problem
What Only a Full Flow Catches
test_login_isolated, test_browse_isolated, and test_checkout_isolated — each calling its own step immediately, with no elapsed time — all passed. The same three operations run as one continuous flow, with realistic delays between steps (matching how long a real user actually spends browsing and filling out a form): failed — "session expired during checkout."
checkout() alone could have caught this, however well written — the bug only exists in the passage of real time across steps, which an isolated test of any single step cannot represent by definition. A full end-to-end flow is the only test shape that naturally includes that elapsed time.
What Even a Full Flow Can't Catch
test_e2e_happy_path passes. breaker.times_opened after the test: 0. The e2e test never once caused a failure, so it structurally cannot exercise what happens when one occurs — not because the test was written badly, but because a happy-path flow has no failure in it to trigger the breaker.
breaker.call(failing_dependency) three times in a row: breaker.state becomes "OPEN", times_opened becomes 1, and a subsequent call — even with a healthy dependency — is correctly rejected fast rather than attempted: "circuit open - failing fast."
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| A bug only visible across real elapsed time between steps | Chapter 2's own fault-localization tradeoff — a cost e2e coverage pays for, in exchange for catching this exact class of bug |
| A circuit breaker's OPEN state left completely unexercised by a happy-path e2e test | Distributed Systems & Scalability Chapter 9's own resilience-pattern verification — genuinely separate coverage, not a subset of e2e |
Hands-On Exercises
Using this chapter's own naive_flaky_test, increase the fixed wait from 30ms to 80ms (still less than the maximum possible 100ms load time) and re-run the same 200-iteration measurement. Report the new pass rate and explain why it's higher but still not 100%.
Modify this chapter's own full-flow e2e test so the total delay between login and checkout is 0.10 seconds instead of 0.20 seconds (still under the 0.15-second session timeout). Verify the flow now completes successfully, and explain why this doesn't mean the underlying session-expiry risk is gone.
📄 View solutionWrite a second resilience-pattern test for this chapter's own CircuitBreaker: after it trips to OPEN, simulate enough time passing for it to attempt a "half-open" retry (you'll need to add minimal half-open logic to CircuitBreaker yourself), and verify a subsequent successful call resets it back to CLOSED.
Chapter 6 Quick Reference
- Verified: a fixed-wait e2e test scored a 23.5% pass rate across 200 identical runs; polling for the real condition scored 100%
- Verified: a session-expiry bug passed every isolated per-step test but failed the one test running the full flow with realistic elapsed time
- Verified: a passing e2e happy-path test left a circuit breaker's OPEN state completely unexercised — 0 trips recorded
- The flakiness fix: poll for the real condition instead of guessing a fixed wait time
- What only e2e catches: bugs that exist in the passage of real time or state across multiple steps
- What even e2e can't catch: resilience-pattern behavior — that needs tests that deliberately simulate failure, not just a healthy happy path
- Next chapter: Test-Driven Development — writing the test before the code, and what that changes
Test-Driven Development: Red-Green-Refactor
Software Testing Strategy
Chapter 7 · Test-Driven Development: Red-Green-Refactor
Every prior chapter tested code that already existed. TDD reverses the order: write a failing test first, write only enough code to pass it, then clean up. This chapter walks the full cycle on a real algorithmic problem — verifying, along the way, a genuine gap that the incremental process catches — then gives TDD an honest limit: a task where the same discipline creates friction rather than value.
Red-Green-Refactor, Walked Through
Building a balanced-brackets checker: does "([{}])" have every bracket properly opened and closed, in the right order, of the right type?
is_balanced_v1's own "always return True" trivially passes both test_empty_string_is_balanced and a naive test_single_pair checking "()" — because it would pass literally any input. Only a test specifically checking an unbalanced case, is_balanced("(") == False, forces the implementation to do real work — it correctly fails against v1, as expected.
The Cycle That Caught a Real Gap
is_balanced_v2 correctly handled the empty string, a matched pair, and a single unclosed bracket — all 3 prior tests passed. Against the new test: is_balanced_v2("(]") returns True, but the correct answer is False — ( opens, ] closes, and a depth counter alone has no way to notice the bracket types don't match. This is a real, non-obvious gap that only surfaced because the next Red step deliberately asked a question the implementation had never been tested against.
is_balanced_v3 passes all 4 tests so far (empty, matched pair, unclosed, mismatched types), plus a Cycle 4 addition — is_balanced("([)"), brackets closed in the wrong order — also correctly returns False.
is_balanced_v3 (tidier variable use, an added tolerance for non-bracket characters) was checked against the full accumulated suite — 7 cases including two new ones ("([{}])" and "([)]") never run against the code before. All 7 passed. Refactoring changed the code's own shape; it changed none of its answers.
An Honest Limit: Where TDD Is a Weaker Fit
True for a matched pair, False for a mismatched type — was correct the first time it was written, because bracket balance has one objectively correct answer per input. Simulating the threshold-tuning exploration (trying 0.3 → 0.5 → 0.7, each new value chosen because it genuinely improved on the last by the metric being optimized): a TDD-first test asserting "the threshold IS 0.3" would have needed rewriting 2 times as better values were discovered through real experimentation, not derived in advance.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| A depth-counter implementation missing bracket-type mismatches, caught by the next Red step | Chapter 3's own brittleness material — TDD's incremental tests are behavior-focused by construction, each one added because a new behavior needed proving |
| Zero test rewrites for a deterministic problem vs. 2 for an exploratory one | Pseudocode & Algorithmic Problem-Solving's own "design before you write" theme, applied at the level of an individual test rather than a whole algorithm |
Hands-On Exercises
Following this chapter's own Red-Green-Refactor cycle, add a new test for is_balanced("]") — a closing bracket with nothing open at all. Determine whether it passes or fails against is_balanced_v2 (the depth-counter version), and explain the result in terms of exactly what v2's own known gap is and isn't.
Continue this chapter's own threshold-exploration simulation with two more tried values, 0.55 and 0.65, added to threshold_results with plausible recall/false-positive-rate figures of your own choosing. Determine how many additional test rewrites (if any) this causes beyond the chapter's own count of 2.
Write one more Red-Green cycle for the bracket checker: a test for deeply nested, fully valid input, "((([[[{{{}}}]]])))". Verify it passes against the refactored implementation with no code changes at all, and explain what that confirms about the refactor's own correctness.
Chapter 7 Quick Reference
- Red-Green-Refactor: write a failing test, write the minimal code to pass it, then clean up while every test stays green
- Verified: a depth-counter implementation passed 3 tests, then failed a 4th ("(]" should be False) — a real gap the incremental process caught before shipping
- Verified: the refactor step changed the implementation's own shape with zero change to any of 7 accumulated test results
- Verified: an algorithmic task needed 0 test rewrites; an exploratory threshold-tuning task needed 2 — because only one of them has a knowable-in-advance correct answer
- The honest limit: TDD fits where correctness can be reasoned about before the code exists; it fights against exploratory work, where the code itself is how "correct" gets discovered
- Next chapter: Behavior-Driven Development — Given-When-Then, and living documentation that fails loudly instead of going stale silently
Behavior-Driven Development & Specification by Example
Software Testing Strategy
Chapter 8 · Behavior-Driven Development & Specification by Example
Documentation & Runbooks named a real problem: documentation decays silently, looking exactly as authoritative the day it goes stale as the day it was written. BDD is a direct, concrete answer for a specific category of documentation — the description of what the system does — by making the description itself executable. This chapter verifies exactly what that buys you, side by side against the plain prose it replaces.
Given-When-Then, on a Real Policy
scenario_gold_member_gets_ten_percent_off() passes against the real apply_discount() function. Anyone reading the Given/When/Then comments alone — no Python experience required — learns the exact business rule; anyone running the file confirms the rule is actually true of the current code, in the same three lines.
The Real Test: What Happens When the Policy Changes
STATIC_DOC — a plain markdown string, not connected to any code — still reads "GOLD members receive a 10% discount." Nothing about running the program, the tests, or a build checks it. It will read as correct to anyone who trusts it until a human happens to notice the mismatch by hand — exactly Documentation & Runbooks Chapter 7's own silent-decay finding.
scenario_gold_member_gets_ten_percent_off, re-run against the new apply_discount_v2 with its own expected value left unchanged: fails immediately — "expected 90.0, got 85.0." The scenario doesn't quietly become wrong; it announces the mismatch the next time anyone runs it, typically in CI, long before a human would have caught the prose drift by inspection.
85.0 is a deliberate, reviewed code change — not a silent edit nobody notices. The scenario now passes again, and reading it tells anyone the current, correct policy, with no way for it to quietly drift out of sync a second time without failing first.
| Static prose documentation | BDD scenario | |
|---|---|---|
| When the code changes underneath it | Stays exactly as written — silently wrong | Fails immediately the next run — loudly wrong |
| How the mismatch gets found | A human happens to notice, eventually, or never | Automatically, on the next CI run |
| Updating it | Easy to forget — no signal that it's needed | Forced — the scenario won't pass until it's updated |
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| Static documentation staying silently wrong after a real policy change | Documentation & Runbooks Chapter 7's own silent-decay problem, reproduced concretely rather than described abstractly |
| A scenario failing loudly instead of drifting quietly | Chapter 3's own brittleness material — the difference here is the scenario is supposed to fail when real behavior changes, which is the entire point rather than a bug |
Hands-On Exercises
Write a second Given-When-Then scenario for this chapter's own apply_discount function covering PLATINUM members (20% off). Verify it passes against the original function, then verify it also fails when re-run against a modified version where PLATINUM's own discount changes to 25%.
Write a static prose description (a plain string, like this chapter's own STATIC_DOC) for a third membership tier, SILVER, that gives a 5% discount. Add SILVER support to apply_discount, then change the SILVER discount to 8% and verify the static doc, once again, stays silently wrong with no automatic check catching it.
Write a Given-When-Then scenario for a member with no recognized tier (e.g. membership_tier = "BASIC"), asserting no discount is applied. Verify it passes against both apply_discount and apply_discount_v2 unchanged — and explain why this particular scenario is unaffected by the GOLD-tier policy change that broke the other one.
Chapter 8 Quick Reference
- Given-When-Then: a scenario that reads as a spec for a human and runs as a test for CI, in the same lines
- Verified: after a real policy change, static prose documentation stayed silently wrong — no automatic check ever catches it
- Verified: the identical BDD scenario, re-run against the changed code, failed immediately and loudly
- The real value: not that BDD prevents behavior from changing — it's that a change gets discovered on the next run, not whenever a human happens to notice
- Directly answers: Documentation & Runbooks' own silent-decay problem, for the specific category of documentation BDD scenarios can express
- Next chapter: Testing Legacy & Untested Code — what to do when none of this exists yet
Testing Legacy & Untested Code
Software Testing Strategy
Chapter 9 · Testing Legacy & Untested Code
Clean Code, SOLID & Refactoring's own capstone refactored TangleMart's tangled order processor in six verified, dependency-ordered steps — but it never had to answer a harder question: what do you do when the code you need to touch has no tests at all yet? Michael Feathers, in Working Effectively with Legacy Code, gives legacy code a precise, testable definition: code without tests, regardless of its age or who wrote it. This chapter builds the technique for getting a safety net under code like that before changing a single line.
Discover, Don't Guess
(10, 'A', False) → 5.0, (10, 'B', False) → 6.0, (10, 'C', True) → 17.5, (-5, 'A', False) → 0.0 (negative weight silently clamped), (10, 'Z', False) → 10.0 — an unrecognized zone is charged more, not rejected as an error — and (60, 'A', False) → 27.0, reflecting an undocumented bulk discount past 50 units.
Characterization Tests as a Safety Net
A Safe Refactor, Verified Against the Same Net
calculate_shipping_refactored instead of the original: all 6 pass, including the exact same undocumented quirks — the negative-weight clamp, the unknown-zone default of 2.0, and the bulk discount. A genuinely cleaner implementation (dict lookup, no repeated if/elif), zero behavior change.
The Real Payoff: Catching an Accidental Change
calculate_shipping_WELLMEANING_FIX(10, 'Z', False) now returns 5.0 instead of the captured 10.0 — the characterization test fails. Whether 2.0 was a real bug or an intentional (if undocumented) business decision is still unknown — but the test forces that question to be asked and answered deliberately, instead of letting the change ship silently as a side effect of an unrelated refactor.
2.0 really is a bug, the fix is the same one shown above — but now it's a deliberate, reviewed change to the test's own expected value (exactly Chapter 8's own "update the scenario intentionally" pattern), not a silent side effect nobody noticed until a customer complained.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| Capturing real behavior before refactoring, verified surviving a safe refactor | Clean Code, SOLID & Refactoring's own capstone — the missing first step for code that starts with zero tests |
| A test failing on an unintended change, forcing a deliberate decision | Chapter 8's own "update the scenario intentionally" pattern — the same discipline, applied to undocumented legacy behavior instead of a known business rule |
Hands-On Exercises
Probe this chapter's own legacy_calculate_shipping with a new input it wasn't tested against: (0, 'B', True) (zero weight, express). Discover the actual returned value by running the code, write a characterization test asserting it, and verify it passes against both the original and refactored versions.
This chapter's own bulk discount triggers at weight > 50. Discover what happens at exactly weight = 50 (the boundary itself) by running the code, and write a characterization test capturing that exact boundary behavior.
Introduce a second unintended change to this chapter's own refactored version: accidentally apply the bulk discount at weight >= 50 instead of weight > 50. Determine which of this chapter's own 6 characterization tests (if any) catches it, and explain why the answer depends on which specific inputs happen to already be covered.
Chapter 9 Quick Reference
- Feathers' definition: legacy code is simply code without tests — age and authorship don't matter
- Characterization tests: capture what the code actually does, discovered by running it — never what you think it should do
- Verified: 6 characterization tests captured a legacy function's real behavior, including 3 undocumented quirks (negative-weight clamping, an expensive unknown-zone default, a bulk discount)
- Verified: all 6 tests passed unchanged against a genuinely cleaner refactor, proving behavior was preserved exactly
- Verified: the same 6 tests caught a well-meaning "fix" that silently changed undocumented behavior — forcing a deliberate decision instead of an accidental one
- Next chapter: Capstone — designing a full test strategy for a real system, from the ground up
Capstone — Designing a Test Strategy for a Real System
Software Testing Strategy
Chapter 10 · Capstone: Designing a Test Strategy for a Real System
One continuous worked project: a full test strategy for Clean Code, SOLID & Refactoring's own refactored TangleMart order system — the exact calculate_order_total, OrderService, DiscountStrategy/ShippingStrategy hierarchies, and payment classes that course's own capstone assembled. Every technique from Chapters 2 through 9 gets applied to this one real system, in the order its own dependencies actually require, closing with a single order run through every layer at once.
Step 1Unit Tests (Chapter 3)
test_gold_discount_applies_ten_percent_off and test_no_discount_applies_full_price each create their own fresh items list — no shared mutable state between them, per Chapter 3's own finding. Both pass: $41.00 with GoldDiscount, $45.00 with NoDiscount.
Step 2Test Doubles (Chapter 4)
FakePayment (a genuine in-memory list): 0.23ms total — 802× faster, consistent with Chapter 4's own 443x finding on a different dependency. A correctness check confirms the fake genuinely exercises real logic: fake.charges == [41.0] after a $41.00 checkout.
Step 3Integration & Contract Tests (Chapter 5)
test_integration_real_checkout, wiring OrderService directly to the real CreditCardPayment with no doubles: passes — $41.00, correct receipt, inventory correctly decremented. A contract test asserting every DiscountStrategy's apply() returns a non-negative number: NoDiscount, GoldDiscount, PlatinumDiscount all pass; a deliberately broken BrokenDiscount (returning a string instead of a number) is caught immediately — "apply() must return a number, got str" — without ever needing to run it through a full checkout.
Step 4End-to-End Testing (Chapter 6)
Step 5Test-Driven Development (Chapter 7)
$41.00 → 40 points), Cycle 2 (exactly $10.00 → 10 points), Cycle 3 ($9.99 → 0 points, below the first tier) — all pass. A cleaner refactored implementation, checked against all 5 accumulated cases including two new ones ($100.00 → 100, $0 → 0): all pass. This is exactly the algorithmically well-suited case Chapter 7 identified — a deterministic rule with a knowable-in-advance correct answer.
Step 6Behavior-Driven Development (Chapter 8)
scenario_customer_earns_loyalty_points_on_checkout (Given a $41.00 order, When points are calculated, Then 40 points are earned): passes. Re-run unchanged against a policy bump to 15 points per $10: fails immediately — "expected 40, got 60." Exactly Chapter 8's own living-documentation finding, on a feature this capstone built from scratch two steps earlier.
Step 7Testing Legacy Code (Chapter 9)
legacy_calculate_shipping pass against the original function. Wrapped in a new LegacyShippingAdapter(ShippingStrategy) — migrating the old, undocumented function into this capstone's own strategy-pattern architecture — the identical 6 tests: all pass, confirming the migration preserved every discovered quirk (the negative-weight clamp, the expensive unknown-zone default, the bulk discount) exactly.
The Actual Pyramid Shape Built
| Level | Tests built across Steps 1-7 | Why this shape |
|---|---|---|
| Unit | 11 (2 pricing + 3 TDD loyalty-points + 6 characterization) | Fast, precise fault localization (Chapter 2), the base of the pyramid |
| Integration / Contract | 4 (1 real-seam integration + 3 passing contract checks) | Catches wiring and interface mismatches Chapters 1 and 4 found unit tests structurally can't |
| End-to-End | 1 (the polling-based checkout flow) | The one thing that exercises real elapsed time across the whole flow, kept deliberately small per Chapter 1's own 26.1x cost finding |
| BDD scenario | 1 (living documentation for the loyalty-points policy) | Not a pyramid level — a cross-cutting spec that also happens to be a test |
Final Integration: One Real Order, Every Layer
{'widget': 48} — plus a separate legacy-shipping quote for an unrelated package, correctly computed at $6.00 through the migrated adapter. Every number matches what Steps 1 through 7 independently verified, confirming the whole assembled test strategy — not just its individual pieces — describes one coherent, correct system.
Hands-On Exercises
Add a third unit test for this chapter's own calculate_order_total, covering PlatinumDiscount (20% off) combined with a hypothetical ExpressShipping costing $15. Verify it passes, following the same AAA structure and isolated-state pattern as Step 1's own two tests.
Extend Step 3's own contract test to also reject a DiscountStrategy whose apply() returns a total greater than the input (a discount that somehow increases the price). Write a deliberately broken example that triggers this new check, and verify the existing valid strategies (NoDiscount, GoldDiscount, PlatinumDiscount) still pass.
Add a second order to this chapter's own final integration check — a different item, PlatinumDiscount, checked out through the same OrderService instance used for the first order — and verify both orders' own totals, receipts, loyalty points, and inventory deductions are correct and fully independent of each other.
Chapter 10 Quick Reference — Course Summary
- Verified end to end: a full test pyramid built for one real system — 11 unit, 4 integration/contract, 1 e2e, 1 BDD scenario — every level directly justified by a specific prior chapter's own measured finding
- Step 2 reproduced Chapter 4's finding: an 802x fake-vs-real-I/O speedup on this system's own payment method
- Step 3 caught a genuinely broken strategy via a contract test, before it ever reached a full checkout
- Step 4 reproduced Chapter 6's flakiness finding exactly: 21% pass rate with a fixed wait, 100% with polling
- Step 5 built a brand-new feature via real TDD, then Step 6 turned its own policy into a living, executable specification
- Step 7 migrated legacy code into the new architecture with characterization tests proving zero behavior change
- Course complete: the pyramid, unit tests, test doubles, integration/contract testing, e2e testing, TDD, BDD, and legacy code — all ten chapters, verified throughout