SuperAPI AI Directory

aile-tdd

Testing & quality

View repository

Updated 10-16 08:39

About this skill

Test-Driven Development (TDD)

Source Original Skill

  • Source: superpowers TDD capability (migrated to aile-only)
  • Strategy: Keep the RED → GREEN → REFACTOR closed loop and align with the team's stage workflow.

Overview

Write the test first. Watch it fail. Write the minimum code to pass.

Core principle: If you didn't see the test fail, you don't know whether it tested the right thing.

**Violating the letter of the rules is violating the spirit of the rules. **

When to use

Always:

  • New features
  • Bug fixes
  • Refactoring
  • Behavior changes

Exceptions (ask your human partner):

  • One-off prototypes
  • Generated code
  • Configuration files

Thinking "skip TDD this time"? Stop. That's rationalization.

Iron law

NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST

Write code before the test? Delete it. Start over.

No exceptions:

  • Don't keep it as a "reference"
  • Don't "adapt" it when writing tests
  • Don't look at it
  • Delete means delete

Implement the new test. Period.

Red Green Refactor

digraph tdd_cycle {
    rankdir=LR;
    red [label="RED\nWrite failing test", shape=box, style=filled, fillcolor="#ffcccc"];
    verify_red [label="Verify fails\ncorrectly", shape=diamond];
    green [label="GREEN\nMinimal code", shape=box, style=filled, fillcolor="#ccffcc"];
    verify_green [label="Verify passes\nAll green", shape=diamond];
    refactor [label="REFACTOR\nClean up", shape=box, style=filled, fillcolor="#ccccff"];
    next [label="Next", shape=ellipse];

    red -> verify_red;
    verify_red -> green [label="yes"];
    verify_red -> red [label="wrong\nfailure"];
    green -> verify_green;
    verify_green -> refactor [label="yes"];
    verify_green -> green [label="no"];
    refactor -> verify_green [label="stay\ngreen"];
    verify_green -> next;
    next -> red;
}

Red - Write a failing test

Write a minimal test to show what should happen.

<Good>

test('retries failed operations 3 times', async () => {
  let attempts = 0;
  const operation = () => {
    attempts++;
    if (attempts < 3) throw new Error('fail');
    return 'success';
  };

  const result = await retryOperation(operation);

  expect(result).toBe('success');
  expect(attempts).toBe(3);
});

Clear name, tests real behavior, one thing </Good>

<Bad>

test('retry works', async () => {
  const mock = jest.fn()
    .mockRejectedValueOnce(new Error())
    .mockRejectedValueOnce(new Error())
    .mockResolvedValueOnce('success');
  await retryOperation(mock);
  expect(mock).toHaveBeenCalledTimes(3);
});

Vague name, tests mocks instead of code </Bad>

Requirements:

  • One behavior
  • Clear name
  • Real code (no mocks unless unavoidable)

Verify red - Watch it fail

**Mandatory. Never skip. **

npm test path/to/test.test.ts

Confirm:

  • Test fails (not errors)
  • The expected failure message appears
  • It fails due to missing functionality (not a typo)

**Test passes? ** You are testing existing behavior. Fix the test.

**Test errors? ** Fix the error, rerun, until it fails correctly.

Green - Minimal code

Write the simplest code to pass the test.

<Good>

async function retryOperation<T>(fn: () => Promise<T>): Promise<T> {
  for (let i = 0; i < 3; i++) {
    try {
      return await fn();
    } catch (e) {
      if (i === 2) throw e;
    }
  }
  throw new Error('unreachable');
}

Just enough to pass </Good>

<Bad>

async function retryOperation<T>(
  fn: () => Promise<T>,
  options?: {
    maxRetries?: number;
    backoff?: 'linear' | 'exponential';
    onRetry?: (attempt: number) => void;
  }
): Promise<T> {
  // YAGNI
}

Over-engineering </Bad>

Don't add features, refactor other code, or make "improvements" beyond the test.

Verify green - Watch it pass

**Mandatory. **

npm test path/to/test.test.ts

Confirm:

  • Test passes
  • Other tests still pass
  • Output is clean (no errors, warnings)

**Test fails? ** Fix the code, not the test.

**Other tests fail? ** Fix immediately.

Refactor - Clean up

Only after green:

  • Remove duplication
  • Improve names
  • Extract helpers

Keep tests green. Don't add behavior.

Repeat

The next failing test for the next feature.

Good tests

Quality Good Bad
Minimal One thing. The “and” in the name? Split it. test('validates email and domain and whitespace')
Clear The name describes the behavior test('test1')
Indicates intent The API needed Obscures program code execution

Why order matters

“I will write tests afterwards to verify whether it works”

Tests written after the code passes pass immediately. Passing immediately proves nothing:

  • Might test the wrong thing
  • Might test the implementation, not the behavior
  • Might miss edge cases you forgot
  • You have never seen it catch a bug

Test-first forces you to see the test fail, proving it actually tests something.

“I have manually tested all the edge cases”

Manual testing is ad hoc. You think you tested everything, but:

  • There is no record of what you tested
  • It cannot be rerun after the code changes
  • Cases are easily forgotten under pressure
  • “I tried it and it worked” ≠ comprehensive

Automated tests are systematic. They run the same way every time.

“Eliminating X hours of work is a waste”

Sunk cost fallacy. The time has already passed. Your choices now:

  • Delete and rewrite using TDD (a few more hours, high confidence)
  • Keep it and add tests afterwards (30 minutes, low confidence, possible bugs)

“Waste” is keeping code you do not trust. Working code without real tests is technical debt.

“TDD is dogmatic; being pragmatic means adapting”

TDD is pragmatic:

  • Finds bugs before committing (faster than debugging afterwards)
  • Prevents regressions (tests catch breakage immediately)
  • Documents behavior (tests show how to use the code)
  • Enables refactoring (change freely, tests catch breakage)

“Pragmatic” shortcuts = debugging in production = slower.

“Testing after reaching the same goal - it is the spirit, not the ritual”

No. Testing after answers “What does this do?” Test-first answers “What should this do?”

Tests afterwards are biased by your implementation. You test what you built, not what is needed. You verify remembered edge cases, not discovered ones.

Test-first forces edge cases to be discovered before implementation. Tests - verify that you remembered everything (you did not).

≠ TDD after 30 minutes of testing. You got insurance, but lost the work of proving the tests.

Common rationalizations

Sorry Reality
“Too simple to test” Simple code breaks. Testing takes 30 seconds.
“I will test afterwards” Tests passing immediately proves nothing.
“Test after reaching the same goal” Tests-after = “What does this do?” Test-first = “What should this do?”
“Already manually tested” Temporariness ≠ Systematicness. Without records, it cannot be rerun.
"Deleting X hours is a waste" Sunk cost fallacy. Keeping code from the default verification is technical debt.
"Keep it for reference, write tests first" You will get used to it. Later comes the test; deleting means deleting.
"Need to explore first" Beautiful. Abandon exploration, start with TDD.
"Effort testing = unclear design" Listen to the tests. Hard to test = hard to use.
"TDD will slow me down" TDD is faster than debugging. Pragmatic = test first.
"Manual testing is faster" Manual testing cannot prove edge cases. You will retest every change.
"Existing code has no tests" You are improving it. Add tests for existing code.

Danger sign - stop and start over

  • Code before test
  • Test after implementation
  • Test passes immediately
  • Cannot explain why the test failed
  • Add tests "later"
  • Rationalize "just this once"
  • "I already tested it manually"
  • "Test after achieving the same purpose"
  • "This is about spirit, not ritual"
  • "Keep for reference" or "Adapt existing code"
  • "Already spent X hours, deleting is too wasteful"
  • "TDD is dogmatic, I am pragmatic"
  • "This is different because..."

**All of this means: delete the plan code. Start with TDD. **

Example: Bug fix

Bug: Accept empty email

Red

test('rejects empty email', async () => {
  const result = await submitForm({ email: '' });
  expect(result.error).toBe('Email required');
});

Verify red

$ npm test
FAIL: expected 'Email required', got undefined

Green

function submitForm(data: FormData) {
  if (!data.email?.trim()) {
    return { error: 'Email required' };
  }
  // ...
}

Verify green

$ npm test
PASS

Refactor Extract validation for multiple fields if needed.

Verification checklist

Before marking work complete:

  • Every new function/method has a test
  • Observe each test fail before implementation
  • Each test fails for the expected reason (missing functionality, not a typo)
  • Write the minimum code to pass each test
  • All tests pass
  • Output is clean (no errors, warnings)
  • Tests use real code (mock only when unavoidable)
  • Edge cases and errors covered

Can't complete all interruptions? You skipped TDD. Start over.

When stuck

Problem Solution
Don't know how to test Write the desired API. Write assertions first. Ask your human partner.
Test too complex Design is too complex. Simplify the interface.
Must mock everything Code coupling is too strong. Use dependency injection.
Test setup is huge Extract helper. Still complex? Simplify the design.

Debugging integration

Found an error? Write a failing test to reproduce it. Follow the TDD cycle. Tests prove the fix and prevent regressions.

Never fix a bug without a test.

Testing anti-patterns

When adding mocks or test utilities, read @testing-anti-patterns.md section Common pitfalls:

  • Testing mock behavior instead of real behavior
  • Adding test-only methods to production classes
  • Mocking without understanding the dependencies

Final rule

Production code → test exists and failed first
Otherwise → not TDD

No exceptions without permission from your human partner.