LESSON 53 · INTERMEDIATE4 min read

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.

Python 3.12+Free to readModule 11 of 19
YOUR NEXT SMALL WINS

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.

test_payment.pyPython
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) == 1

02Implement 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.

payments.py🐍Python
Ctrl + ↵
Output
Click Run or press Ctrl+Enter
Output will appear here after you run the code.

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.

failure_tests.pyPython
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")
Outputstdout
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.

.github/workflows/test.ymlYAML
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=85
LET IT CLICK

The 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.
TURN UNDERSTANDING INTO SOMETHING YOU CAN DO

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.

01

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.

your_turn_1.py🐍Python
Ctrl + ↵
Output
Click Run or press Ctrl+Enter
Output will appear here after you run the code.
Explore a worked solution
one_possible_solution.pyPython
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.

02

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.

your_turn_2.py🐍Python
Ctrl + ↵
Output
Click Run or press Ctrl+Enter
Output will appear here after you run the code.
Explore a worked solution
one_possible_solution.pyPython
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.

A SMALL QUESTION. A USEFUL NEXT STEP.

What stayed with you?

Check the ideas while they’re fresh. Your answers stay in this page and feedback appears as you go.

A LITTLE ACTIVE RECALL0 / 3 answered
01What is the correct red-green-refactor order?
02Why use a fake gateway in service tests?
03What does branch coverage add over line coverage?
⚡CODING CHALLENGE
🔒Required to Mark Lesson Complete

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.

EXPECTED OUTPUT
service.refund("order-1" Decimal("5.00"))
service.refund("order-1" Decimal("5.00"))
gateway.calls
main.pyPython 3
Ctrl + ↵
Execution Console
Click "Run & Verify" (or press Ctrl + Enter) to test your code against the challenge.

Keep exploring.

The official references behind this lesson, when you want a second angle or the full details.

MODULE 11 · PRACTICAL SELF-CHECK

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 module

One lesson closer.

Reading is a start. Trying it yourself makes the connection.

Solve the hands-on coding challenge in the knowledge section to verify your code and unlock lesson completion.