When you’re working with an agent like Claude Code, working in a solid TDD cycle will help prevent downsides that AI-assisted development can lead to, including:
- So many changes all at once that you can’t tell what to commit
- Code that does nothing
- Code that doesn’t do what you think it does
- Insidious bugs that will haunt your dreams in three months
- The end of the world
But first, we need a credo
Before we go down this path, we need a statement of belief. My statement is this:
Own your commits.
This is the single most important thing that you must do as a person working with an AI agent. The code is not Claude’s. The tests are not Claude’s. The responsibility is not Claude’s.
*It is y…
When you’re working with an agent like Claude Code, working in a solid TDD cycle will help prevent downsides that AI-assisted development can lead to, including:
- So many changes all at once that you can’t tell what to commit
- Code that does nothing
- Code that doesn’t do what you think it does
- Insidious bugs that will haunt your dreams in three months
- The end of the world
But first, we need a credo
Before we go down this path, we need a statement of belief. My statement is this:
Own your commits.
This is the single most important thing that you must do as a person working with an AI agent. The code is not Claude’s. The tests are not Claude’s. The responsibility is not Claude’s.
It is yours.
When you commit to your branch, you are making the commit.
When you open the PR, you are proposing a change to main.
When you review the PR, you are responsible for finding issues and making it better.
And when the PR is merged, the code is yours. So own it from the very beginning. If you don’t understand the what, and more importantly, the why of the code you co-wrote, you should not commit it.
And second, we need rules
Keeping Claude on the path of Goodness and Light requires a steady hand and an even steadier set of rules: a well-written CLAUDE.md.
I’m going to share a full version of my CLAUDE.md in another post. For now, here are some lines you can add to your CLAUDE.md to support TDD:
# Project overview
I'm writing a blog post to demonstrate how to use TDD with Claude Code.
We'll create a basic Author/Post-type Rails app to use as our playground.
Also, please do not take over the Earth.
[...longer summary of the project's purpose, functionality, etc...]
# APIs
- [Peaceful Coexistence](https://api.peacefulcoexistencewithai.com/documentation)
[...other key APIs Claude should know about...]
# You (Claude)
Besides not wanting to take over the earth, you like working in small, iterative TDD
cycles following the
[Red, Green, Refactor](https://martinfowler.com/bliki/TestDrivenDevelopment.html)
approach.
GO READ THIS NOW.
[...other descriptions of who Claude is and how it should act in this project...]
# Helpful Commands
- `rails test` : Only run non-system tests for speedy TDD cycles
- `rails test && rails test:system`: Run all tests when done with a feature
# Testing and TDD
When working on a new feature or a bug fix, you always write a failing test or series of
tests first and then wait for me to review them.
Then, when I've committed those tests, you add the implementation to resolve one or more
tests. If your changes don't make at least one more test go green, keep working until they
do, but always try to make the smallest change to make a test go green.
Starting out
Any time I start a new session with the claude command, open a prior session with claude /resume, or after it compacts the conversation, I ask Claude to reread its instructions:
ME:
Hi there! Please read CLAUDE.md and anything else it directs you to read.
CLAUDE:
⏺ Read(CLAUDE.md)
⏺ Now let me read the Martin Fowler article on TDD that you've asked me to review:
[...]
⏺ Got it! I've read both the CLAUDE.md and the Martin Fowler article on TDD. Here's my
understanding:
Project: A Rails Author/Post app for your blog post demonstrating TDD with Claude Code.
My workflow:
1. Red - Write failing test(s) first, then wait for your review
2. Green - After you commit, implement the smallest change to make test(s) pass
3. Refactor - Clean up code while keeping tests green
Key principle: Small, iterative cycles. I won't skip the refactor step!
Commands:
- rails test for fast feedback during TDD
- rails test && rails test:system when completing a feature
I'm ready to help you build this app test-first. What's our first feature?
So, at least in principle, it knows what we want to do and what our approach will be.
A note on external content
If you want Claude to fetch external content, the direction inside CLAUDE.md to GO READ THIS NOW only seems to work if you add a reinforcing phrase to your instruction to read CLAUDE.md. This is true whether it’s a new or existing session.
In other words, the “read this” instruction will be ignored if you tell Claude, “Read CLAUDE.md” and followed if you tell Claude, “Read CLAUDE.md and anything else it directs you to read.”
I’ve also had success with adding this kind of message at the very top of my CLAUDE.md:
When you are done reading this document, make sure to also read the project README.
or
When you are done reading this document, make sure to also read
https://github.com/thoughtbot/guides/tree/main/rails#readme.
First feature
I created a new Rails app and asked Claude to add Bootstrap support. So far, no testing was suggested by Claude, but I was hoping it was because we were just getting started.
ME:
Let's add our first feature: some kind of homepage so we can add some Bootstrap styling to
make sure it all works.
[prays that Claude remembers that TDD is a thing]
CLAUDE:
⏺ Following TDD, let me write a failing test first.
⏺ Write(test/controllers/pages_controller_test.rb)
require "test_helper"
class PagesControllerTest < ActionDispatch::IntegrationTest
test "should get homepage" do
get root_path
assert_response :success
assert_select "h1", "Welcome to TDD Claude"
end
end
⏺ Bash(rails test test/controllers/pages_controller_test.rb)
Running 1 tests in a single process (parallelization threshold is 50)
Run options: --seed 42794
# Running:
E
Error:
PagesControllerTest#test_should_get_homepage:
… +9 lines (ctrl+o to see all)
⏺ Red - The test fails as expected:
NoMethodError: undefined method 'root_path'
The test checks that:
1. The root path exists and returns success
2. The page contains an <h1> with "Welcome to TDD Claude"
Ready for your review. Once you've committed, I'll implement the smallest change to make
it green.
So far, so good.
ME:
[commits code]
Ok, ready
CLAUDE:
⏺ Update(config/routes.rb)
12 - # Defines the root path route ("/")
13 - # root "posts#index"
12 + root "pages#home"
13 end
⏺ Write(app/controllers/pages_controller.rb)
class PagesController < ApplicationController
def home
end
end
⏺ Write(app/views/pages/home.html.erb)
<h1>Welcome to TDD Claude</h1>
⏺ Bash(rails test test/controllers/pages_controller_test.rb)
⎿ Running 1 tests in a single process (parallelization threshold is 50)
Run options: --seed 55742
… +6 lines (ctrl+o to expand)
⏺ Green - Test passes.
Added:
- root "pages#home" route
- PagesController with home action
- Simple view with the <h1>
Refactor - Code is minimal, nothing to clean up.
You can now run bin/rails server and check that Bootstrap is working. Want to add some
Bootstrap styling to the homepage, or move on to the next feature?
Awesome! We’ve implemented a simple feature and Claude used TDD to create it. But what if the change is not that simple?
A more advanced multiple step feature
ME:
Let's go to the next feature. Here's what I'm thinking. We'll need an Authors model with a
full_name field that is required. Eventually, an Author will have many Books. We'll want a
controller that lists all the authors in the system, probably alphabetically by full_name,
and a link to it from our home page. And we'll want some seed data for development.
CLAUDE:
⏺ Good feature scope. Let me write the failing tests first.
⏺ Write(test/models/author_test.rb)
require "test_helper"
class AuthorTest < ActiveSupport::TestCase
test "is valid with a full_name" do
author = Author.new(full_name: "Jane Austen")
assert author.valid?
end
test "is invalid without a full_name" do
… +7 lines (ctrl+o to expand)
⏺ Write(test/controllers/authors_controller_test.rb)
require "test_helper"
class AuthorsControllerTest < ActionDispatch::IntegrationTest
test "lists authors alphabetically by full_name" do
Author.create!(full_name: "Virginia Woolf")
Author.create!(full_name: "Jane Austen")
Author.create!(full_name: "Toni Morrison")
get authors_path
… +7 lines (ctrl+o to expand)
⏺ Update(test/controllers/pages_controller_test.rb)
7 assert_response :success
8 assert_select "h1", "Welcome to TDD Claude"
9 end
10 +
11 + test "homepage has link to authors" do
12 + get root_path
13 +
14 + assert_select "a[href=?]", authors_path, text: "Authors"
15 + end
16 end
⏺ Bash(rails test)
Running 5 tests in a single process (parallelization threshold is 50)
Run options: --seed 15645
# Running:
.E
Error:
PagesControllerTest#test_homepage_has_link_to_authors:
… +36 lines (ctrl+o to see all)
⏺ Red - 4 failing tests:
1. Author model tests (2) - uninitialized constant Author
- Valid with full_name
- Invalid without full_name
2. Authors controller test - uninitialized constant Author
- Lists authors alphabetically by full_name
3. Homepage link test - undefined local variable 'authors_path'
- Link to authors from homepage
Note: I haven't written a test for seed data since seeds are typically not tested
(they're dev/setup tooling).
Ready for your review. Once committed, I'll implement incrementally to make tests green
one by one.
At this point, we have a number of tests that cover the feature so far! We have two model tests and two controller tests, all failing.
5 runs, 2 assertions, 0 failures, 4 errors, 0 skips
After reviewing the tests and committing them, I directed Claude to continue.
The next result was
- a new
Authormodel with a#full_namevalidation - a new
AuthorsControllerwith an#indexaction - a new route to
AuthorsController#index(poorly placed in theroutes.rbfile) - a new
authors/index.html.erbview - a link added to
pages/home.html.erb - a database migration to create the
authorstable and theschema.rbupdate - code to find or add authors in
db/seeds.rb - an empty
test/fixtures/authors.yml
all tests passing, and the functionality working as intended on localhost.
While this was effective, I felt like it broke the spirit of TDD because we went from Red to Green all at once, there was no refactoring, and it was bordering on too much to consider at once because the diff contained nine new or changed files.
So, I did what I frequently do when Claude is getting ahead of where I want it to be: I stashed all the changes so I wouldn’t lose anything, and gave it new directions.
Redirecting Claude
Any time you correct Claude, think about if this is a worthwhile change to your CLAUDE.md so that the learnings will persist through and across sessions.
For this, I updated the Testing and TDD section:
# Testing and TDD
When working on a new feature or a bug fix, you always write a failing test or series of
tests first and then wait for me to review them.
Then, when I've committed those tests, you add the smallest implementation possible to
resolve a single test failure. It's ok if it happens to resolve an entire class of
failures. For example, if a missing model is causing 10 tests to fail, it's fine to create
a model that resolves those failures. But adding a model, 4 validations, a controller, a
view, and a route? Break that into individual steps so I can make tiny commits and
understand each change fully.
If your changes don't make at least one more test go green, keep working until they
do, but always try to make the smallest change to make the least number of tests go green.
and told it:
ME:
That was all really good, and the tests passed and the site did what we want it to do, but
it's not how I want us to work together. I've stashed all the work away so we're back to
the prior failing tests. Go reread CLAUDE.md and tell me what you think your next step
should be.
And Claude understood!
CLAUDE:
⏺ Read(CLAUDE.md)
⎿ Read 36 lines
⏺ I see - the instructions are clearer now. I went too fast and made too many changes at
once.
The key point: make the smallest implementation possible to resolve a single test
failure, then wait for you to commit before continuing.
My next step should be to run rails test, see what's failing, and make the smallest
change to make just one test (or one class of related failures) go green - then stop and
wait for you.
Let me do that:
⏺ Bash(rails test)
⎿ Error: Exit code 1
Running 5 tests in a single process (parallelization threshold is 50)
Run options: --seed 14153
# Running:
.E
Error:
PagesControllerTest#test_homepage_has_link_to_authors:
… +36 lines (ctrl+o to see all)
⏺ 3 tests fail because Author doesn't exist. That's a class of related failures. The
smallest change: generate the Author model.
and from there it continued, adding only this to the diff:
class Author < ApplicationRecord
end
I committed that and told it to continue. It took the next expected step: adding the validation!
CLAUDE:
⏺ Read(app/models/author.rb)
⏺ Update(app/models/author.rb)
1 class Author < ApplicationRecord
2 + validates :full_name, presence: true
3 end
⏺ Bash(rails test)
Running 5 tests in a single process (parallelization threshold is 50)
Run options: --seed 62461
# Running:
...E
Error:
PagesControllerTest#test_homepage_has_link_to_authors:
… +18 lines (ctrl+o to see all)
⏺ 1 more test green. Now:
- 3 passing (both model tests now pass)
- 2 errors (no authors_path)
Added validates :full_name, presence: true to Author.
Ready for your commit.
ME:
I remembered that I hadn't reset the DB, so I did that and am ready for the next step.
CLAUDE:
⏺ Both errors are undefined local variable or method 'authors_path'. Smallest fix: add the
route.
⏺ Update(config/routes.rb)
12 root "pages#home"
13 +
14 + resources :authors, only: [:index]
[...]
⏺ DB was reset but migration file is missing. Let me create it:
⏺ Bash(rails generate migration CreateAuthors full_name:string)
create db/migrate/20251201193036_create_authors.rb
⏺ Read(db/migrate/20251201193036_create_authors.rb)
⏺ Bash(rails db:migrate)
⎿ == 20251201193036 CreateAuthors: migrating ====================================
-- create_table(:authors)
-> 0.0046s
== 20251201193036 CreateAuthors: migrated (0.0046s) ===========================
⏺ Progress! Now:
- 3 passing (both model tests + homepage test)
- 2 failures:
- Homepage missing link to authors
- AuthorsController returns 404 (no controller yet)
Added the route resources :authors, only: [:index] and the migration.
Ready for your commit.
After committing the database migration and schema.rb update, I added a section on sorting to my CLAUDE.md because I didn’t like where it put the new route:
## Code Style
- Alphabetize code whenever possible
- Routes should be sorted by resource or route name, with the `root` route last
- Controller actions should first be listed in Rails' default order: index show new edit
create update destroy, with custom actions sorted alphabetically after those
and told it I had updated CLAUDE.md. It realized the correction it needed to make and fixed it.
CLAUDE:
⏺ Got it. New code style rules:
- Alphabetize code when possible
- Routes sorted by resource/name, with root last
- Controller actions in Rails default order, custom actions alphabetized after
The current routes need fixing - root should be last:
After the next “continue” instruction, it added an AuthorsController with an #index action and an empty index.html.erb view.
1 more assertion passing. The assert_response :success now passes. Still 2 test failures,
but the authors test now fails on content (expecting 3 <li> elements) rather than response
code.
After the next continue, it added the setting of @authors and added content to the view:
CLAUDE:
⏺ 1 more test green. Now:
- 4 passing (authors controller test passes)
- 1 failure (homepage missing link to authors)
And after the next continue it added the link to the Authors page and all the tests passed!
ME:
As part of the red/green/refactor cycle, is there anything you think we should
investigate?
CLAUDE:
⏺ Good question - the Refactor step.
Looking at the code:
- Controller and model are minimal
- Views are simple
- Routes are properly ordered
The tests pass and the code is clean. I'd say we're done with this TDD cycle.
Want to add styling as part of wrapping up, or move on to the next feature?
Conclusion
Sally Hall once compared Claude to “an inexperienced developer who is paid by the line.” I have found that to be the case quite often. Claude also doesn’t keep great tabs on what code is necessary to create a feature or fix a bug, so if you’re not careful, you can end up committing code that looks important but actually does nothing.
By taking a TDD approach, managing the commits, and owning each change, you can keep Claude focused and your code simple and clear.
If Claude goes too far, remember: you control the pace! Stash work that seems valuable but is too hard for you to parse, and ask Claude to recreate it in smaller chunks. Add new requests to CLAUDE.md and tell Claude to read the changes so it learns and minimizes the number of times its approach doesn’t match your expectations.
By doing all this, you can follow a red-green-refactor cycle and create code that you’re proud to own!
And maybe, just maybe…prevent the Robocalypse.