Project: A Tested Payment Service
Drive a payment service with a failing test, isolate the gateway behind a Protocol, make retries idempotent, and enforce coverage in CI.
By the end, you’ll be able to…
- Practice the red-green-refactor loop on a real service boundary.
- Replace a live payment gateway with a deterministic test double.
- Make duplicate charges safe with an idempotency key.
- Enforce branch coverage without turning the threshold into a target.
01Start with a failing test
Write the smallest test that describes the next behavior: paying a valid charge returns the gateway receipt. The test references PaymentService and Charge, which do not exist yet, so it fails for the right reason. That failure proves the test is connected to the behavior rather than passing by accident.
The red step is not a formality. Read the failure and confirm it says the class or method is missing, not that the assertion is wrong. Then add only enough code to make it pass. A large implementation written before the first test hides which behavior the test actually verified.
from decimal import Decimalfrom payments import Charge, PaymentServiceclass FakeGateway: def __init__(self) -> None: self.charges = [] def charge(self, charge: Charge) -> str: self.charges.append(charge) return "receipt-1"def test_charges_once(): gateway = FakeGateway() service = PaymentService(gateway) receipt = service.pay(Charge(Decimal("25.00"), "USD", "order-1")) assert receipt == "receipt-1" assert len(gateway.charges) == 102Implement only what the test needs
The first green implementation can be small: validate the amount and currency, call the gateway, and return the receipt. Keep the gateway behind a Protocol so the service depends on the behavior it needs, not on a specific vendor SDK. The fake in the test records calls and returns a known receipt.
Validation belongs before the gateway call. A zero or negative amount, an unsupported currency, or a blank key should fail without contacting the provider. These are cheap checks that protect both the customer and the integration. Raise ValueError with a message that names the rejected field.
03Test the failure paths
A payment integration that only tests success is not tested. Add a gateway that raises a timeout, a gateway that declines the charge, and a malformed charge that fails validation. Each test should assert the observable outcome: the exception type, the message, and that no receipt was stored.
Use unittest.mock when you need to assert an interaction, such as the gateway being called exactly once. Prefer a hand-written fake when the behavior is simple and you want readable state. Both approaches isolate the service from the network; neither proves that the real provider works, so keep a small contract test against a sandbox when the integration changes.
from decimal import Decimalfrom unittest.mock import Mockfrom payments import Charge, PaymentServicegateway = Mock()gateway.charge.return_value = "receipt-9"service = PaymentService(gateway)charge = Charge(Decimal("10.00"), "USD", "order-9")assert service.pay(charge) == "receipt-9"gateway.charge.assert_called_once()gateway.charge.side_effect = TimeoutError("provider timed out")try: service.pay(Charge(Decimal("10.00"), "USD", "order-10"))except TimeoutError as exc: print(exc)print("called once")provider timed out called once
04Make coverage and CI tell the truth
Coverage measures executed lines or branches, not correctness. A line can run without its result being asserted. Use branch coverage and a threshold that reflects the critical paths, then read the missing lines. A payment service should cover validation, success, duplicate submission, decline, and timeout before it chases a percentage.
Run the same checks in CI that a developer runs locally: tests, coverage, and the type checker. Cache dependencies by lock file, upload the coverage report as an artifact, and fail the job when the threshold drops. A required check that the team routinely bypasses teaches everyone to ignore red builds.
name: teston: push: pull_request:jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" cache: pip - run: python -m pip install -r requirements.txt - run: python -m pytest --cov=payments --cov-branch --cov-fail-under=85The ideas worth keeping.
- A failing test first proves the test is connected to the behavior.
- A Protocol plus a fake keeps payment logic testable without the network.
- Idempotency keys make retries safe; coverage is evidence, not proof.
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.
Add an idempotent refund
Extend PaymentService with refund(key, amount) that returns a receipt, rejects a nonpositive amount, and returns the same receipt for a repeated key without calling the gateway twice.
Explore a worked solution
from decimal import Decimalclass RefundService: def __init__(self, gateway): self.gateway = gateway self._receipts = {} def refund(self, key: str, amount: Decimal) -> str: if amount <= 0: raise ValueError("amount must be positive") if key in self._receipts: return self._receipts[key] receipt = self.gateway.refund(key, amount) self._receipts[key] = receipt return receiptclass FakeGateway: def __init__(self): self.calls = 0 def refund(self, key, amount): self.calls += 1 return "refund-1"gateway = FakeGateway()service = RefundService(gateway)print(service.refund("order-1", Decimal("5.00")))print(service.refund("order-1", Decimal("5.00")))print(gateway.calls)The repeated key returns the stored receipt before the gateway is touched. A real refund API also needs the original charge identifier and a provider-supported idempotency mechanism.
Reject a blank idempotency key
Write two tests for Charge validation: a blank key raises ValueError, and a duplicate key calls the gateway once. Use a fake gateway that records calls.
Explore a worked solution
from decimal import Decimalclass FakeGateway: def __init__(self): self.calls = 0 def charge(self, charge): self.calls += 1 return "receipt-1"class Service: def __init__(self, gateway): self.gateway = gateway self.seen = {} def pay(self, charge): if not charge[2].strip(): raise ValueError("idempotency key cannot be blank") if charge[2] in self.seen: return self.seen[charge[2]] receipt = self.gateway.charge(charge) self.seen[charge[2]] = receipt return receiptgateway = FakeGateway()service = Service(gateway)try: service.pay((Decimal("5.00"), "USD", " "))except ValueError as exc: print(exc)service.pay((Decimal("5.00"), "USD", "order-1"))service.pay((Decimal("5.00"), "USD", "order-1"))print(gateway.calls)The first assertion proves invalid input never reaches the provider. The second proves the duplicate key is handled by the service rather than by a second network call.
What stayed with you?
Check the ideas while they’re fresh. Your answers stay in this page and feedback appears as you go.
Add an idempotent refund
Extend PaymentService with refund(key, amount) that returns a receipt, rejects a nonpositive amount, and returns the same receipt for a repeated key without calling the gateway twice.
service.refund("order-1" Decimal("5.00"))
service.refund("order-1" Decimal("5.00"))
gateway.callsKeep exploring.
The official references behind this lesson, when you want a second angle or the full details.
Pause. Put the pieces together.
You’ve reached the end of Advanced Testing Practices. Before moving on, use this task to check what you can do independently.
Write a failing test first, implement the feature to pass it, add mocks for external dependencies, and verify 80%+ test coverage.
Try it in your own environment and write down the result. This is a self-check; reading completion does not evaluate your code.
Review this moduleOne lesson closer.
Reading is a start. Trying it yourself makes the connection.