Engineering Blog

Technical insights from Grid Dynamics engineers

TDD in Agentic Workflows: Specs as Executable Guardrails

TDD in Agentic Workflows: Specs as Executable Guardrails

Adam Wrobel · Mar 22, 2026

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 → review

Unit 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 more

If 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 exceeded

2. 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 header

3. 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") == True

Reviewing 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") == True

Front-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""" pass

The agent fills in implementation for each test case.