You have probably done this. A bug report comes in, you paste the stack trace and logs into Claude Code, and ask it to fix the symptom. Claude reads the repo, forms a hypothesis, writes a plausible patch, confirms the existing tests pass, and reports back. You try it on a real device, and the symptom reappears. You paste more logs, Claude tries again, and an hour quietly disappears.
The cause of this spin is not Claude Code's capability. It is that I was delegating both "reproducing the symptom" and "fixing it" at the same time, without giving Claude a precise, code-level definition of what "failing" means. This post describes a workflow I now use every time: write a failing reproduction test myself, commit it, and only then hand the fix over to Claude Code. I will share the principles I apply, a concrete test example, and the prompts I use at each stage.
Why a Repro Test First Stops the Spin
When I tell Claude Code "login redirects are broken," Claude starts by guessing how to reproduce the symptom. It reads the test suite, opens adjacent files, forms a hypothesis, and writes a patch. Existing tests stay green, so Claude reports the job done. But Claude never had a way to observe the original failure, so it cannot confirm the fix truly addresses it.
When I hand Claude a test that is guaranteed to fail under the bug conditions, two things become unambiguous:
- The goal of the fix is to turn this specific test green.
- Correctness is measured by this test turning green plus no other test going red.
That is a contract written as code, not as a vague symptom description. Claude can focus on satisfying it.
There is a second, quieter benefit. Writing the repro test forces me to define the symptom precisely. "Login redirect is broken" could mean a 200 response with no Location header, a 302 with the wrong URL, or a client-side navigation that silently stops. I cannot write a test without picking one. That precision then flows into the prompt I hand to Claude, and the whole loop gets tighter.
Four Principles for a Minimal Repro Test
The shorter and more precise the repro test, the less Claude wanders. These four principles are what I have settled on.
Principle 1: Write it inside the existing test suite
Do not write it in an isolated sandbox file. You lose access to the project's fixtures and helpers, and Claude does not get to see the test sitting next to its peers. I put bug repros under tests/bugs/ and name the file after the ticket ID. That gives future me a very obvious way to find "the test I wrote when ticket 234 came in."
Principle 2: One observable fact per test case
Do not combine "login succeeds + redirect is correct + session cookie is set" into one test. When it fails, you cannot tell which assertion broke. One assertion per test keeps the failure signal sharp. Claude reads it and heads straight for the one condition it needs to satisfy.
Principle 3: Test at the boundary where the bug lives
If the bug manifests at the HTTP, DB, queue, or external API boundary, write the test at that boundary. In FastAPI projects I reach for TestClient and hit the real endpoint, rather than mocking out the framework. Mocks introduce slack, and slack is exactly where bugs hide.
Principle 4: Pin the repro to one variable pair
If the bug happens for "specific user role × specific request order," parameterize both and add cases. Showing Claude a 2×2 table of "green vs. red" cases makes the source of divergence far easier to localize.
A Concrete Example: Redirect Failure for Stale Sessions
Here is a simplified version of a real bug I hit on a small FastAPI auth endpoint: "users with old sessions get 200 back from login instead of 302."
When I was still spinning, this was my prompt:
Login is not returning 302. Please look at src/auth/login.py
and related tests and fix it.
Claude would patch a nearby conditional, confirm existing tests stayed green, and declare done. The bug would still reproduce on real devices.
Here is the repro test I wrote before re-delegating:
# tests/bugs/test_login_stale_session_redirect.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
from tests.factories import UserFactory, make_stale_session
client = TestClient(app)
@pytest.mark.parametrize(
"session_age_days, expected_status",
[
(0, 302), # fresh session: redirect as normal
(8, 302), # stale session: should still redirect (currently returns 200)
],
)
def test_login_redirects_regardless_of_session_age(session_age_days, expected_status):
user = UserFactory.create(password="valid-password")
make_stale_session(user, age_days=session_age_days)
response = client.post(
"/auth/login",
data={"email": user.email, "password": "valid-password"},
follow_redirects=False,
)
assert response.status_code == expected_statusThe parametrization pins the divergence: fresh sessions pass, stale ones fail. The prompt I send to Claude shrinks to this:
tests/bugs/test_login_stale_session_redirect.py is currently failing
on the session_age_days=8 case (returns 200 instead of 302).
Make that case pass without breaking any other existing test.
Claude is now aiming at a target defined by code, not by prose. And "without breaking any other existing test" kills a whole class of sloppy fixes that touch unrelated behavior.
The Three-Stage Workflow
Once I can write the repro test, I run a three-stage loop with Claude Code.
Stage 1: Write the repro test and commit it
I commit the failing test first, by itself. The commit message makes the intent unambiguous: test(bugs): reproduce stale session redirect issue (#234). CI shows the test failing as expected. Six months later, whoever blames this commit can read its purpose instantly.
Stage 2: Delegate the fix to Claude Code
I keep the prompt tight. The repro path, the tests that must stay green, and the search surface for the fix, all in one message.
Repro test: tests/bugs/test_login_stale_session_redirect.py
Must stay green: tests/auth/, tests/session/
Likely fix locations: src/auth/, src/session/
Turn the repro test green. Breaking any existing test is a failure.
Before writing code, state your fix plan in under three lines.
The "state your plan in under three lines" constraint is load-bearing. It lets me veto an obviously wrong hypothesis before Claude spends tokens implementing it.
Stage 3: Review the diff — not just against the test, but against intent
When CI goes green, I review on two axes: "is the repro test actually green" and "is the fix aiming at the real cause." The second matters because Claude will sometimes find the cheapest change that makes the test pass, not the change that fixes the bug. In this example, Claude could simply clear the stale session before login. That turns the repro test green. But it also hides the real issue.
If that happens, I push back: "The current patch turns the repro green but does not address the root cause because X. Please try again in direction Y." The re-delegation is cheap now, because the target is still defined in code. Only the direction changes.
Bugs You Cannot Easily Reproduce
Honestly, some bugs resist this approach. The usual offenders:
- Concurrency bugs that reproduce once in 1000 runs under load
- UI bugs tied to a specific browser build × OS combination
- State transitions that only show up after restart or long uptime
Writing a stable test for these is hard, and flaky tests poison the suite. For these I drop down a level: I write a scripted repro, not a test. Something like scripts/repro/<issue>.py that, when run N times, will hit the bug a few times. The prompt becomes: "this script currently fails some fraction of the time. Fix the underlying issue so it stops failing. A proper CI-grade test can come separately." Even stepping down from "deterministic test" to "repeatable experiment" dramatically tightens the delegation.
What Changed After Running This for a While
Three things shifted on my side.
First, my reflex on reading a new bug report became "let me try to write the failing test." Before, I would read code, form hypotheses, and an hour would vanish. Now I spend the first twenty minutes trying to capture the bug in a test. If I cannot, that is a signal I do not yet understand the symptom.
Second, my decision speed on Claude's patches improved more than the patch quality did. The two guardrails — repro test green, existing tests green — remove ambiguity about whether to merge.
Third, the test suite grows. Every bug leaves a regression test behind. After six months you find that every bug you ever fixed is still being checked on every CI run. For a solo developer maintaining a project for years, that compounding value is substantial.
What to Try Tomorrow
If this sounds worth testing, the smallest possible first step is this: on the next bug ticket, commit one failing test file before you delegate anything to Claude Code. Observe how different the delegation feels. If you cannot write the test, note that down — the kinds of bugs that resist tests will tell you where your own leverage is thin. Claude Code is a capable collaborator, but pointing at the right target is, for now, still our job.