test-driven-development

Enforce strict test-driven development with the Iron Law: no production code without a failing test first. Use when implementing any feature, fixing any bug, or modifying any behavior. Covers Red-Gree

SKILL.md

GitHub

Overview

Test-Driven Development is the single most impactful engineering practice for AI coding agents. Without it, agents produce untested, unreliable code. CodeHands enforces the strictest TDD discipline: the Iron Law.

When to Use

  • When implementing ANY new feature or behavior
  • When fixing ANY bug (use the Prove-It Pattern)
  • When refactoring existing code (tests must pass before and after)
  • When modifying API contracts (update contract tests first)
  • NOT when writing documentation, configuration, or non-code files

The Iron Law

NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST.

If you wrote production code before writing a test, delete it. Not "save it as reference." Not "keep it and add a test." DELETE it. Then write the test. Then rewrite the production code to make the test pass.

This is not a guideline. This is the law.

Why deletion, not "just add a test after"? Because:

  1. Tests written after the code test the implementation, not the requirement
  2. You cannot verify the test would have caught a real bug if it was written to match existing code
  3. It reinforces the discipline that prevents the rationalization spiral

Process

Phase 1: RED — Write a Failing Test

  1. Understand the requirement. What should the code do? What are the inputs and outputs?
  2. Write a test that describes the desired behavior. Use DAMP names (Descriptive And Meaningful Phrases):
    GOOD: test_returns_404_when_user_not_found()
    BAD:  test_get_user_error()
    
  3. Run the test. Confirm it FAILS. If it passes, your test is wrong — it's not testing new behavior.
  4. Read the failure message. It should clearly describe what's missing.

Phase 2: GREEN — Make It Pass

  1. Write the MINIMUM code to make the test pass. Not elegant code. Not complete code. The minimum.
  2. Run the test. Confirm it PASSES.
  3. Run the full test suite. Confirm nothing else broke.

Phase 3: REFACTOR — Clean Up

  1. Now improve the code. Extract functions, rename variables, remove duplication.
  2. Run the test suite after every change. If tests fail, your refactoring changed behavior — undo it.
  3. Commit. The test and the code go in the same commit.

Repeat

Each cycle should take 2-10 minutes. If a cycle takes longer than 15 minutes, you're writing too much at once — break it down.

Test Pyramid

Follow the test pyramid for balanced coverage:

        ╱  E2E  ╲          5% — Full user journeys
       ╱─────────╲
      ╱Integration╲       15% — Component boundaries
     ╱─────────────╲
    ╱    Unit Tests  ╲    80% — Individual functions/modules
   ╱─────────────────╲
Level Scope Speed # of Tests
Unit Single function/module < 10ms Many (80%)
Integration Component boundaries, API contracts < 1s Moderate (15%)
E2E Full user journeys Seconds Few (5%)

The Beyonce Rule: "If you liked it, you should have put a test on it." If a behavior is important enough to keep, it's important enough to test.

The Prove-It Pattern (Bug Fixes)

For every bug fix, follow this exact sequence:

  1. Reproduce — Write a test that exposes the bug. Run it. It MUST fail.
  2. Fix — Implement the minimal fix.
  3. Verify — Run the reproduction test. It MUST pass.
  4. Regression — Run the full test suite. Nothing else should break.

This proves the bug was real, the fix works, and nothing regressed. Skip any step and you haven't proven anything.

Test Quality Standards

DAMP Over DRY in Tests

Tests should be Descriptive And Meaningful Phrases, even at the cost of some repetition:

# DAMP (preferred) — each test tells a complete story
def test_login_fails_with_expired_token():
    user = create_user(email="test@example.com")
    token = create_token(user, expired=True)
    response = login(token=token)
    assert response.status == 401
    assert response.error == "Token expired"

# DRY (avoid) — shared setup hides intent
def test_expired_token():
    response = self.login(self.expired_token)
    self.assert_unauthorized(response)

What Makes a Bad Test

  • Tests implementation, not behavior. If you can refactor without changing behavior and the test breaks, it's a bad test.
  • Flaky. If it fails intermittently, fix or delete it. Flaky tests erode trust.
  • Non-deterministic. Tests that depend on time, random numbers, or external services without mocking.
  • Snapshot abuse. Snapshot tests for complex objects create maintenance burden with no behavioral insight.

Common Rationalizations

Rationalization Reality
"I'll write the tests after the code works" This is the #1 cause of untested production code. Once code "works," there's no incentive to test it. Write tests first.
"This function is too simple to test" If it's too simple to test, it's too simple to break. But it CAN break — via integration, refactoring, or dependency changes. Test it.
"I need to see if the approach works first" Use a spike branch. Throw the spike away. Then TDD the real implementation. Spikes that become production code are the enemy.
"The test would just be testing the framework" If your code delegates to a framework, test the delegation — not the framework internals. Verify you're calling the right method with the right arguments.
"There's no existing test infrastructure" Setting up the test framework IS the first task. Create the test runner, write one trivial test, verify it passes. Then proceed.
"I already know this code works" You don't know it works. You believe it works. Belief is not evidence. Write the test.
"I'll keep the code as reference and add tests" No. Delete the code. If you can't rewrite it from the test, you didn't understand it well enough to ship it.
"TDD doesn't work for UIs" TDD works for UI logic, state management, and data transformation. Use component tests. Visual aspects can use snapshot tests sparingly.

Red Flags

  • Production code committed without corresponding tests
  • Tests that were clearly written after the code (test names mirror implementation, not behavior)
  • Test file added in a DIFFERENT commit than the production code
  • "I tested it manually" as justification
  • Test suite not run before committing
  • High code coverage but tests that don't assert meaningful behavior
  • Zero test failures in a development session (suggests tests aren't being written first)
  • Spike code that becomes production code without being rewritten via TDD

Verification

  • Every new behavior has a corresponding test committed in the same changeset
  • Tests were written BEFORE production code (Red → Green → Refactor sequence followed)
  • All tests pass: npm test / pytest / go test ./... / equivalent
  • Test names describe behavior, not implementation (DAMP naming)
  • Bug fixes include a reproduction test that proves the fix
  • Test pyramid is balanced (not all E2E, not all unit)
  • No flaky tests introduced

See Also

  • codehands:verification-before-completion — Confirm work is actually done
  • codehands:systematic-debugging — When tests reveal deeper issues
  • codehands:code-review-and-quality — Tests reviewed alongside code
  • references/testing-patterns.md — Framework-specific test patterns
  • references/testing-anti-patterns.md — Common testing mistakes