Riadh Mnasri
← Back to blog
8 min read

Doing real TDD with Claude Code: the step you must never skip

In the article on TDD under deadline pressure, I explain why writing the test before the code saves time. What that article doesn't spell out is what "writing the test before the code" precisely means: TDD isn't a vague habit, it's a cycle with rules, named techniques, and precise reasons behind each of them. Understanding these concepts completely changes what you can expect from an AI assistant on this ground, and where you need to stay vigilant.

The red-green-refactor cycle, and what each step proves

The cycle popularized by Kent Beck has three phases, and each answers a different question:

 RED                       GREEN                     REFACTOR
 Does the test fail   ──▶  Does the minimal     ──▶  Can the design be
 for the right reason?     code make the test         improved without
 (proof that the test      pass, nothing more?        changing the
 actually tests                                        behavior?
 something)

Red isn't just about "having a failing test": it proves the test is capable of failing, and therefore actually tests something. A test that passes on the first run, before anything has been implemented, is often a poorly written test (an assertion that's always true, the wrong object under test), and that flaw only shows up if you observe red before writing the code.

Green demands the minimum, not the most elegant possible solution: the only goal of this phase is satisfying the current test, nothing anticipated. Resisting the temptation to generalize right away is a discipline of its own, detailed below.

Refactor is the phase most often skipped, even though it's the one that justifies everything else: without it, TDD only produces an accumulation of minimal code that never gets cleaned up. Existing tests act as a safety net during this phase: observable behavior must never change, only the code's internal structure can move.

Two ways to reach green: obvious implementation or "Fake It"

Kent Beck describes two legitimate strategies for getting to green, and the choice between them depends on how confident you are in the solution:

StrategyWhen to use itWhat it gives you
Obvious implementationThe solution is simple and unambiguousWrite the correct code directly
"Fake It"The solution isn't obvious, or you want to move forward in cautious small stepsReturn a hardcoded value that makes the test pass, then generalize

"Fake It" isn't cheating: it's a deliberate way to separate two problems that, mixed together, are harder to solve at once: "is my test well formed?" and "what's the right generalization?". The price to pay is that hardcoded code left hardcoded misleads whoever reads it later: "Fake It" only makes sense followed by a generalization, never as a stopping point.

Triangulation: forcing generalization with a second case

When "Fake It" is used, triangulation is the technique that forces you to abandon the hardcoded value: you add a second test, with different data, that can't pass with the same hardcoded value. That forces you to write a real generalization, not one guessed ahead of time.

On MissionMatch, the decision that makes a MatchResult eligible relies on a continuous score between 0.0 and 1.0, compared against a threshold. It's a good case to illustrate the full sequence.

Test 1, and "Fake It" to reach green as fast as possible:

@Test
fun `a score of 0_5 is eligible, the threshold is inclusive`() {
    // Given a score exactly at the eligibility threshold
    val score = MatchingScore(0.5)

    // When checking eligibility
    val result = score.isAboveThreshold()

    // Then the score is eligible
    assertTrue(result)
}
// Fake It: passes test 1, but generalizes nothing
fun isAboveThreshold(): Boolean = true

Test 2, the triangulation: a case the hardcoded value can't satisfy, forcing the real generalization.

@Test
fun `a score of 0_3 is not eligible, below the threshold`() {
    // Given a score clearly below the eligibility threshold
    val score = MatchingScore(0.3)

    // When checking eligibility
    val result = score.isAboveThreshold()

    // Then the score is not eligible
    assertFalse(result)
}

This second test fails against the frozen implementation (it always returns true), which forces the real generalization:

fun isAboveThreshold(): Boolean = value >= ELIGIBILITY_THRESHOLD

Both tests now pass, and nothing in this code was guessed ahead of time: every piece of generalization was forced by a specific test. Next comes the refactor phase: here, the calculation fits on one readable line, there's no duplication to extract, so this phase is limited to confirming no further cleanup is needed, which is a perfectly valid refactor outcome.

What makes a test a good test

The F.I.R.S.T. acronym (popularized by Clean Code) summarizes the properties a test needs to stay useful over time:

PropertyWhat it meansWhat happens if you ignore it
FastRuns in milliseconds, not secondsNobody runs the full suite before committing
IndependentNo test depends on the order or result of anotherA cascading broken test hides the real cause
RepeatableSame result every run, on any machine"Sometimes red" tests that eventually get ignored
Self-validatingThe result is a clean pass/fail, not output to readTime wasted manually interpreting every failure
TimelyWritten right before the code it tests, not afterThe test documents the code instead of specifying it

Isolating versus testing through real objects

Two schools of TDD answer "should you mock a tested unit's collaborators?" differently. The classicist school (the "Detroit" school) tests through real objects as much as possible, and only mocks at the system's boundaries (database, external service). The mockist school (the "London" school) systematically isolates every unit by mocking all its collaborators, so each test focuses on a single responsibility.

On a project using hexagonal architecture, as discussed in the article on hexagonal architecture in practice, the classicist approach naturally fits inside the domain (business objects genuinely collaborate with each other in tests), and the mockist approach takes over at the boundary (ports toward infrastructure are mocked, since they represent exactly the edge of the domain you want to isolate).

The trap specific to AI

Once these concepts are in place, what breaks with an AI assistant becomes precise: asking "implement this feature with tests" amounts to skipping straight past the obvious implementation without ever going through red, "Fake It", or triangulation. An assistant that generates implementation and test together already knows the answer by the time it writes the test. The resulting test is very likely to pass on the first run, not because the behavior is correct, but because the test was written to match code that already existed.

 Real TDD                            Common trap with AI

 1. write the test    (red)          1. generate implementation + test together
 2. verify red          ← proof      2. test passes on first run
 3. write the code    (green)        3. red never observed, never proven
 4. refactor                         4. it "looks like" TDD

The difference doesn't show up in the final code: a passing test is a passing test, whether written before or after. It only shows up in the process, and it disappears the moment you stop explicitly checking for it.

The steps that preserve the discipline with Claude Code

StepWhat you ask Claude CodeWhat you verify yourself
1. SpecifyDescribe the expected behavior as Given/When/Then, without mentioning the implementationIs the case unambiguous?
2. Test alone"Write only the test, not the implementation"Does it compile, and does it fail for the right reason?
3. Proven redRun the test yourself (or ask Claude to run it) before continuingDoes the failure message match the missing behavior, not a compile error?
4. Minimal green"Implement just enough to make this test pass, nothing more" (obvious implementation or "Fake It")Does the added code cover only the tested case?
5. Proven greenRerun the testDoes it pass, and do existing tests still pass?
6. Triangulate if neededA second case before generalizing a hardcoded valueDoes the new test fail against the frozen implementation?
7. RefactorAsk for a cleanup pass, with tests as the safety netDid the observable behavior change? (it shouldn't)

Step 3 is the one that silently disappears unless you explicitly demand it: without it, nothing distinguishes a real red-green cycle from code generated all at once with tests tagging along.

Making the discipline more robust than a simple request

Asking Claude Code to follow these steps works most of the time, but it remains an instruction the model can misread under an ambiguous prompt or a long conversation. As discussed in the article on agents, harnesses, skills and hooks, an instruction followed by the model stays probabilistic, even well phrased. What makes the discipline genuinely reliable is taking the red verification out of the model's judgment: running the test suite yourself before accepting the implementation, or automating that check in a hook that refuses a commit unless a test failed and then passed somewhere in the recent command history. TDD with an AI assistant is only reliable if proof of red stays a check you run yourself, never a claim you accept on trust.