Python · Testing with pytest · Expert

Test-Driven Development

5 tasks

Drive design with tests: write failing tests first, then implement to make them pass.

Test-Driven Development: Red-Green-Refactor

#
Test-Driven Development (TDD) inverts the normal coding order: you write the test before writing any implementation code, then write just enough code to make the test pass, then improve the code. The discipline is captured in a three-phase loop called Red-Green-Refactor. ## The Red-Green-Refactor cycle **Red** — Write a test for behaviour that does not yet exist. Run it. It fails — the output is red. This is intentional and correct. A failing test before any implementation proves that your test is actually testing something real. If a brand-new test passes without any code changes, either the behaviour was already implemented elsewhere or the test is wrong. **Green** — Write the minimum code that makes the failing test pass. Do not add anything extra. No "I'll need this later" code. No polish. The sole objective is green output. Write the simplest thing that could possibly work. **Refactor** — Now that the code works, improve it. Remove duplication, rename for clarity, extract helpers, reorganise. The tests guard you: if a refactor breaks something, you hear about it immediately. This is the phase where you write good code — not in the Green phase. ``` [Red] Write failing test for the next behaviour ↓ [Green] Write minimum code to make it pass ↓ [Refactor] Improve the code while tests stay green ↓ [Red] Write the next failing test ... ``` Each loop is short — a few minutes at most. The cycle is intentionally small. You never accumulate large amounts of unverified code. ## Why write the test first? The case for test-first is not just discipline — it changes what you build and how you build it. **Tests written after code describe what the code does, not what it should do.** You already know the implementation when you write after-the-fact tests, so you unconsciously confirm what exists rather than specifying what was intended. Edge cases you didn't implement don't get tested because you didn't think to test them. **Hard-to-test code signals a design problem.** A function that needs 8 setup steps before you can call it is hard to write a test for — and hard to use in production. With TDD, you discover this before the code is written. The pain of a difficult test drives a better design. **The test is the first client of your API.** Writing a test is the first time you "call" your code. If calling it feels awkward in the test, the API is awkward. TDD surfaces API problems while you can still fix them at zero cost. **Coverage of your specification is automatic.** Every line of implementation exists because a failing test demanded it. If no test demanded a line, that line doesn't get written. ## When TDD pays off TDD delivers the most value when: - **The logic is non-trivial.** Validation rules, business constraints, state machines, parsing, calculations with multiple branching conditions — any code where correctness depends on subtle combinations of inputs. - **You are designing the API.** TDD is a design tool. A test that is easy to write suggests a good API; a painful test suggests a bad one. The friction appears before the code is committed. - **You will refactor.** Refactoring without tests is guesswork. With TDD, every behaviour has a test before any refactoring begins — you can restructure freely and trust the tests to catch regressions. - **Correctness matters more than speed.** Financial calculations, security-sensitive code, data transformations with edge cases — places where a silent wrong answer is worse than an obvious crash. ## When TDD is less valuable TDD doesn't fit every situation: - **Exploratory spikes.** When you don't know what you're building yet — prototyping an approach, learning an unfamiliar library, trying out an algorithm — write the spike first to learn, then discard it and start TDD on the real implementation. Tests on throwaway code are waste. - **Thin glue code.** Route definitions, migration files, declarative config — code with no logic to test. Testing that Django mapped `/users/` to `UserView` adds no confidence. - **UI rendering and layout.** Asserting pixel positions or animation behaviour is fragile and high-maintenance. Test the logic that feeds the UI; use lighter-weight tools for UI integration. - **Thin external API wrappers.** `return requests.get(url).json()` has nothing meaningful to test beyond "did we call requests". Such tests are brittle and don't add confidence. ## How TDD shapes code structure Code grown test-first tends to acquire certain structural properties — not because of discipline but because the tests demand it during development: **Smaller functions.** A function that requires 8 arguments or deep setup to call is painful to test. The pain appears before the code exists, so you make the function smaller. **Explicit dependencies.** If a function needs a database connection, the test has to provide one. The path of least resistance is making the dependency a parameter (dependency injection) rather than a hidden module-level import. TDD makes hidden globals and side-effectful imports immediately painful. **More pure functions.** Functions that take arguments and return values — no shared state, no file I/O, no network — are the easiest to test. TDD consistently nudges you toward writing more of them. **Minimal, cohesive classes.** A class that does too much requires too much setup in each test. The test friction signals the design problem before it is baked in. ## Common objections **"TDD takes twice as long."** The test has to be written at some point — the question is when. Writing it first guides the implementation and often makes it faster. The real time sink is after-the-fact debugging of untested code. TDD shifts cost from debugging to design, which is a better trade. **"I don't know what I'm building yet."** You don't need to know the whole design upfront. You only need to know the next behaviour. Write one test for the smallest next thing. TDD is incremental — you discover the design through the tests, not before them. **"My codebase is untestable."** Untestable code is a symptom of design problems: tight coupling, hidden dependencies, global state. TDD applied to new code prevents these from forming. Applied to existing code, TDD acts as a pressure valve — it makes the design problems visible as test pain, motivating you to fix them.

TDD in Practice: Building BankAccount Step by Step

#
## The application we will build A `BankAccount` class with `deposit`, `withdraw`, and `balance`, grown using strict TDD — one test at a time, minimum code to pass, then refactor. Two files: ``` bank.py # production code — starts empty test_bank.py # tests — grows one test at a time ``` At the start, both files are empty. --- ## Cycle 1: a new account has zero balance **Red** — write the first test: ```python # test_bank.py from bank import BankAccount def test_new_account_has_zero_balance(): account = BankAccount() assert account.balance == 0 ``` Run it: ``` $ pytest test_bank.py -v FAILED - ModuleNotFoundError: No module named 'bank' ``` Good — it fails. We have permission to write code. **Green** — absolute minimum: ```python # bank.py class BankAccount: def __init__(self): self.balance = 0 ``` ``` 1 passed ``` **Refactor** — nothing to clean up yet. --- ## Cycle 2: depositing money increases the balance **Red:** ```python def test_deposit_increases_balance(): account = BankAccount() account.deposit(100) assert account.balance == 100 ``` ``` FAILED - AttributeError: 'BankAccount' object has no attribute 'deposit' ``` **Green:** ```python def deposit(self, amount): self.balance += amount ``` ``` 2 passed ``` **Refactor** — still nothing to deduplicate. --- ## Cycle 3: withdrawing money decreases the balance **Red:** ```python def test_withdraw_decreases_balance(): account = BankAccount() account.deposit(200) account.withdraw(50) assert account.balance == 150 ``` **Green:** ```python def withdraw(self, amount): self.balance -= amount ``` ``` 3 passed ``` --- ## Cycle 4: overdraft raises ValueError Here the test forces us to specify the contract — what error type, what message — before any code exists: **Red:** ```python import pytest def test_withdraw_raises_when_insufficient_funds(): account = BankAccount() account.deposit(100) with pytest.raises(ValueError, match="Insufficient funds"): account.withdraw(150) ``` ``` FAILED - DID NOT RAISE <class 'ValueError'> ``` **Green:** ```python def withdraw(self, amount): if amount > self.balance: raise ValueError("Insufficient funds") self.balance -= amount ``` ``` 4 passed ``` The error message `"Insufficient funds"` was decided in the test — the specification drives the implementation detail. --- ## Cycle 5: deposit must be a positive amount **Red:** ```python def test_deposit_rejects_non_positive_amount(): account = BankAccount() with pytest.raises(ValueError, match="Amount must be positive"): account.deposit(-50) ``` **Green:** ```python def deposit(self, amount): if amount <= 0: raise ValueError("Amount must be positive") self.balance += amount ``` ``` 5 passed ``` --- ## Cycle 6: withdraw amount must also be positive Withdrawing zero or a negative number would silently corrupt the balance. The test catches this before it can happen: **Red:** ```python def test_withdraw_rejects_non_positive_amount(): account = BankAccount() account.deposit(100) with pytest.raises(ValueError, match="Amount must be positive"): account.withdraw(0) ``` **Green:** ```python def withdraw(self, amount): if amount <= 0: raise ValueError("Amount must be positive") if amount > self.balance: raise ValueError("Insufficient funds") self.balance -= amount ``` ``` 6 passed ``` --- ## Refactor: extract `_validate_positive` Both `deposit` and `withdraw` now have identical validation. Before this grows further, extract it: **Step 1** — add the helper (all 6 tests still pass): ```python def _validate_positive(self, amount): if amount <= 0: raise ValueError("Amount must be positive") ``` **Step 2** — replace the duplicated checks (all 6 tests still pass): ```python def deposit(self, amount): self._validate_positive(amount) self.balance += amount def withdraw(self, amount): self._validate_positive(amount) if amount > self.balance: raise ValueError("Insufficient funds") self.balance -= amount ``` ``` 6 passed ``` The refactor is safe because every behaviour has a test. --- ## Final state of both files ```python # test_bank.py import pytest from bank import BankAccount def test_new_account_has_zero_balance(): account = BankAccount() assert account.balance == 0 def test_deposit_increases_balance(): account = BankAccount() account.deposit(100) assert account.balance == 100 def test_withdraw_decreases_balance(): account = BankAccount() account.deposit(200) account.withdraw(50) assert account.balance == 150 def test_withdraw_raises_when_insufficient_funds(): account = BankAccount() account.deposit(100) with pytest.raises(ValueError, match="Insufficient funds"): account.withdraw(150) def test_deposit_rejects_non_positive_amount(): account = BankAccount() with pytest.raises(ValueError, match="Amount must be positive"): account.deposit(-50) def test_withdraw_rejects_non_positive_amount(): account = BankAccount() account.deposit(100) with pytest.raises(ValueError, match="Amount must be positive"): account.withdraw(0) ``` ```python # bank.py class BankAccount: def __init__(self): self.balance = 0 def deposit(self, amount): self._validate_positive(amount) self.balance += amount def withdraw(self, amount): self._validate_positive(amount) if amount > self.balance: raise ValueError("Insufficient funds") self.balance -= amount def _validate_positive(self, amount): if amount <= 0: raise ValueError("Amount must be positive") ``` --- ## What TDD gave us - 6 behaviours, each specified by a test before the code existed. - Error messages were decided in the tests — the specification drives implementation details, not the other way around. - The `_validate_positive` helper emerged from refactoring, not upfront design. The test suite made it safe to extract. - There is no code in `bank.py` that isn't covered by a test — every line is there because a failing test demanded it. - At any point in the cycle, running `pytest -v` tells you exactly where you stand.

TDD Reference: Cycle, Naming, and Patterns

#
## The TDD cycle ``` 1. [Red] Write a failing test for the next behaviour 2. [Green] Write the minimum code to make it pass — nothing extra 3. [Refactor] Clean up the code while all tests stay green 4. Repeat for the next behaviour ``` ## pytest commands for TDD ```bash # Stop at first failure — fastest feedback while implementing pytest -x # Run only tests matching a name pattern pytest -k "withdraw" # Run one specific test pytest test_bank.py::test_withdraw_raises_when_insufficient_funds -v # Verbose: see every test name and pass/fail pytest -v # Watch mode: re-run on file save (requires pytest-watch) pip install pytest-watch ptw -- -x ``` ## Test naming for TDD Good names read as a specification. Pattern: `test_<subject>_<behaviour>_<condition>`. | Name | Readable as | |---|---| | `test_new_account_has_zero_balance` | "A new account has zero balance" | | `test_withdraw_raises_when_insufficient_funds` | "Withdraw raises when funds are insufficient" | | `test_deposit_rejects_non_positive_amount` | "Deposit rejects non-positive amounts" | | `test_email_validator_returns_false_for_empty_string` | Condition is explicit | Avoid: `test_bank_1`, `test_edge_case`, `test_stuff`. When a test fails, its name should tell you what broke without reading the body. ## Verifying the Red phase A new test that passes immediately (without code changes) means one of two things: 1. The behaviour was already implemented → keep the test as a regression guard. 2. The test is wrong → it's not actually exercising the behaviour you think. To verify: temporarily sabotage the implementation and confirm the test goes red: ```python def deposit(self, amount): pass # deliberately broken # If test_deposit_increases_balance now FAILS → test is correct # If it still PASSES → the test is not testing deposit ``` Restore the implementation after verifying. ## Refactoring safely Rule: **all tests must pass before and after every refactor step**. Never refactor while any test is red. ```python # Before: duplication in two methods def deposit(self, amount): if amount <= 0: raise ValueError("Amount must be positive") self.balance += amount def withdraw(self, amount): if amount <= 0: raise ValueError("Amount must be positive") ... # Refactor step 1: add helper (tests still pass) def _validate_positive(self, amount): if amount <= 0: raise ValueError("Amount must be positive") # Refactor step 2: replace duplicates (tests still pass) def deposit(self, amount): self._validate_positive(amount) self.balance += amount ``` Make one small change, run tests, confirm green, then make the next change. ## TDD with parametrize `@pytest.mark.parametrize` maps naturally to TDD: write the full spec (all test cases) before any implementation. All cases fail simultaneously (Red); one implementation makes them all pass (Green): ```python import pytest @pytest.mark.parametrize("amount,is_valid", [ (100, True), (0.01, True), (0, False), (-50, False), ]) def test_deposit_validates_amount(amount, is_valid): account = BankAccount() if is_valid: account.deposit(amount) assert account.balance == pytest.approx(amount) else: with pytest.raises(ValueError): account.deposit(amount) ``` The parametrize output shows each case separately — you can track progress through the Red phase as cases turn green one by one. ## Characterization tests for legacy code When adding tests to existing code without tests, write "characterization tests" — tests that document what the code currently does: ```python # Step 1: write tests to capture current behaviour (they pass immediately) def test_word_count_splits_on_spaces(): stats = TextStats("hello world foo") assert stats.word_count() == 3 # Step 2: refactor implementation while keeping tests green # Step 3: add new tests for new behaviour ``` The tests aren't specifying what the code should do — they're capturing what it does. Once captured, you can refactor safely. ## Making time-dependent code testable Inject time as a dependency rather than calling `time.time()` directly: ```python import time class RateLimiter: def __init__(self, calls_per_second, time_fn=None): self.limit = calls_per_second self._time = time_fn if time_fn is not None else time.time # None = use real clock self._window_start = self._time() self._call_count = 0 def allow(self): now = self._time() if now - self._window_start >= 1.0: self._window_start = now self._call_count = 0 if self._call_count < self.limit: self._call_count += 1 return True return False ``` In tests, pass a fake clock — no `time.sleep` needed: ```python fake_time = [0.0] limiter = RateLimiter(2, time_fn=lambda: fake_time[0]) fake_time[0] = 1.5 # advance time instantly ``` ## Common TDD mistakes | Mistake | Consequence | Fix | |---|---|---| | Writing several tests before any Green | Cascade of failures, hard to isolate | One test at a time | | Green phase adds more code than needed | Unspecified behaviour with no tests | Make only the failing test pass | | Skipping Refactor phase | Technical debt accumulates each cycle | Always refactor, even briefly | | Not confirming Red before coding | Test may not be testing anything real | Always watch the test fail first | | Testing implementation internals | Tests break on refactor, not on behaviour change | Test behaviour (inputs/outputs), not internals | | Refactoring in large steps | Hard to pinpoint which step broke a test | Small steps, run tests between each |
01

Implement a Stack class using TDD

#

Using TDD, implement a `Stack` class with four methods: `push(item)`, `pop()`, `peek()`, and `is_empty()`. **Step 1 -- Red:** Fill in all 7 test bodies first. Run pytest -- all 7 should fail with `AttributeError` or `AssertionError`. Do not touch `stack.py` yet. Tests to write: 1. `test_new_stack_is_empty` -- `Stack().is_empty()` returns `True` 2. `test_push_makes_stack_non_empty` -- after `push(1)`, `is_empty()` returns `False` 3. `test_pop_returns_last_pushed_item` -- push 1, 2, 3; `pop()` returns `3` 4. `test_pop_removes_the_item` -- after push and pop, `is_empty()` returns `True` 5. `test_peek_returns_top_without_removing` -- `peek()` returns the top item; `is_empty()` is still `False` afterwards 6. `test_pop_raises_on_empty_stack` -- `pop()` on empty stack raises `IndexError` 7. `test_peek_raises_on_empty_stack` -- `peek()` on empty stack raises `IndexError` **Step 2 -- Green/Refactor:** Implement `Stack` one method at a time, running pytest after each addition.

# stack.py
class Stack:
    pass


# test_stack.py
import pytest
from stack import Stack


def test_new_stack_is_empty():
    pass


def test_push_makes_stack_non_empty():
    pass


def test_pop_returns_last_pushed_item():
    pass


def test_pop_removes_the_item():
    pass


def test_peek_returns_top_without_removing():
    pass


def test_pop_raises_on_empty_stack():
    pass


def test_peek_raises_on_empty_stack():
    pass
Solution
# stack.py
class Stack:
    def __init__(self):
        self._items = []

    def push(self, item):
        self._items.append(item)

    def pop(self):
        if self.is_empty():
            raise IndexError("pop from empty stack")
        return self._items.pop()

    def peek(self):
        if self.is_empty():
            raise IndexError("peek at empty stack")
        return self._items[-1]

    def is_empty(self):
        return len(self._items) == 0


# test_stack.py
import pytest
from stack import Stack


def test_new_stack_is_empty():
    stack = Stack()
    assert stack.is_empty() is True


def test_push_makes_stack_non_empty():
    stack = Stack()
    stack.push(1)
    assert stack.is_empty() is False


def test_pop_returns_last_pushed_item():
    stack = Stack()
    stack.push(1)
    stack.push(2)
    stack.push(3)
    assert stack.pop() == 3


def test_pop_removes_the_item():
    stack = Stack()
    stack.push(42)
    stack.pop()
    assert stack.is_empty() is True


def test_peek_returns_top_without_removing():
    stack = Stack()
    stack.push(10)
    stack.push(20)
    assert stack.peek() == 20
    assert stack.is_empty() is False


def test_pop_raises_on_empty_stack():
    stack = Stack()
    with pytest.raises(IndexError):
        stack.pop()


def test_peek_raises_on_empty_stack():
    stack = Stack()
    with pytest.raises(IndexError):
        stack.peek()
02

Write tests first, then implement validate_email

#

Using TDD, implement `validate_email(s: str) -> bool`. **Step 1 — Red:** Write all 5 test bodies first. Run pytest — all 5 fail because the function always returns `None` (which is falsy). Do not implement anything yet. Required tests: 1. `test_valid_email_returns_true` — `"[email protected]"` → `True` 2. `test_missing_at_sign_returns_false` — `"userexample.com"` → `False` 3. `test_missing_local_part_returns_false` — `"@example.com"` → `False` 4. `test_missing_domain_returns_false` — `"user@"` → `False` 5. `test_empty_string_returns_false` — `""` → `False` **Step 2 — Green:** Implement one validation rule at a time. After each rule, run pytest. Add only the code needed to make the currently-failing test pass.

# email_validator.py
def validate_email(s: str) -> bool:
    pass


# test_email.py
from email_validator import validate_email


def test_valid_email_returns_true():
    pass


def test_missing_at_sign_returns_false():
    pass


def test_missing_local_part_returns_false():
    pass


def test_missing_domain_returns_false():
    pass


def test_empty_string_returns_false():
    pass
Solution
# email_validator.py
def validate_email(s: str) -> bool:
    if not s:
        return False
    if '@' not in s:
        return False
    local, _, domain = s.partition('@')
    if not local:
        return False
    if not domain:
        return False
    return True


# test_email.py
from email_validator import validate_email


def test_valid_email_returns_true():
    assert validate_email("[email protected]") is True


def test_missing_at_sign_returns_false():
    assert validate_email("userexample.com") is False


def test_missing_local_part_returns_false():
    assert validate_email("@example.com") is False


def test_missing_domain_returns_false():
    assert validate_email("user@") is False


def test_empty_string_returns_false():
    assert validate_email("") is False
03

Write characterization tests for legacy code, then refactor safely

#

You are given a `TextStats` class with existing behaviour but no tests. Your task has two parts: **Part 1 — Characterize:** Write 4 tests that document the current behaviour. These tests should pass immediately once you fill them in (the code already works). They are not specifying new behaviour — they are capturing existing behaviour so refactoring is safe. Tests to write: 1. `test_word_count` — `TextStats("hello world foo").word_count()` returns `3` 2. `test_char_count` — `TextStats("hello").char_count()` returns `5` 3. `test_most_common_word` — `TextStats("cat dog cat").most_common_word()` returns `"cat"` 4. `test_most_common_word_is_case_insensitive` — `TextStats("Cat cat Dog").most_common_word()` returns `"cat"` **Part 2 — Refactor:** Once all 4 tests pass, rewrite `most_common_word` using `collections.Counter`. Run the tests after — they must still pass. ```python # text_stats.py — given, do not modify in Part 1 class TextStats: def __init__(self, text: str): self.text = text def word_count(self): return len(self.text.split()) def char_count(self): return len(self.text) def most_common_word(self): words = self.text.lower().split() counts = {} for word in words: if word in counts: counts[word] += 1 else: counts[word] = 1 best, best_count = None, 0 for word, count in counts.items(): if count > best_count: best, best_count = word, count return best ```

# text_stats.py — given, do not modify in Part 1
class TextStats:
    def __init__(self, text: str):
        self.text = text

    def word_count(self):
        return len(self.text.split())

    def char_count(self):
        return len(self.text)

    def most_common_word(self):
        words = self.text.lower().split()
        counts = {}
        for word in words:
            if word in counts:
                counts[word] += 1
            else:
                counts[word] = 1
        best, best_count = None, 0
        for word, count in counts.items():
            if count > best_count:
                best, best_count = word, count
        return best


# test_text_stats.py
from text_stats import TextStats


def test_word_count():
    pass


def test_char_count():
    pass


def test_most_common_word():
    pass


def test_most_common_word_is_case_insensitive():
    pass
Solution
# test_text_stats.py
from text_stats import TextStats


def test_word_count():
    stats = TextStats("hello world foo")
    assert stats.word_count() == 3


def test_char_count():
    stats = TextStats("hello")
    assert stats.char_count() == 5


def test_most_common_word():
    stats = TextStats("cat dog cat")
    assert stats.most_common_word() == "cat"


def test_most_common_word_is_case_insensitive():
    stats = TextStats("Cat cat Dog")
    assert stats.most_common_word() == "cat"


# text_stats.py — after refactor
from collections import Counter

class TextStats:
    def __init__(self, text: str):
        self.text = text

    def word_count(self):
        return len(self.text.split())

    def char_count(self):
        return len(self.text)

    def most_common_word(self):
        words = self.text.lower().split()
        if not words:
            return None
        return Counter(words).most_common(1)[0][0]
04

TDD with a time-dependent RateLimiter — inject the clock

#

Using TDD, implement a `RateLimiter(calls_per_second, time_fn=None)` class with a single method `allow() -> bool`. The `time_fn` parameter accepts any callable returning the current time as a float. Defaulting to `time.time`. This makes the clock injectable — tests pass a fake clock instead of sleeping. **Step 1 — Red:** Write all 5 tests using a fake clock (no `time.sleep`). All fail because `RateLimiter` doesn't exist yet. Tests to write: 1. `test_first_call_is_always_allowed` 2. `test_calls_within_limit_are_allowed` — at limit=2 two calls at t=0 are both `True` 3. `test_calls_exceeding_limit_are_denied` — at limit=2 the third call at t=0 is `False` 4. `test_calls_reset_after_one_second` — after advancing fake time to 1.0, calls are allowed again 5. `test_two_limiters_are_independent` — exhausting one limiter does not affect another **Step 2 — Green/Refactor:** Implement `RateLimiter` using a fixed 1-second window. The window resets when `now - window_start >= 1.0`.

# rate_limiter.py
class RateLimiter:
    pass


# test_rate_limiter.py
from rate_limiter import RateLimiter


def test_first_call_is_always_allowed():
    pass


def test_calls_within_limit_are_allowed():
    pass


def test_calls_exceeding_limit_are_denied():
    pass


def test_calls_reset_after_one_second():
    pass


def test_two_limiters_are_independent():
    pass
Solution
# rate_limiter.py
import time

class RateLimiter:
    def __init__(self, calls_per_second, time_fn=None):
        self.limit = calls_per_second
        self._time = time_fn if time_fn is not None else time.time
        self._window_start = self._time()
        self._call_count = 0

    def allow(self):
        now = self._time()
        if now - self._window_start >= 1.0:
            self._window_start = now
            self._call_count = 0
        if self._call_count < self.limit:
            self._call_count += 1
            return True
        return False


# test_rate_limiter.py
from rate_limiter import RateLimiter


def test_first_call_is_always_allowed():
    fake_time = [0.0]
    limiter = RateLimiter(2, time_fn=lambda: fake_time[0])
    assert limiter.allow() is True


def test_calls_within_limit_are_allowed():
    fake_time = [0.0]
    limiter = RateLimiter(2, time_fn=lambda: fake_time[0])
    assert limiter.allow() is True
    assert limiter.allow() is True


def test_calls_exceeding_limit_are_denied():
    fake_time = [0.0]
    limiter = RateLimiter(2, time_fn=lambda: fake_time[0])
    limiter.allow()
    limiter.allow()
    assert limiter.allow() is False


def test_calls_reset_after_one_second():
    fake_time = [0.0]
    limiter = RateLimiter(2, time_fn=lambda: fake_time[0])
    limiter.allow()
    limiter.allow()            # limit reached at t=0
    fake_time[0] = 1.0        # advance the fake clock
    assert limiter.allow() is True   # new window, allowed again


def test_two_limiters_are_independent():
    fake_time = [0.0]
    a = RateLimiter(1, time_fn=lambda: fake_time[0])
    b = RateLimiter(1, time_fn=lambda: fake_time[0])
    a.allow()                  # exhaust a's limit
    assert b.allow() is True   # b is unaffected
05

TDD with parametrize: specify the contract first, then implement

#

Using TDD with `@pytest.mark.parametrize`, implement `to_roman(n: int) -> str` that converts a positive integer to its Roman numeral string. **Step 1 — Red:** The parametrized test is already written below. Run it — all 10 cases fail because `to_roman` returns `None`. Do not implement anything yet. Read all 10 cases to understand the full contract. **Step 2 — Green:** Implement `to_roman`. A clean approach: a list of `(value, numeral)` pairs in descending order, including the subtractive cases (`900 → "CM"`, `400 → "CD"`, `90 → "XC"`, etc.). Loop through: while `n >= value`, append the numeral and subtract the value. **Step 3 — Refactor:** Once all 10 cases pass, review the implementation. Is the lookup table in the right order? Are all subtractive pairs present? ```python # test_roman.py — the spec is already written; run it as-is import pytest from roman import to_roman @pytest.mark.parametrize("n,expected", [ (1, "I"), (4, "IV"), (9, "IX"), (14, "XIV"), (40, "XL"), (90, "XC"), (400, "CD"), (900, "CM"), (1994, "MCMXCIV"), (2024, "MMXXIV"), ]) def test_to_roman(n, expected): assert to_roman(n) == expected ```

# roman.py
def to_roman(n: int) -> str:
    pass


# test_roman.py — do not modify; run pytest and watch all 10 fail
import pytest
from roman import to_roman


@pytest.mark.parametrize("n,expected", [
    (1,    "I"),
    (4,    "IV"),
    (9,    "IX"),
    (14,   "XIV"),
    (40,   "XL"),
    (90,   "XC"),
    (400,  "CD"),
    (900,  "CM"),
    (1994, "MCMXCIV"),
    (2024, "MMXXIV"),
])
def test_to_roman(n, expected):
    assert to_roman(n) == expected
Solution
# roman.py
def to_roman(n: int) -> str:
    values = [
        (1000, "M"),  (900, "CM"), (500, "D"),  (400, "CD"),
        (100,  "C"),  (90,  "XC"), (50,  "L"),  (40,  "XL"),
        (10,   "X"),  (9,   "IX"), (5,   "V"),  (4,   "IV"),
        (1,    "I"),
    ]
    result = ""
    for value, numeral in values:
        while n >= value:
            result += numeral
            n -= value
    return result


# test_roman.py — unchanged
import pytest
from roman import to_roman


@pytest.mark.parametrize("n,expected", [
    (1,    "I"),
    (4,    "IV"),
    (9,    "IX"),
    (14,   "XIV"),
    (40,   "XL"),
    (90,   "XC"),
    (400,  "CD"),
    (900,  "CM"),
    (1994, "MCMXCIV"),
    (2024, "MMXXIV"),
])
def test_to_roman(n, expected):
    assert to_roman(n) == expected