TDD in Agentic Workflows: Specs as Executable Guardrails
After doing some hands-off code reviews with AI agents, I'm seeing the value of TDD again. I'm too lazy to do proper TDD myself, but agents don't care about the overhead or changing unit tests signatures for the 15th time.
Why Humans Resist TDD
Developers resist TDD because writing tests first interrupts the flow state. Specs change mid-implementation, and the cognitive overhead competes with actually solving the problem. You changed your mind and now you have to modify all the unit tests. Maybe you changed some signature or design of classes - again, all unit tests fail.
Agents execute instructions without caring about "natural" workflows. They don't get bored (usually) and follow specs literally. That's great material for a test engineer.
Example developer workflow
The workflow looks like this:
# Implementation plan
spec → review → plan → review
# Each phase or feature - can be parallelized
create tests → review tests → implementation → reviewUnit tests act as executable specs. You can review the examples of business logic without looking at how it's implemented.
Automated reviewing
Example chain for Claude Code:
/test # Tests must pass - can be a post-generation hook
/simplify # Check for over-engineering (deslopify)
/lint # Code quality
/review-backend # Custom skill which has specific instructions for this repo's backend
/review # Generic review
/integration-test # Custom skill that uses docker-compose, DB/Cloud emulators, mocked servers and test scripts
...
... and potentially moreIf everything passes, merge. If not, the agent iterates with tests as guardrails.
It is a decision of the developer:
- to generate 1500 lines of bad code
- to merge bad code
- to provide tools that generate evidence of correctness and requirements completion
How to seed the process:
- initial tests and specs must be 100% in the preferred style and guidelines
- agents will only inflate and compound this initial design, if it's bad, then everything might be bad
But my repo setup already enforces TDD, SOLID, DRY, KISS...
Most likely your repo is indeed already initialized, you have specialized agents, skills, README.md, CLAUDE.md, Cursor AGENTS.md. Maybe you also load some tools and MCPs.
Using TDD is just one of the rules marked as the proverbial CRITICAL: DO NOT SKIP THIS....
It needs to be enforced programatically or by developers workflow. It is perfectly possible that it will be forgotten or done afterwards, where LLMs will find the most plausible way to retrofit tests into generated code. Also by this point you are far into the higher tiers of context window filled to like ~80%. Agents get sloppy here.
Examples of enforcing this programatically could be using an agentic SDK like claude-agents-sdk or codex-sdk or similar, for example using Rosetta's internal plan manager.
Example: Rate Limiter Implementation
Traditional agent workflow:
Human: "Build a rate limiter that handles 100 requests per minute per user"
Agent: *generates 200 lines of code*
Human: "This doesn't handle burst traffic correctly"
Agent: *rewrites 150 lines*
Human: "Now it's not thread-safe"TDD workflow:
1. Spec
Build a rate limiter:
- 100 requests/minute per user
- Handle burst traffic (allow up to 10 req/sec bursts)
- Thread-safe for concurrent requests
- Return clear error messages when limit exceeded2. Plan (agent proposes, human reviews)
- Token bucket algorithm (supports bursts)
- In-memory store with user_id keys
- Mutex locks for thread safety
- Return 429 with retry-after header3. Write tests (agent generates, human reviews)
def test_allows_requests_within_limit():
limiter = RateLimiter(max_requests=100, window_seconds=60)
# First 100 requests should succeed
for _ in range(100):
assert limiter.allow("user_123") == True
def test_blocks_101st_request():
limiter = RateLimiter(max_requests=100, window_seconds=60)
# First 100 requests succeed
for _ in range(100):
assert limiter.allow("user_123") == True
# 101st request should be blocked
assert limiter.allow("user_123") == False
def test_thread_safety():
import concurrent.futures
limiter = RateLimiter(max_requests=100, window_seconds=60)
with concurrent.futures.ThreadPoolExecutor(max_workers=20) as executor:
results = list(executor.map(lambda _: limiter.allow("user"), range(200)))
assert sum(results) == 100 # Exactly 100 should succeed
def test_different_users_have_independent_limits():
limiter = RateLimiter(max_requests=5, window_seconds=60)
# Alice makes 5 requests
for _ in range(5):
assert limiter.allow("alice") == True
# Alice's 6th request is blocked
assert limiter.allow("alice") == False
# Bob is unaffected
assert limiter.allow("bob") == TrueReviewing 20 lines of test logic is easier than reviewing 200 lines of implementation. If you approve the tests, you've defined success before the agent writes any code.
4. Implementation
Human: "Implement RateLimiter to pass these tests"
Agent: *generates implementation*The agent now has clear success criteria, validated architecture, and defined edge cases.
TDD Loops
This is a composable blueprint, therefore various agentic loops and harnesses can be built around this idea.
For example, we can imagine a composed agent harness that does all this autonomously, just using skills and loops.
Should I use it?
Use TDD with agents for code with clear requirements:
- Business logic
- Data transformations
- API endpoints with defined contracts
- Algorithms with known edge cases
Skip it for:
- Exploratory prototypes (specs unclear)
- UI/UX implementation (unless you plug in some UI MCP like
playwright) - Performance optimization (requires profiling)
Tips
Test behavior, not implementation
# ❌ Testing implementation details
# Don't look at private code
def test_token_bucket_refill_rate():
assert limiter._bucket.refill_rate == 100/60
# ✅ Testing behavior
def test_limit_resets_after_window():
limiter = RateLimiter(max_requests=5, window_seconds=1)
for _ in range(5):
limiter.allow("user")
assert limiter.allow("user") == False
time.sleep(1.1)
assert limiter.allow("user") == TrueFront-load edge cases in tests
def test_handles_negative_time_drift():
"""System clock goes backwards (NTP adjustment)"""
pass
def test_handles_user_id_collision():
"""Hash collision or malicious input"""
passThe agent fills in implementation for each test case.
