- Home
- Skills
- Testing & Debugging
- Developing Features with TDD — Strict Red/Green/Refactor
Works with the AI Agents you already use
Developing Features with TDD — Strict Red/Green/Refactor
Build features test-first in nine strict phases: design, failing test, minimum code, refactor, validate, slice by slice.
Free
Developing Features with TDD — Strict Red/Green/Refactor
Example session with this skill installed
TDD this: coupon redemption at checkout.
- Python 3.12, pytest, src layout (
src/coupons/, pytest haspythonpath = ["src"]). - Percent-off coupons. Amounts are integer cents; the discount rounds down.
- A coupon can't be used at or after its
expires_at. - Each user can redeem a given coupon once.
applyis called when checkout is confirmed, so applying = redeeming. - Persistence is out of scope today: define the repository interface, we'll write the Postgres implementation next.
- Read your context and instructions
- Compiled the developing features
Summary. Five slices, each driven by a failing test: a happy path, two error cases and two adversarial cases. Persistence sits behind a CouponRepository protocol, and the clock is injected so expiry is deterministic. The code and tests below are complete. The RED and GREEN results are the expected outcomes of each step. Nothing was executed here, so run the command at the end to confirm them.
1. Requirement decomposition
| # | GIVEN | WHEN | THEN | SO THAT |
|---|---|---|---|---|
| AC1 | a valid 15% coupon and a cart of 10,000 cents | the user applies it | the discount is 1,500 and the total is 8,500 | customers get the promised price |
| AC2 | no coupon with that code | the user applies it | CouponNotFoundError | invalid codes never discount |
| AC3 | a coupon whose expires_at is now or earlier | the user applies it | CouponExpiredError | promotions end when the business says so |
| AC4 | a user who already redeemed the coupon | they apply it again | CouponAlreadyRedeemedError | one-per-user promotions can't be farmed |
2. Threat model, and the tests it requires
Reuse or replay by the same user. Test: test_should_reject_second_redemption_when_same_user_reuses_coupon. The real repository must make "check and record" atomic with a unique (code, user_id) constraint. That becomes the integration slice in the Postgres task.
Tampered cart total (negative). Test: test_should_reject_when_cart_total_is_negative. A negative total would produce a negative "discount".
- Expiry boundary. Tested at exactly
now == expires_at, not just "yesterday".
Out of scope, flagged. Brute-forcing codes needs rate limiting at the HTTP layer. Whether codes are case- and whitespace-normalized is not in the spec.
Question for you: should "save15 " match SAVE15?
3. Architecture plan
FEATURE: coupon redemption
SLICES: 1 happy path · 2 unknown code · 3 expired · 4 second redemption · 5 negative cart total
FILE STRUCTURE:
src/coupons/coupon_types.py Coupon, CouponApplication, domain errors
src/coupons/coupon_repository.py CouponRepository (Protocol)
src/coupons/coupon_service.py CouponService (business rules)
tests/coupons/test_coupon_service.py
DEPENDENCIES (injected): CouponService(repository: CouponRepository, clock: Callable[[], datetime])
DURABILITY ANCHOR: test names encode AC1–AC4 and the threats; PR "Acceptance Criteria → Tests" table
NON-OBVIOUS DECISION: redemption is recorded inside apply(), because applying is redeeming per the spec
4. Slice-by-slice build
Shared test helpers (tests/coupons/test_coupon_service.py):
from datetime import datetime, timedelta, timezone
import pytest
from coupons.coupon_service import CouponService
from coupons.coupon_types import (
Coupon, CouponAlreadyRedeemedError, CouponApplication,
CouponExpiredError, CouponNotFoundError, InvalidCartTotalError,
)
NOW = datetime(2026, 10, 8, 12, 0, tzinfo=timezone.utc)
SAVE15 = Coupon(code="SAVE15", percent_off=15, expires_at=NOW + timedelta(days=1))
class FakeCouponRepository:
def __init__(self, coupons: list[Coupon]) -> None:
self._coupons = {coupon.code: coupon for coupon in coupons}
self._redemptions: set[tuple[str, str]] = set()
def find_by_code(self, code: str) -> Coupon | None:
return self._coupons.get(code)
def record_redemption(self, code: str, user_id: str) -> bool:
if (code, user_id) in self._redemptions:
return False
self._redemptions.add((code, user_id))
return True
def make_service(*coupons: Coupon, now: datetime = NOW) -> CouponService:
return CouponService(FakeCouponRepository(list(coupons)), clock=lambda: now)
Slice 1: happy path (AC1)
def test_should_apply_percent_discount_when_coupon_is_valid():
result = make_service(SAVE15).apply(cart_total_cents=10_000, code="SAVE15", user_id="u_1")
assert result == CouponApplication(discount_cents=1_500, total_cents=8_500)
VERIFY RED (expected): ModuleNotFoundError: No module named 'coupons.coupon_service'. This is the right reason: the feature doesn't exist yet.
GREEN: create coupon_types.py (Coupon, CouponApplication) and coupon_repository.py (find_by_code). Add CouponService.apply that looks up the coupon and returns total * percent // 100 as the discount.
- VERIFY GREEN (expected): 1 passed.
Slice 2: unknown code (AC2)
def test_should_raise_not_found_when_code_is_unknown():
with pytest.raises(CouponNotFoundError):
make_service(SAVE15).apply(cart_total_cents=10_000, code="NOPE", user_id="u_1")
VERIFY RED, first attempt (expected): ImportError: cannot import name 'CouponNotFoundError' during collection. That is the
wrong reason, because it breaks slice 1 as well. Fix the plumbing: declare the empty error class in coupon_types.py, then run again.
VERIFY RED, second attempt (expected): AttributeError: 'NoneType' object has no attribute 'percent_off'. This is the right reason: the guard is missing.
- GREEN:
if coupon is None: raise CouponNotFoundError(code).
Slice 3: expired, boundary at exactly expires_at (AC3)
def test_should_raise_expired_when_now_equals_expiry():
old = Coupon(code="OLD10", percent_off=10, expires_at=NOW)
with pytest.raises(CouponExpiredError):
make_service(old, now=NOW).apply(cart_total_cents=10_000, code="OLD10", user_id="u_1")
VERIFY RED (expected): Failed: DID NOT RAISE <class 'coupons.coupon_types.CouponExpiredError'>. The error class was declared first, so the test fails for the right reason.
- GREEN: inject
clockand addif self._clock() >= coupon.expires_at: raise CouponExpiredError(code).
Slice 4: adversarial, second redemption by the same user (AC4)
def test_should_reject_second_redemption_when_same_user_reuses_coupon():
service = make_service(SAVE15)
service.apply(cart_total_cents=10_000, code="SAVE15", user_id="u_1")
with pytest.raises(CouponAlreadyRedeemedError):
service.apply(cart_total_cents=10_000, code="SAVE15", user_id="u_1")
- VERIFY RED (expected):
Failed: DID NOT RAISE.
GREEN: add record_redemption to the protocol. Raise CouponAlreadyRedeemedError when it returns False. Record only after the coupon passes all checks.
Slice 5: adversarial, negative cart total
def test_should_reject_when_cart_total_is_negative():
with pytest.raises(InvalidCartTotalError):
make_service(SAVE15).apply(cart_total_cents=-1, code="SAVE15", user_id="u_1")
- VERIFY RED (expected):
Failed: DID NOT RAISE. Without the guard, the current code returns a discount of-1. - GREEN: a guard clause that runs first, before any repository call.
REFACTOR. make_service() and FakeCouponRepository were pulled out of the test bodies after slice 2, once the setup repeated. The test assertions didn't change. The production code needs no refactor: one method, six branches, no duplication, nesting depth 1.
Final production code
# src/coupons/coupon_types.py
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class Coupon:
code: str
percent_off: int
expires_at: datetime
@dataclass(frozen=True)
class CouponApplication:
discount_cents: int
total_cents: int
class CouponNotFoundError(Exception): ...
class CouponExpiredError(Exception): ...
class CouponAlreadyRedeemedError(Exception): ...
class InvalidCartTotalError(Exception): ...
# src/coupons/coupon_repository.py
from typing import Protocol
from coupons.coupon_types import Coupon
class CouponRepository(Protocol):
def find_by_code(self, code: str) -> Coupon | None: ...
def record_redemption(self, code: str, user_id: str) -> bool:
"""Atomically record the redemption; return False if (code, user_id) already exists."""
...
# src/coupons/coupon_service.py
from collections.abc import Callable
from datetime import datetime
from coupons.coupon_repository import CouponRepository
from coupons.coupon_types import (
CouponAlreadyRedeemedError, CouponApplication, CouponExpiredError,
CouponNotFoundError, InvalidCartTotalError,
)
class CouponService:
def __init__(self, repository: CouponRepository, clock: Callable[[], datetime]) -> None:
self._repository = repository
self._clock = clock
def apply(self, cart_total_cents: int, code: str, user_id: str) -> CouponApplication:
if cart_total_cents < 0:
raise InvalidCartTotalError(cart_total_cents)
coupon = self._repository.find_by_code(code)
if coupon is None:
raise CouponNotFoundError(code)
if self._clock() >= coupon.expires_at:
raise CouponExpiredError(code)
if not self._repository.record_redemption(coupon.code, user_id):
raise CouponAlreadyRedeemedError(code)
discount = cart_total_cents * coupon.percent_off // 100
return CouponApplication(discount_cents=discount, total_cents=cart_total_cents - discount)
5. Final test run
pytest tests/coupons --cov=src/coupons --cov-branch --cov-report=term-missing
ruff check src tests && ruff format --check src tests && mypy src/coupons
Not executed in this session. The expected result is 5 tests passing. Coverage is not reported here because it hasn't been measured. Show the real branch-coverage figure from the run above, which must be at least 75%, before calling this done.
6. Decisions
- The clock is injected. Expiry tests are deterministic, and the boundary (
>=) is tested at its exact edge. - Recording happens inside
applyand comes last among the checks. Expired or unknown codes never use up a user's single redemption. Atomicity is the repository's job, through a unique(code, user_id)constraint, which is the next integration slice.
Integer cents with floor division. No floating-point rounding. The discount can never exceed the total, because percent_off comes from trusted data, not the client.
- The negative-total guard runs first. Invalid input is rejected before any data access.
Open question: code normalization (case and whitespace). It is deliberately not implemented until the spec decides.
Next steps
- Run the commands in section 5 and paste the real output and coverage.
- Next feature: a
PostgresCouponRepositorywith an integration test proving that two concurrent redemptions by the same user produce exactly one success. - Commit each slice as
feat(coupons): <slice>withSlice:,Test:andWhy:lines, and keep refactor commits separate.
Connects securely to your tools. The creator never sees your data.
About this skill
Developing Features with TDD builds a feature with strict test-driven development. Its core rule is to never write production code for a new behavior before a failing test proves that behavior is missing.
Problems it solves
- Tests written after the code that only confirm what the code already does.
- Tests that pass before the feature exists.
- Green tests on top of tangled architecture.
- No tests for abuse cases.
- LLM features shipped with no timeout, fallback or kill switch.
The cycle (strict order)
- UNDERSTAND. Each acceptance criterion becomes Given/When/Then/So-That, with a threat model.
- DESIGN. Slices, file structure, injected dependencies, threats that will become tests, and a "durability anchor" saying where each decision is recorded.
- RED. A failing test for the next slice.
- VERIFY RED. It must fail, and for the right reason.
- GREEN. The minimum code to pass.
- VERIFY GREEN.
- REFACTOR. Clean up while tests stay green.
- VALIDATE. Full suite, at least 75% branch coverage, linters, and complexity of 10 or less.
- REPEAT for the next slice.
Slices go in a set order: happy path, edge cases, errors, adversarial cases, integration seams, then AI-runtime failure modes when the feature calls an LLM.
References
cycle.md: each phase in detail, with adversarial slice examples.test-quality.md:should_X_when_Ynaming, Arrange–Act–Assert, what not to test, anti-patterns such as mockery, liar and snowball tests.ai-runtime.md: the runtime profile an LLM feature needs (timeout, circuit breaker, fallback, cost ceiling, kill switch, SLO) and the failure-mode tests it requires.checklist.md: the end-of-feature gate, per-slice commit format and PR template.
It applies every rule of developing-features unchanged (security, SOLID, file limits, observability, data layer). TDD changes how you get there, not the quality bar.
When it triggers. "TDD this", "tests first", "red green refactor", or features that must be verifiable from the spec.
Free and MIT-licensed. Part of the claude-skills-czl collection.
Source on GitHub: this skill · full repository
How to use
Skills on Agensi run inside your AI agent. Copy the prompt, paste it into your agent, and it connects to Agensi and installs this skill for you. If a payment is needed, your agent shows it to you first.
Reviews
No reviews yet
Be one of the first to try it. Every listed skill passes our trust checks below.
Security scanned
Passed our 8-point scan before listing
Fresh listing
Recently published to Agensi
Free forever
No account required to browse
Trust & safety
Security scanned
Verified clean 10 days ago
- Free to download with an account