Test-Driven Development
Use the red-green-refactor cycle to design interfaces from behavior and keep refactoring safe.
By the end, you’ll be able to…
- Explain the red-green-refactor loop.
- Write a failing test before the implementation.
- Refactor with a suite that protects behavior.
- Recognize design pressure toward testable code.
01Write the test before the code
Test-driven development reverses the usual order. Write a small test for the next behavior, run it, and watch it fail for the expected reason. Then write the minimum implementation that passes, and refactor once the suite is green. The failing step proves the test can detect the absence of the behavior.
A test written after the implementation can accidentally encode the implementation's bugs. A test written first describes the caller's expectation. Keep each cycle small: one behavior, one reason to fail, one clear next step.
import pytestdef test_total_adds_prices(): assert calculate_total([10.0, 20.0, 30.0]) == 60.0# First run: NameError, because calculate_total does not exist yet.# Then implement the minimum function and run again.02Make the failure informative
A useful test name describes the behavior, not the method name. test_rejects_blank_title is more informative than test_validate_2. Arrange the input, perform one action, and assert the observable result. When the test fails, the name and the assertion should tell you what broke.
Test one reason to fail per test. A test that checks creation, validation, persistence, and formatting all at once gives you a vague failure and a slow diagnosis. Split it into separate tests that can pass or fail independently.
def normalize_title(text: str) -> str: cleaned = " ".join(text.split()) if not cleaned: raise ValueError("title cannot be blank") return cleaneddef test_collapses_internal_whitespace(): assert normalize_title(" Python basics ") == "Python basics"def test_rejects_blank_title(): try: normalize_title(" ") except ValueError as exc: assert "blank" in str(exc) else: raise AssertionError("expected ValueError")03Refactor under a safety net
Refactoring changes structure without changing behavior. The suite is the evidence that behavior survived. If a refactor makes a test fail, decide whether the implementation broke the contract or the test encoded an implementation detail. Do not simply update the expected value to match the new output.
Run the suite after each small change. A large refactor followed by twenty failures is hard to diagnose; five small refactors with one failure each are easy. Commit at green points so you can return to a known state. A refactor that requires rewriting many tests usually means the tests were coupled to structure rather than behavior.
04Design for testability
Hard-to-test code usually hides a dependency: a global clock, a database created inside a function, or a network call in the middle of a calculation. Pass the dependency in, or isolate it behind a small function. The resulting design is easier to test and easier to replace.
Do not confuse testability with test coverage. A function can be easy to call and still have untested branches. Write tests for the normal case, the boundary, and the failure contract. Those three tests usually reveal more than a large number of redundant happy-path cases.
The ideas worth keeping.
- Write the test first to define behavior and expose missing code.
- Refactor only while the suite is green.
- Inject dependencies so the interesting logic can run without a real boundary.
Make it yours.
Type and test your code right here in the interactive workspace. Run your solution, check the output, and compare with the worked guide when ready.
Test-first email validation
Write a failing test for a simple email validator, then implement the smallest function that passes. Reject text without exactly one @ and without a dot after it.
Explore a worked solution
def is_valid_email(text: str) -> bool: if text.count("@") != 1: return False local, domain = text.split("@") return bool(local) and "." in domain and not domain.startswith(".") and not domain.endswith(".")assert is_valid_email("user@example.com")assert not is_valid_email("invalid")assert not is_valid_email("a@b")assert not is_valid_email("@example.com")print("validator checks passed")The test cases describe the contract before the implementation. The function is intentionally small; a production validator should use a library or a carefully reviewed pattern.
Protect an invoice boundary
Write tests for invoice_total(subtotal, tax_rate) covering a normal value, a zero rate, and a negative subtotal that raises ValueError. Implement the function so the tests pass.
Explore a worked solution
def invoice_total(subtotal: float, tax_rate: float) -> float: if subtotal < 0: raise ValueError("subtotal cannot be negative") if not 0 <= tax_rate <= 1: raise ValueError("tax rate must be between 0 and 1") return round(subtotal * (1 + tax_rate), 2)assert invoice_total(100, 0) == 100.0assert invoice_total(100, 0.2) == 120.0try: invoice_total(-1, 0.2)except ValueError: print("negative subtotal rejected")The boundary tests cover the identity case, a normal calculation, and a rejected input. The validation happens before the arithmetic so a bad value cannot produce a plausible total.
What stayed with you?
Check the ideas while they’re fresh. Your answers stay in this page and feedback appears as you go.
Test-first email validation
Write a failing test for a simple email validator, then implement the smallest function that passes. Reject text without exactly one @ and without a dot after it.
validator checks passed
Keep exploring.
The official references behind this lesson, when you want a second angle or the full details.
One lesson closer.
Reading is a start. Trying it yourself makes the connection.