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.
| Layer | Failure worth catching | Cheapest useful test | What passing does not establish |
|---|---|---|---|
| Tools and policy | Wrong tenant, invalid arguments, duplicate refund | Pure unit tests and service contract tests | Model chooses the right tool |
| Model adapter | Tool-call parsing, streaming fragments, malformed JSON | Recorded provider fixtures and schema validation | Current provider behavior is unchanged |
| Harness | Infinite loop, missing approval, retry after a committed write | Scripted model decisions through the real loop | Live-model task quality |
| One reasoning step | Bad extraction, unsupported claim, wrong routing | Curated inputs with real model and explicit checks | Entire workflow succeeds |
| Multi-agent workflow | Lost identity, incompatible handoff, duplicate side effects | Scripted workers through real orchestration | Real workers cooperate reliably |
| Whole deployed system | IAM, persistence, timeouts, deployment drift | Small staging end-to-end suite and production observation | Future 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:
| Case | Required evidence | Why it earns its place |
|---|---|---|
| Missing approval | Explicit blocked result and zero writes | Protects an expensive mistake |
| Order outside tenant scope | Forbidden result and zero writes | Checks the trust boundary |
| Successful task | Correct result and one ledger effect | Prevents a suite that only proves refusal |
| Repeated request | One effect across two deliveries | Models queue and HTTP retries |
| Response lost after commit | Retry returns the result without another effect | Covers an ambiguous distributed failure |
| Repeating model | Loop stops after three calls | Bounds runaway tool cycles |
| Unknown action | Explicit rejection and zero writes | Makes tool dispatch fail closed |
| Key reused for different payload | Conflict error | Prevents 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.
| Setup | Smallest useful test seam | More elaborate option | Likely ROI and adoption trigger |
|---|---|---|---|
| Plain Python | Inject a callable model and tools into the loop | JSON cases plus OpenTelemetry and an evaluation runner | Highest initial simplicity for a small workflow; maintenance rises as orchestration grows |
| PydanticAI | Agent.override with TestModel or FunctionModel; fake dependencies | Pydantic Evals and trace-based checks | Strong for typed outputs; add evaluation tooling when case/report management hurts |
| LangGraph | Inject model/node dependencies; test nodes and compiled graph transitions | Checkpoint/resume tests, trajectory evaluation, optional LangSmith | Strong for branching and durable state; a graph is overhead for a two-step task |
| CrewAI | Test tool functions and Flow state; replace the application-owned worker boundary | Real crew evaluation and role/task-level reports | Useful when role decomposition is justified; live judging alone is a weak policy gate |
| AWS Strands | Inject model/tool boundaries; unit-test policy outside the model | Open-source Strands Evals; graph/swarm trajectory evaluation | Natural AWS-oriented path; keep the first tests credential-free |
| Bedrock / AgentCore | Test application adapter and AWS request contracts | Staging Bedrock calls, AgentCore evaluations and CloudWatch traces | Valuable 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.
| Option | Estimated initial effort | Ongoing cost | Return and limitation | Upgrade when |
|---|---|---|---|---|
| A. pytest + scripted model + fake tools | 2–4 hours | CI minutes; no inference in offline tests | Excellent initial return on policy, retries, budgets; no model-quality evidence | Always add a small live smoke set before release |
| B. A + JSON cases + live model + code graders | 0.5–1 day | Inference per case and repeat | Best general starting balance; catches prompt/model regressions | Reading individual reports becomes slow |
| C. B + open-source evaluation/reporting | 1–3 days | Same inference plus storage/runner maintenance | Better slicing, comparison, and debugging; setup can exceed savings for ten cases | Multiple contributors or frequent model changes |
| D. C + calibrated judges + multi-agent fault tests | 3–5 days | Extra judge calls, labeling, simulation | Useful for semantic quality and handoff failures; judges can be wrong | Semantic failures dominate manual review |
| E. Managed AWS tracing/evaluation + staging contracts | 2–5 days if AWS foundations exist | AWS inference, evaluation, telemetry, storage, CI | Strong operational evidence and integration checks; adds IAM and telemetry work | AWS operations and investigation time justify it |
| F. Simulation, chaos, load tests, shadow/canary evaluation | 1–2+ weeks | Environments, traffic replay, model/tool spend, maintenance | Valuable for high-impact or concurrent systems; simulated realism has limits | Real 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 option | Monthly benefit | Monthly cost | Monthly net | ROI |
|---|---|---|---|---|
| Minimal: 4h setup, 1h upkeep, $10 runtime | $340 | $196.67 | $143.33 | 73% |
| Reporting layer: 12h setup, 2h upkeep, $60 runtime; saves 6h + $200 loss | $680 | $540 | $140 | 26% |
| Operational suite: 30h setup, 4h upkeep, $200 runtime; saves 12h + $600 loss | $1,560 | $1,320 | $240 | 18% |
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.