CCLearn

Handing over the test first — the failing test on purpose

verified against Claude Code 2.1.261 · 2026-09-0915 min

By the end you can

  • Ask for a reproducing failing test before asking for the fix
  • Write example cases and testing preferences into the request itself
  • Let the existing suite set style rather than specifying a framework
  • Constrain what must not change so a green result means the code moved
  • Turn a passing suite into a completion criterion a task cannot close without

A bug report has landed, you can reproduce it by hand, and the obvious next move is to describe it and ask for a fix. Lessons 5.1 and 5.2 both assumed the check already existed — that there was something to run and the only open question was how hard it gated the stop. This lesson is the case where there is nothing to run yet, and the first thing you hand over is the check itself.

The test is the specification you hand over

The documented rewrite of "fix the login bug" — the one Lesson 2.4 pulled apart into symptom, likely location and definition of done — is not longer because it is politer. It is longer because it ends somewhere:

users report that login fails after session timeout. check the auth flow in src/auth/, especially token refresh. write a failing test that reproduces the issue, then fix it — Best practices

Three clauses, three jobs. The symptom says what is wrong, the location aims the search, and the last clause defines done. Only the last one survives the session: the symptom and the pointer are consumed the moment work starts, while the test is still in the repository next month, failing if the bug comes back. Prose about what "correct" means is context; a test is context that keeps executing.

Failing first, on purpose

The order in the documented rewrite is explicit: write a failing test, then fix it. Not "fix it and add a test".

Fix, then testTest, then fix
What the test is written againstThe code that now existsThe behaviour you described
Its first runPassesFails, for the reason you named
What green proves at the endThe code and the test agreeThe behaviour changed
If the bug was misdiagnosedBoth are wrong together, quietlyThe test never reproduces, and you find out early

In practice the second row is the one that pays. A test never seen to fail has not demonstrated that it can fail; it may be asserting something the code did before the change and will do after it. Watching it go red first, for the stated reason, is the cheapest evidence you will get that the test is attached to the bug at all — and it costs one sentence in the request.

A test that reproduces a bug can only be written from a reproduction, which is why the documented bug recipe asks you for the command that reproduces the issue and its stack trace, the steps, and whether the error is intermittent or consistent (Common workflows).

What belongs in the request

ElementDocumented example
Which file, which scenario, which testing preferences"write a test for foo.py covering the edge case where the user is logged out. avoid mocks."
The cases that decide the answer"example test cases: user@example.com is true, invalid is false, user@.com is false"
The instruction to run them"run the tests after implementing"
What fixed looks like"write a failing test that reproduces the issue, then fix it"
Where green must come from"address the root cause, don't suppress the error"

Two of those rows are easy to skip and expensive to skip. "avoid mocks" is a testing preference: two words up front, a rewrite of the test and a second review of it if you say it after reading the diff. It is scope in Lesson 2.1's sense — what is out of bounds — and every codebase has house rules about tests that are invisible in the code being tested. And the example cases are what turn a description into a decidable question. That is the validateEmail rewrite from Lesson 5.1's table, seen from the request's side: "validate email addresses" has no failure condition, three named inputs with their expected answers do, and the request that carries them is also the request that says to run them. The documented instruction is to be specific about what behaviour you want to verify; the cases are how you are specific about it.

Letting the existing suite set the style

The specificity that pays is about behaviour, not mechanics — because the mechanics are already on disk:

Claude examines your existing test files to match the style, frameworks, and assertion patterns already in use. — Common workflows

Naming the framework, the assertion library and the file layout is usually spending the request on something the repository already answers. Spend it on the cases instead. The one thing the existing suite cannot supply is the cases nobody thought of, and that is an explicit ask:

For comprehensive coverage, ask Claude to identify edge cases you might have missed. — Common workflows

Claude can analyse the code paths and suggest tests for error conditions, boundary values and unexpected inputs — but it is a step of its own, the third of four separate prompts in the documented recipe, sent in order:

text
find functions in NotificationsService.swift that are not covered by tests
add tests for the notification service
add test cases for edge conditions in the notification service
run the new tests and fix any failures

That last line is Lesson 5.1's rule showing up inside a testing recipe: even where the deliverable is a test, the step that closes the loop is running it.

Green by weakening the test

The fix is to say which half is fixed, and the documentation gives you two clauses for it. One is the root-cause clause from the prompt-rewrite table: "address the root cause, don't suppress the error". The other is the part of a /goal condition — Lesson 5.2's rung two — that most people leave off:

Constraints that matter: anything that must not change on the way there, such as "no other test file is modified" — Keep Claude working toward a goal

That example is not an accident: a completion condition phrased only as an end state can be reached from either direction.

Route to greenThe clause that closes it
The assertion was loosenedThe test file is not modified
The case was skippedNo test is skipped or removed; the suite's test count does not fall
The exception was swallowedAddress the root cause, don't suppress the error
Another test was broken to make roomNo file outside src/auth is modified

Written that way, a green result carries information again: the code moved, because nothing else was allowed to.

From a suite to a gate

Once the suite exists it stops being something you ask for and becomes something you can require. Lesson 5.2 sited that requirement at the end of a turn, with a Stop hook. There is a second event, which fires when a task is marked done rather than when a turn ends:

Use this to enforce completion criteria like passing tests or lint checks before a task can close. — Hooks reference

That is the TaskCompleted hook. Read its firing rule before relying on it: it runs when a task is being marked completed — either by an agent marking it through the TaskUpdate tool, or by an agent-team teammate finishing a turn with tasks still in progress. It supports no matchers and fires on every occurrence. The blocking mechanism is the one Lesson 5.2 already taught, pointed at a different thing:

Exit code 2: the task is not marked as completed and the stderr message is fed back to the model as feedback. — Hooks reference

The documented example is exactly the gate this lesson has been building toward:

bash
#!/bin/bash
INPUT=$(cat)
TASK_SUBJECT=$(echo "$INPUT" | jq -r '.task_subject')

# Run the test suite
if ! npm test 2>&1; then
  echo "Tests not passing. Fix failing tests before completing: $TASK_SUBJECT" >&2
  exit 2
fi

exit 0

Two properties make that worth having over the same check in a prompt. Exit 2 does both halves at once — the task stays open and the stderr text is handed back to the model as feedback, so the run has a reason to keep working rather than a bare refusal. And it applies to tasks nobody re-typed the criteria for, including one closed by a teammate in a run you were not watching. Reach for a Stop hook when no turn should end with the suite red, and for TaskCompleted when the work is tracked as tasks and "done" is a status an agent sets.

What you actually hand over

A failing test is three things at once: the request, the specification, and the check that closes the loop. Write the cases, say which half of the code is allowed to move, and green means something again.

Checkpoint

  1. You ask for a fix and a test in one message, and the run comes back green with a summary that mentions both. What single thing do you look for in the diff before believing the bug is gone?

    Show answer

    Which half of the diff moved. "Make the tests pass" does not say which half is allowed to move, and there are four documented routes to green that leave the bug in place: an assertion was loosened, the case was skipped, the exception was swallowed in a try/catch, or another test was broken to make room. Each one satisfies the request exactly as written and produces a confident summary, so read the test side first — if an existing assertion, a skip marker or a catch moved, green is telling you about the test rather than the behaviour.

    The reason you are in this position is the order you asked for. Fix-and-test in one message writes the test against the code that now exists, so green proves the code and the test agree, not that the behaviour changed — and a test never seen to fail has not demonstrated that it can fail. Next time ask the documented way, "write a failing test that reproduces the issue, then fix it", plus the clause that closes the route you care about: "address the root cause, don't suppress the error", or "no test is skipped or removed".

  2. Why does naming the assertion library in your prompt usually buy less than naming three input/output pairs?

    Show answer

    Because the repository already answers the mechanics and cannot answer the behaviour. Claude examines your existing test files to match the style, frameworks and assertion patterns already in use, so naming the assertion library spends the request on something that is on disk anyway. The three pairs are the part nothing else supplies: they turn a description into a decidable question. "Validate email addresses" has no failure condition; "user@example.com is true, invalid is false, user@.com is false" does — and the request that carries the cases is also the request that says to run them.

    The specificity that pays is about behaviour, not mechanics. The two things still worth spending words on are the house rules the code being tested cannot show — a testing preference like "avoid mocks" costs two words up front and a rewrite plus a second review if you say it after reading the diff — and asking Claude to identify edge cases you might have missed, which is a step of its own rather than a clause.

  3. Your suite is green and your teammate's task closed itself overnight while a TaskCompleted hook was configured to run npm test. Name one way that sequence is still consistent with the bug being present.

    Show answer

    The suite can be green because the test moved rather than the code. The hook asserts that npm test passes; it asserts nothing about whether the suite still tests the bug. A loosened assertion, a skipped or removed case, a swallowed exception, or a neighbouring test broken to make room all produce a passing run, and exit 0 cannot tell any of them from a fix. Unless the completion criteria also said what must not change — the test file is not modified, no test is skipped or removed and the count does not fall, address the root cause rather than suppressing the error, no file outside the relevant directory is modified — green carries no information about which half moved.

    A second route ends the same way: if the test was written after the fix, against the code that now exists, it may be asserting something the code did before the change and will do after it. It was never seen to go red for the stated reason, so it was never shown to be attached to the bug — and a gate that runs a test which cannot fail is a gate on nothing.

Sources