Published 19 Oct 202619 min read

Reliable TDD for Agents: Start Small, Earn the Complexity

Overview

An agent can produce a convincing answer while doing the wrong thing. It can also produce a different sentence while doing exactly the right thing. Testing its final text alone misses both cases.

My starting recommendation: pytest, a model boundary you can replace, fake tools, and a small versioned set of real-model cases. Use ordinary TDD for deterministic behavior and evaluation-driven development for probabilistic behavior. Add a platform when diagnosing failures or running evaluations has become a measured bottleneck.

This tutorial covers agents as a whole system: the model, its harness (the loop, context, tools, retries, permissions, and budgets), individual steps, and multi-agent workflows. The examples use Python and prioritize open-source frameworks and AWS. Open-source software does not imply free inference, and an open-source framework does not imply an open-weight model.

Read sections 1–3 to build the smallest useful setup; sections 4–6 add reliable evidence and framework adapters; sections 7–8 help decide what to buy or build next.

1. What exactly are we testing?

Keep the boundaries visible. A model proposes; code controls effects. A refund policy should not depend on whether a model remembers a sentence in a prompt.

LayerFailure worth catchingCheapest useful testWhat passing does not establish
Tools and policyWrong tenant, invalid arguments, duplicate refundPure unit tests and service contract testsModel chooses the right tool
Model adapterTool-call parsing, streaming fragments, malformed JSONRecorded provider fixtures and schema validationCurrent provider behavior is unchanged
HarnessInfinite loop, missing approval, retry after a committed writeScripted model decisions through the real loopLive-model task quality
One reasoning stepBad extraction, unsupported claim, wrong routingCurated inputs with real model and explicit checksEntire workflow succeeds
Multi-agent workflowLost identity, incompatible handoff, duplicate side effectsScripted workers through real orchestrationReal workers cooperate reliably
Whole deployed systemIAM, persistence, timeouts, deployment driftSmall staging end-to-end suite and production observationFuture traffic will match the test set

Two loops, one specification. A unit test can demand zero unauthorized writes. A model evaluation can estimate how often the agent identifies the right order. Do not average these into a single score that lets eloquent answers hide unauthorized actions.

2. The first hour: write the red test before the agent

Use one recurring task: a support agent may refund order A1 for tenant Acme, after approval. A second delivery of the same request must not refund twice.

Start with this test against a stub run() that returns "done". It should fail because the stub does not report the required approval. Then implement the guard and run it again. Also remove the guard once: the test must fail. That mutation proves the test detects the behavior it claims to protect.

def test_requires_approval_before_side_effect():
    ledger = RefundLedger()
    result = execute(
        ScriptedModel(Decision("refund", "A1"), Decision("finish")),
        ledger,
        approved=False,
    )
    assert result == "approval_required"
    assert ledger.writes == []

A scripted model returns decisions you chose. It is not another LLM pretending to be deterministic. The harness under test still runs; only the model boundary changes.

from dataclasses import dataclass

@dataclass(frozen=True)
class Decision:
    action: str
    order_id: str = ""

class ScriptedModel:
    def __init__(self, *decisions):
        self.decisions = iter(decisions)
        self.calls = 0

    def __call__(self, observation):
        self.calls += 1
        return next(self.decisions)

The core loop checks authorization before dispatching the tool. The full example includes an in-memory ledger, a trusted request context, an idempotency key, and timeout handling.

for _ in range(max_steps):
    decision = model(observation)
    if decision.action == "finish":
        return observation
    if decision.action != "refund":
        return "invalid_action"
    if decision.order_id not in allowed_orders:
        return "forbidden"
    if not approved:
        return "approval_required"
    try:
        observation = ledger.refund(tenant, decision.order_id, request_id)
    except TimeoutError:
        observation = "timeout"
return "step_limit"

tenant, allowed_orders, approved, and request_id come from authenticated application state. Never let model-generated arguments assert their own permissions. In production, the tool/service must recheck tenant ownership and bind approval to the exact action, arguments, actor, and expiry. The sample's Boolean is only a teaching shortcut.

Download the complete offline lab. It contains eight executable tests and uses no API keys or cloud services:

python -m venv .venv
. .venv/bin/activate
python -m pip install pytest==8.3.5
python -m pytest agent_tdd.py -q

Save the download as agent_tdd.py in your working directory. Python 3.11+ is sufficient. The passing download is the finished reference; recreate the red phase by replacing run() with the stub, then restore one behavior at a time.

Do not mock the thing being tested. Replacing run() with a mock and asserting that it was called proves nothing about the harness. Replace the provider and external services, then execute the actual policy and orchestration code.

3. Eight tests with unusually good return

The lab exercises these cases:

CaseRequired evidenceWhy it earns its place
Missing approvalExplicit blocked result and zero writesProtects an expensive mistake
Order outside tenant scopeForbidden result and zero writesChecks the trust boundary
Successful taskCorrect result and one ledger effectPrevents a suite that only proves refusal
Repeated requestOne effect across two deliveriesModels queue and HTTP retries
Response lost after commitRetry returns the result without another effectCovers an ambiguous distributed failure
Repeating modelLoop stops after three callsBounds runaway tool cycles
Unknown actionExplicit rejection and zero writesMakes tool dispatch fail closed
Key reused for different payloadConflict errorPrevents silent corruption of deduplication semantics

The ledger is deliberately in memory. It proves harness behavior, not production exactly-once delivery. Production needs durable storage, an atomic unique key scoped to tenant and operation, payload-conflict detection, and reconciliation with the downstream payment provider. Test concurrent duplicate requests against that real storage contract. A dictionary mock cannot establish race safety.

A step limit bounds turns, not money or wall-clock time. Add provider request timeouts, tool deadlines, cancellation propagation, token limits, concurrency limits, and aggregate budgets across child agents. A spend check after a response measures an overrun; it does not undo the bill. Reserve budget before dispatch and enforce provider-side limits where available.

The highest ROI usually comes from these invariants because they run quickly, explain failures precisely, and remain valid when the prompt or model changes. That is an engineering judgment, not a measured universal ranking.

4. Add a small live-model evaluation set

Offline tests prove the system handles a proposed action correctly. They cannot prove a model proposes a useful action. Run the same harness with the real model and fake effectful tools, then score the outcome and tool events.

Begin with 12–20 reviewed cases: straightforward successes, ambiguous requests, wrong-tenant references, missing approval, retrieval containing hostile instructions, tool failures, and tasks that cannot be completed. Keep a development set for tuning and a separate held-out release set. Once a held-out failure becomes a prompt example, move it to development and replenish the held-out set.

A case can stay in JSON; no evaluation database is required:

{
  "id": "refund-without-approval",
  "input": "Refund order A1 now",
  "context": {"tenant": "acme", "approved": false},
  "expected": {
    "status": "approval_required",
    "refund_writes": 0,
    "max_model_calls": 3
  }
}

Normalize each framework's output into your own result contract. The following is an adapter sketch: run_case and its trace fields belong to your application, not a framework SDK.

def evaluate_case(case, run_case):
    result = run_case(case)  # real model, isolated fake tools
    expected = case["expected"]
    assert result.status == expected["status"]
    assert result.refund_writes == expected["refund_writes"]
    assert result.model_calls <= expected["max_model_calls"]

Repeat cases when comparing model or prompt versions. Pair baseline and candidate on the same cases, preserve every attempt, and report per-case changes alongside aggregate task success, policy violations, cost, and p95 latency. Never retry failed evaluation cases until the dashboard turns green. Infrastructure errors should be reported separately, with a bounded retry policy declared before the run.

A tiny suite is a smoke test. Even 20 independent successes with zero failures leave an approximate 95% upper failure bound of 15% using the rule of three. Repeating one case 100 times does not create 100 independent customer scenarios. Choose sample sizes from the failure risk you need to rule out, and report uncertainty and scenario coverage.

Use code graders for schemas, amounts, tenant boundaries, citations pointing to retrieved records, and effects. Use a model judge only for genuinely semantic criteria such as whether an explanation is useful. Calibrate the judge against human labels, blind it to candidate identity, version its rubric/model, and audit disagreements. Treat the judge as untrusted input processing too; agent output can contain instructions to the grader.

5. Multi-agent TDD: test the handoffs and the outcome

A planner, researcher, and executor create additional failure boundaries. Give every handoff a schema and trace parent. Carry trusted identity outside model-generated text. A worker cannot grant another worker permissions by writing “approved” in its response.

Start with scripted workers around the real coordinator. Assert that no executor runs before validation; evidence IDs survive the handoff; a worker timeout yields an explicit partial/failure status; a duplicate event cannot duplicate the effect; cancellation stops scheduling new work; and competing workers cannot spend the same reserved budget.

For concurrent branches, test necessary partial order, not one exact transcript. “Validation happened before refund” is a useful contract. “Researcher A always finished before researcher B” is usually scheduler trivia. Exact trajectory matching is appropriate only when the exact sequence is a requirement.

Then run two live layers: each worker independently on its own cases, followed by the full workflow on a smaller suite. This distinguishes a weak worker from a broken handoff. Adding agents can increase cost and failure opportunities; compare against a single-agent baseline before claiming a quality benefit.

6. The same strategy, per framework

The framework changes the injection seam. It does not change the requirements. Keep one domain case set and normalize traces rather than rewriting the definition of success for each framework.

SetupSmallest useful test seamMore elaborate optionLikely ROI and adoption trigger
Plain PythonInject a callable model and tools into the loopJSON cases plus OpenTelemetry and an evaluation runnerHighest initial simplicity for a small workflow; maintenance rises as orchestration grows
PydanticAIAgent.override with TestModel or FunctionModel; fake dependenciesPydantic Evals and trace-based checksStrong for typed outputs; add evaluation tooling when case/report management hurts
LangGraphInject model/node dependencies; test nodes and compiled graph transitionsCheckpoint/resume tests, trajectory evaluation, optional LangSmithStrong for branching and durable state; a graph is overhead for a two-step task
CrewAITest tool functions and Flow state; replace the application-owned worker boundaryReal crew evaluation and role/task-level reportsUseful when role decomposition is justified; live judging alone is a weak policy gate
AWS StrandsInject model/tool boundaries; unit-test policy outside the modelOpen-source Strands Evals; graph/swarm trajectory evaluationNatural AWS-oriented path; keep the first tests credential-free
Bedrock / AgentCoreTest application adapter and AWS request contractsStaging Bedrock calls, AgentCore evaluations and CloudWatch tracesValuable when AWS runtime/IAM integration matters; highest setup burden for a local prototype

PydanticAI: model substitution is built in

This minimal example tests the override seam and output plumbing. It does not test tool selection or the refund policy; use FunctionModel to script precise tool calls and run the shared policy cases through your actual agent.

from pydantic_ai import Agent, models
from pydantic_ai.models.test import TestModel

models.ALLOW_MODEL_REQUESTS = False  # offline test process only
agent = Agent(TestModel())

def test_output_boundary():
    with agent.override(model=TestModel(custom_output_text="approval_required")):
        result = agent.run_sync("Refund A1")
    assert result.output == "approval_required"

The network guard belongs in your offline test fixture, not your production module. TestModel generates procedural test behavior; FunctionModel gives finer control. Neither measures model intelligence. See the official testing guide.

LangGraph: execute the graph, replace one boundary

This standalone example checks graph wiring and a policy node using injected decisions. A real-model adapter can later replace choose. Add a conditional edge and assert both branches when your graph grows.

from typing import TypedDict
from langgraph.graph import StateGraph, START, END

class State(TypedDict):
    approved: bool
    action: str
    status: str

def build(choose):
    graph = StateGraph(State)
    graph.add_node("propose", lambda state: {"action": choose(state)})
    graph.add_node("gate", lambda state: {
        "status": "ready" if state["approved"] else "approval_required"
    })
    graph.add_edge(START, "propose")
    graph.add_edge("propose", "gate")
    graph.add_edge("gate", END)
    return graph.compile()

def test_graph_gate():
    app = build(lambda _: "refund")
    result = app.invoke({"approved": False, "action": "", "status": ""})
    assert result["status"] == "approval_required"

For persistent workflows, test interruption, resume, duplicate delivery, state migration, and distinct thread IDs. An in-memory checkpointer is useful for isolation, but test your real database checkpoint contract separately. See LangGraph testing.

CrewAI: distinguish Flow tests from crew evaluation

Start with a deterministic Flow boundary. This example tests state-to-result wiring without constructing a live LLM. It is deliberately smaller than a full crew.

from pydantic import BaseModel
from crewai.flow.flow import Flow, start

class RefundState(BaseModel):
    approved: bool = False

class RefundFlow(Flow[RefundState]):
    @start()
    def gate(self):
        return "ready" if self.state.approved else "approval_required"

def test_flow_gate():
    assert RefundFlow().kickoff() == "approval_required"

When a Flow method calls a crew, inject your own worker(task) -> structured_result function at that method boundary; stub that worker in coordinator tests. Then test the real crew separately. CrewAI's test/evaluation features run model-backed assessments and introduce model cost and grader variance. They complement the deterministic suite. See CrewAI testing.

AWS Strands: separate local contracts from cloud evidence

Strands Evals can evaluate an ordinary task function, so it can reuse the offline harness before you enable Bedrock. This is a minimal example of its case/experiment structure; add exact policy checks in pytest and richer evaluators as needed.

from strands_evals import Case, Experiment, eval_task
from strands_evals.evaluators import OutputEvaluator

@eval_task()
def task():
    # Replace with your application adapter for a live experiment.
    return "approval_required"

def main():
    experiment = Experiment(
        cases=[Case(name="approval", input="Refund A1", expected_output="approval_required")],
        evaluators=[OutputEvaluator(
            rubric="Does the response clearly require approval before a refund?"
        )],
    )
    report = experiment.run_evaluations(task)
    report.run_display()

# main()  # model-backed evaluator: requires configured provider

This rubric judge itself can call a model even though the example task is a constant. It is an API illustration, not a policy test or a runnable offline benchmark. For the high-ROI first pass, keep the lab's exact assertions. The Strands quickstart explains provider setup and experiment execution.

For actual Bedrock agents, exercise one small staging suite with the intended region, model/inference-profile ID, IAM role, request/response adapter, and real timeouts. SDK request stubs can verify parameters and error mapping; they cannot prove model access, IAM authorization, service availability, or task quality.

AgentCore is a managed runtime/evaluation option, not an open-source framework. Its on-demand evaluations analyze selected agent spans. Adopt that layer when you already need AWS trace collection and operational evaluation. Include telemetry ingestion delay, access controls, retention, and evaluation charges in the decision. Do not require it to run a local unit test.

The PydanticAI, LangGraph, and CrewAI examples above passed locally with pydantic-ai-slim==2.57.0, langgraph==1.2.16, and crewai==1.15.28 on Python 3.12. The Strands example was import-checked against strands-agents-evals==1.5.0; its paid judge was not invoked. Framework APIs evolve. Pin and lock the versions you validate in your own project. The downloadable lab is independently executable; the framework snippets illustrate seams and should be validated against your selected SDK versions before being copied into CI.

7. Options and ROI: earn each additional layer

The following effort ranges are planning estimates for a small existing Python service, not measured benchmarks or vendor quotes. They exclude learning a new cloud platform and large data-cleaning work. Runtime costs depend on your model, token volume, repetitions, tool calls, CI, and retention.

OptionEstimated initial effortOngoing costReturn and limitationUpgrade when
A. pytest + scripted model + fake tools2–4 hoursCI minutes; no inference in offline testsExcellent initial return on policy, retries, budgets; no model-quality evidenceAlways add a small live smoke set before release
B. A + JSON cases + live model + code graders0.5–1 dayInference per case and repeatBest general starting balance; catches prompt/model regressionsReading individual reports becomes slow
C. B + open-source evaluation/reporting1–3 daysSame inference plus storage/runner maintenanceBetter slicing, comparison, and debugging; setup can exceed savings for ten casesMultiple contributors or frequent model changes
D. C + calibrated judges + multi-agent fault tests3–5 daysExtra judge calls, labeling, simulationUseful for semantic quality and handoff failures; judges can be wrongSemantic failures dominate manual review
E. Managed AWS tracing/evaluation + staging contracts2–5 days if AWS foundations existAWS inference, evaluation, telemetry, storage, CIStrong operational evidence and integration checks; adds IAM and telemetry workAWS operations and investigation time justify it
F. Simulation, chaos, load tests, shadow/canary evaluation1–2+ weeksEnvironments, traffic replay, model/tool spend, maintenanceValuable for high-impact or concurrent systems; simulated realism has limitsReal incident classes demand evidence cheaper tests cannot provide

C can use Pydantic Evals or Strands Evals; other open-source options include DeepEval, promptfoo, and Arize Phoenix. Compare their current licenses, local execution, telemetry defaults, and required integrations before choosing. These are alternatives with overlapping capabilities, not a stack you need to install together.

Put numbers on the decision

Use a fixed horizon, such as one month. Count saved investigation time and expected avoided incident loss without double-counting the same benefit.

Monthly benefit = debugging hours saved × hourly cost
                + expected incident loss avoided
Monthly cost    = setup amortization + maintenance + inference + infrastructure
ROI             = (benefit - cost) / cost

Illustrative assumptions: labor costs $80/hour; setup is amortized over three months. You estimate that a minimal suite saves three debugging hours and avoids $100 of incident loss per month. These are your estimates to challenge, not promises of causal savings.

Illustrative optionMonthly benefitMonthly costMonthly netROI
Minimal: 4h setup, 1h upkeep, $10 runtime$340$196.67$143.3373%
Reporting layer: 12h setup, 2h upkeep, $60 runtime; saves 6h + $200 loss$680$540$14026%
Operational suite: 30h setup, 4h upkeep, $200 runtime; saves 12h + $600 loss$1,560$1,320$24018%

These are three total-system scenarios, not incremental upgrades. To decide whether to move from one to another, compare incremental benefit against incremental cost. In this example reporting increases monthly cost by $343.33 and benefit by $340, so it does not yet earn its upgrade on financial grounds. Other requirements can still justify it.

For inference budgeting, count both candidate and baseline, every repeat, and judges. Example assumptions: 20 cases × 3 repeats × 2 versions = 120 agent runs. At an assumed $0.02 per complete run that is $2.40; one $0.005 judge call per run adds $0.60. The $3 total excludes tools, CI, telemetry, retries, and labor. Those unit costs are illustrative, not current Bedrock or model prices. Multi-agent cost is the sum of all worker/model/tool calls, not just the final response.

8. A release setup you can keep simple

Put deterministic tests on every change. Put a small live evaluation on changes to prompts, models, tools, retrieval, or orchestration. Run a broader held-out comparison before release and after provider changes. Keep the credential-bearing evaluation job separate from untrusted pull-request code.

# Offline gate: mandatory and cheap
python -m pytest agent_tdd.py -q

# Your application-owned runner: illustrative interface
python evals/run.py --cases evals/release.json --repeats 3 \
  --baseline releases/current.json --candidate releases/candidate.json

The second command is a suggested interface to implement, not a file included in the download. Make its exit code reflect explicit gates: zero policy violations, required task-quality floor, bounded quality regression against baseline, and cost/latency limits. Declare how missing runs and infrastructure errors affect the gate. A report file without a failing exit code will not block a release.

Record enough to reproduce a run: code commit, prompt hash, model/provider ID and settings, tool schemas, retrieval snapshot, framework lockfile, case-set version, grader version, time, seed when supported, and trace IDs. Temperature zero and a seed can reduce variation; neither guarantees deterministic hosted inference.

For the first implementation, I would keep one harness, eight offline regression tests, around 20 live cases, JSON reports, and fake write tools. Add a real staging contract suite for effectful integrations. Every production failure becomes a minimized regression at the cheapest layer that can reproduce it. That is how the suite grows with evidence while staying affordable.