Works with the AI Agents you already use

    Claude CodeClaude CodeCursorCursorCodex CLICodex CLIMuseMuseDotsDots+22 more

    Developing Features with TDD — Strict Red/Green/Refactor

    1

    Build features test-first in nine strict phases: design, failing test, minimum code, refactor, validate, slice by slice.

    Free

    Security scanned
    Developing Features with TDD — Strict Red/Green/Refactor

    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 has pythonpath = ["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. apply is 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

    #GIVENWHENTHENSO THAT
    AC1a valid 15% coupon and a cart of 10,000 centsthe user applies itthe discount is 1,500 and the total is 8,500customers get the promised price
    AC2no coupon with that codethe user applies itCouponNotFoundErrorinvalid codes never discount
    AC3a coupon whose expires_at is now or earlierthe user applies itCouponExpiredErrorpromotions end when the business says so
    AC4a user who already redeemed the couponthey apply it againCouponAlreadyRedeemedErrorone-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 clock and add if 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 apply and 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

    1. Run the commands in section 5 and paste the real output and coverage.
    2. Next feature: a PostgresCouponRepository with an integration test proving that two concurrent redemptions by the same user produce exactly one success.
    3. Commit each slice as feat(coupons): <slice> with Slice:, Test: and Why: 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)

    1. UNDERSTAND. Each acceptance criterion becomes Given/When/Then/So-That, with a threat model.
    2. DESIGN. Slices, file structure, injected dependencies, threats that will become tests, and a "durability anchor" saying where each decision is recorded.
    3. RED. A failing test for the next slice.
    4. VERIFY RED. It must fail, and for the right reason.
    5. GREEN. The minimum code to pass.
    6. VERIFY GREEN.
    7. REFACTOR. Clean up while tests stay green.
    8. VALIDATE. Full suite, at least 75% branch coverage, linters, and complexity of 10 or less.
    9. 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_Y naming, 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

    Listed11 days ago
    Updated4 days ago

    What's inside

    Frequently Asked Questions