ProductHow it worksPricingBlogDocsLoginFind Your First Bug
A six-stage arc for testing an AI agent, from tracing and deterministic evals through LLM-as-judge, tool-call trajectory checks, behavioral E2E, and a CI regression gate
AITestingAI Agent Testing

How to Test an AI Agent (End to End)

Tom Piaggio
Tom PiaggioCo-Founder at Autonoma

How to test an AI agent end to end means running six connected checks: tracing every LLM call and tool call so you can assert on the trajectory, deterministic evals on structured output, LLM-as-judge scoring on free text, trajectory assertions on which tools got called in what order, behavioral end-to-end checks on what actually happened inside the running app, and a CI gate that catches regressions across all five. Each stage catches a failure the others cannot see.

Search "how to test an AI agent" and you get a wall of vocabulary: evals, guardrails, hallucination rate, agentic reliability. What you don't get is a single runnable test. This guide is different on purpose. Every stage below ships with real, working code: pytest files you can clone and run today, not a diagram of a concept.

The one thing to get straight before any of that code matters: testing an agent's behavior is not the same task as using an agent to do your testing. This guide is entirely about the first one. If an article about "AI agent testing" starts describing a QA copilot that writes your Selenium scripts for you, it has wandered into a different, unrelated category.

The second thing to get straight, and the idea this whole arc builds toward: a response that sounds correct is not the same thing as an action that happened correctly. An agent can pick the right tool, phrase the confirmation perfectly, and still leave the underlying application completely unchanged. Every stage in this guide gets you closer to catching that gap, and the stage that actually closes it (Stage 5) is the one most testing guides skip entirely.

Testing the Agent, Not Using One to Test

A few widely cited pieces get part of this arc right. PostHog's internal writeup lays out a genuinely correct conceptual sequence, tracing, deterministic evals, LLM-as-judge, offline and online checks, then regression in CI, but it ships as prose with no code attached. IBM, Salesforce's Agentforce docs, and Hitachi's engineering blog all rank well here and describe the same concepts at a vocabulary level: "establish evaluation criteria," "monitor for drift," "implement guardrails." None hand you a file you can run.

That's the gap this guide closes. Below is the same six-stage shape, but every stage has a companion file in the repo linked above, built to run against a small example agent (a support agent with a lookup tool and a refund tool) that behaves realistically enough to make each failure mode concrete.

1. Tracecapture calls2. DeterministicEvalsstructured output3. Judgefree text4. Trajectorytool order5. BehavioralE2Ethe app itself6. CI Regression Gate: runs 1-5 on every PR, blocks silent drift

Six stages, one arc. Stages 1 through 4 verify the agent's decisions, Stage 5 verifies the application, and Stage 6 keeps all of it honest on every PR.

StageVerifiesMissesTooling
1. TraceWhat steps ranWhether steps were correctWrapper / spans
2. Deterministic evalsStructured fields, enumsFree-text qualitypytest, jsonschema
3. LLM-as-judgeFree-text qualityWhether tools ran correctlyJudge model + rubric
4. TrajectoryTool order, argumentsApp-side effectsTrace replay + pytest
5. Behavioral E2EWhat the app actually didNothing upstream, but needs 1-4 firstLive preview, browser checks
6. CI regressionAll of the above, per PRAnything not in the suiteGitHub Actions

Stage 1: Trace Every Step the Agent Takes

Before you can assert on anything, you need a record of what actually happened. An agent's "run" isn't one function call, it's a sequence: an LLM call to decide what to do, a tool call to do it, another LLM call to interpret the result, maybe a retry, maybe a second tool call. If your test only checks the final string the agent returns, every failure in that sequence collapses into one undifferentiated "test failed," and you're debugging blind.

The fix is a tracing wrapper that sits around every LLM call and every tool call and records it, in order, with inputs and outputs, before any assertion runs. That trace is what Stages 2 through 4 actually assert against.

Here's a minimal tracer that wraps an agent's LLM and tool calls and produces a structured, replayable trace:

"""Agent tracing wrapper.

Wraps an agent so that every LLM call, tool call, and decision it makes is
captured into a structured, ordered trace. The trace is a plain list of step
dicts (JSON-serializable) that downstream tests can replay and assert against.

Design goals:
  * No external dependencies (standard library only).
  * Deterministic: steps are recorded in call order, no timestamps, so two runs
    of the same agent logic produce byte-identical traces.
  * Non-invasive: you decorate the functions the agent already calls.

Run it directly to see a small stub agent loop traced end to end:

    python src/tracing.py

It prints a JSON trace to stdout.
"""

from __future__ import annotations

import functools
import json
from typing import Any, Callable, Dict, List


class Tracer:
    """Collects an ordered list of steps an agent takes.

    Each step is a dict of the form::

        {"type": "llm" | "tool" | "decision",
         "name": <str>,
         "args": <json-serializable>,
         "output": <json-serializable>}

    Ordering is implicit in list position, so we never record timestamps. That
    keeps traces reproducible and diffable in tests.
    """

    def __init__(self) -> None:
        self.steps: List[Dict[str, Any]] = []

    def record(self, step_type: str, name: str, args: Any, output: Any) -> None:
        self.steps.append(
            {"type": step_type, "name": name, "args": args, "output": output}
        )

    def decision(self, name: str, chosen: Any, options: Any = None) -> None:
        """Record a branching decision the agent made (e.g. which tool to use)."""
        self.record("decision", name, {"options": options}, chosen)

    def wrap(self, step_type: str, fn: Callable) -> Callable:
        """Return a wrapped version of ``fn`` that records every invocation.

        ``step_type`` is "llm" for model calls and "tool" for tool calls.
        """

        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            output = fn(*args, **kwargs)
            # Positional args are captured as a list; kwargs merged in so the
            # recorded args are a single JSON object regardless of call style.
            recorded_args: Dict[str, Any] = dict(kwargs)
            if args:
                recorded_args["_positional"] = list(args)
            self.record(step_type, fn.__name__, recorded_args, output)
            return output

        return wrapper

    def to_json(self) -> str:
        return json.dumps(self.steps, indent=2, sort_keys=False)


# --- A tiny stub agent so the module is runnable on its own -----------------


def _fake_llm(prompt: str) -> str:
    """Stand-in for a real model call. Deterministic for reproducibility.

    Two intents: a planning turn (decide whether to use a tool) and a
    finalizing turn (write the user-facing reply once results are in).
    """
    lowered = prompt.lower()
    if "write the final reply" in lowered:
        # Finalizing turn: produce the user-facing answer.
        return "final:It is currently 18C in Paris."
    if "weather" in lowered:
        # Planning turn: this needs the weather tool.
        return "call_tool:get_weather"
    return "final:I can help with weather questions."


def _get_weather(city: str) -> Dict[str, Any]:
    """Stand-in tool. Returns a fixed payload so runs are deterministic."""
    table = {"Paris": 18, "Tokyo": 24}
    return {"city": city, "temp_c": table.get(city, 20), "unit": "celsius"}


def run_stub_agent(tracer: Tracer, user_message: str) -> str:
    """A minimal plan/act agent loop, fully traced.

    1. Ask the LLM what to do.
    2. If it wants a tool, record the decision and call the (wrapped) tool.
    3. Ask the LLM again to produce the final answer.
    """
    llm = tracer.wrap("llm", _fake_llm)
    get_weather = tracer.wrap("tool", _get_weather)

    plan = llm(f"User asks: {user_message}. What should I do?")

    if plan.startswith("call_tool:"):
        tool_name = plan.split(":", 1)[1]
        tracer.decision("route", chosen=tool_name, options=["get_weather", "answer_directly"])
        # In this stub we only support one tool.
        weather = get_weather("Paris")
        final = llm(f"Weather is {weather['temp_c']}C. Write the final reply.")
    else:
        tracer.decision("route", chosen="answer_directly", options=["get_weather", "answer_directly"])
        final = plan

    answer = final.split(":", 1)[1] if ":" in final else final
    tracer.record("decision", "final_answer", args={}, output=answer)
    return answer


def main() -> None:
    tracer = Tracer()
    run_stub_agent(tracer, "What's the weather in Paris?")
    print(tracer.to_json())


if __name__ == "__main__":
    main()

Run it against a scripted agent loop and you get a list of typed events (llm_call, tool_call, decision) instead of a single opaque output. That's the raw material every later stage reads from. Skip it and every stage after degrades into treating the agent as a black box, which is exactly the posture that makes agent behavior feel unpredictable in the first place.

Stage 2: Deterministic Evals on Structured Output

Not everything an agent produces is prose. When your agent returns a typed field (a status enum, a routing decision, a JSON payload with a fixed schema), you don't need a judge model and you don't need fuzzy matching. You need the same tool you'd use to test any other function: plain pytest, asserting exact values or schema validity.

This is the stage most teams skip past because it feels "too basic" for an AI feature. It's the opposite: it's the cheapest, fastest, most reliable stage in the whole arc, and it should absorb as much of your assertion surface as the agent's output shape allows.

Here's a pytest suite asserting exact-match and schema-valid output on the support agent's structured decision object:

"""Deterministic evaluation of an agent's *structured* output.

When an agent is asked to emit machine-readable output (JSON with an enum, a
classification, a set of extracted fields), you do not need an LLM to grade it.
You assert on it like any other function's return value:

  * exact-match assertions on individual fields, and
  * jsonschema validation of the whole object (shape, types, allowed enum
    values, required keys).

This is the cheapest, most reliable layer of an agent test suite: fast,
deterministic, and free. Run with::

    pytest tests/test_structured_output.py
"""

import jsonschema
import pytest
from jsonschema import Draft7Validator

# The contract the agent's structured output must satisfy.
TRIAGE_SCHEMA = {
    "type": "object",
    "additionalProperties": False,
    "required": ["intent", "priority", "needs_human", "entities"],
    "properties": {
        "intent": {
            "type": "string",
            "enum": ["billing", "bug_report", "feature_request", "other"],
        },
        "priority": {"type": "string", "enum": ["low", "medium", "high"]},
        "needs_human": {"type": "boolean"},
        "entities": {
            "type": "array",
            "items": {"type": "string"},
        },
    },
}


@pytest.fixture
def agent_output():
    """A sample structured response, as an agent would return it.

    In a real suite this comes from invoking your agent with a fixed prompt.
    Here it is inlined so the test is self-contained and deterministic.
    """
    return {
        "intent": "billing",
        "priority": "high",
        "needs_human": True,
        "entities": ["invoice #4471", "double charge"],
    }


def test_output_matches_schema(agent_output):
    """The whole object conforms to the JSON schema (shape + enums + types)."""
    # validate() raises on failure with a readable error path.
    jsonschema.validate(instance=agent_output, schema=TRIAGE_SCHEMA)


def test_no_extra_or_missing_fields(agent_output):
    """additionalProperties=False means a hallucinated extra key fails."""
    polluted = dict(agent_output)
    polluted["hallucinated_field"] = "oops"
    errors = list(Draft7Validator(TRIAGE_SCHEMA).iter_errors(polluted))
    assert errors, "expected schema to reject an unexpected extra field"


def test_exact_field_values(agent_output):
    """Exact-match assertions on the fields that must be deterministic."""
    assert agent_output["intent"] == "billing"
    assert agent_output["priority"] == "high"
    assert agent_output["needs_human"] is True


def test_enum_violation_is_rejected(agent_output):
    """An out-of-enum value must fail validation, not slip through."""
    bad = dict(agent_output, priority="URGENT")  # not in the enum
    with pytest.raises(jsonschema.ValidationError):
        jsonschema.validate(instance=bad, schema=TRIAGE_SCHEMA)

If your agent's structured output doesn't validate here, stop. Don't chase the failure into a judge model or a trajectory replay. A broken enum or a malformed field is a deterministic bug with a deterministic fix, and running it through a probabilistic check first only hides where the failure actually is.

Stage 3: LLM-as-Judge on Free-Text Output

Once the structured fields check out, there's usually still a free-text response: the message the agent actually shows the user. You can't exact-match that (the same correct answer has infinite valid phrasings), so the standard pattern is a judge model that scores the response against a rubric.

The reliability problem with LLM-as-judge isn't the concept, it's sloppy rubrics. A judge prompt that asks "is this a good response?" will drift, because "good" isn't a criterion, it's a vibe. A judge prompt that asks three or four yes/no questions ("does it mention the refund amount," "does it avoid promising a timeline the agent doesn't control") produces a score you can threshold and trust run over run.

Here's a judge harness with a rubric-based prompt, scoring a free-text response and asserting the score clears a threshold:

"""LLM-as-judge harness for grading free-text agent responses.

Not everything an agent produces can be checked with an exact-match assertion.
For open-ended answers you grade against an explicit, multi-criteria rubric and
have a *judge model* return a numeric score. The test then asserts the score
clears a threshold.

Keys to making this reliable:
  * The rubric is explicit and enumerated. Vague rubrics produce noisy scores.
  * The judge is asked to return machine-readable JSON, not prose.
  * Temperature is 0 so the judge is as reproducible as the provider allows.

This file is safe to collect and run without an API key: it skips gracefully
when OPENAI_API_KEY is unset. With a key set::

    pytest tests/test_llm_judge.py
"""

import json
import os

import pytest

# The rubric. Each criterion is scored 0-2 by the judge; total is 0-10.
RUBRIC = """\
You are a strict evaluator. Score the ASSISTANT RESPONSE against the USER
QUESTION on each criterion below. For each, award an integer 0, 1, or 2:
  0 = fails the criterion, 1 = partially meets it, 2 = fully meets it.

Criteria:
  1. RELEVANCE   - directly answers the user's actual question.
  2. CORRECTNESS - factually accurate, no fabricated details.
  3. COMPLETENESS- covers the key points the question requires.
  4. GROUNDING   - claims are supported, no unsupported speculation.
  5. CLARITY     - well structured and unambiguous.

Return ONLY minified JSON of the form:
{"relevance":N,"correctness":N,"completeness":N,"grounding":N,"clarity":N}
"""

# Below this total (out of 10) the response is considered a regression.
SCORE_THRESHOLD = 8


def build_judge_messages(question: str, answer: str):
    """Construct the chat messages sent to the judge model."""
    return [
        {"role": "system", "content": RUBRIC},
        {
            "role": "user",
            "content": (
                f"USER QUESTION:\n{question}\n\n"
                f"ASSISTANT RESPONSE:\n{answer}\n\n"
                "Grade now and return only the JSON object."
            ),
        },
    ]


def judge_score(question: str, answer: str, model: str = "gpt-4o-mini") -> int:
    """Call the judge model and return a total score in the range 0-10.

    Requires OPENAI_API_KEY. Raises if the judge returns unparseable output so
    a broken judge fails loudly rather than silently passing.
    """
    from openai import OpenAI  # imported lazily so import errors don't break collection

    client = OpenAI()  # reads OPENAI_API_KEY from the environment
    resp = client.chat.completions.create(
        model=model,
        temperature=0,
        response_format={"type": "json_object"},
        messages=build_judge_messages(question, answer),
    )
    raw = resp.choices[0].message.content
    scores = json.loads(raw)
    return sum(int(scores[k]) for k in
               ("relevance", "correctness", "completeness", "grounding", "clarity"))


@pytest.mark.skipif(
    not os.getenv("OPENAI_API_KEY"),
    reason="OPENAI_API_KEY not set; skipping live LLM-judge test.",
)
def test_agent_answer_clears_threshold():
    question = "What HTTP status code should a health-check endpoint return when healthy?"
    # A good answer we expect the judge to score highly.
    answer = (
        "A healthy health-check endpoint should return HTTP 200 OK. When a "
        "dependency is down, return 503 Service Unavailable so load balancers "
        "and orchestrators can route traffic away from the instance."
    )
    score = judge_score(question, answer)
    assert score >= SCORE_THRESHOLD, (
        f"judge scored {score}/10, below threshold {SCORE_THRESHOLD}"
    )

Treat the rubric itself as a versioned artifact, not a prompt you tweak inline when a test fails. When you loosen a rubric to make a stubborn test pass, you're not fixing the test, you're quietly lowering the bar the agent has to clear in production.

Stage 4: Assert on Tool Calls and Trajectory

A response can be well-formed and well-scored and still be wrong, if the agent got there by calling the wrong tool, the right tool with the wrong arguments, or tools in an order that happens to produce the right words without doing the right work. This is where the trace from Stage 1 stops being a debugging aid and becomes the thing you assert against directly.

Trajectory testing means replaying that trace and checking which tools got called, in what order, with what arguments. It should also cover recovery: if a tool call fails (a timeout, a malformed response), does the agent retry sensibly, or silently give up while still telling the user it succeeded.

Here's a trajectory test that replays a captured trace and asserts on tool sequence, arguments, and a tool-failure recovery path:

"""Trajectory (trace replay) tests.

A response-level eval only sees the agent's final answer. A trajectory test
inspects *how* the agent got there: which tools it called, in what order, with
what arguments, and how it recovered from a failing tool.

We replay a captured trace (the same structure emitted by ``src/tracing.py``)
and assert on the sequence of steps. This catches regressions that a
final-answer check misses, e.g. the agent silently skipping a required tool, or
calling tools in the wrong order, or not retrying after a transient failure.

Run with::

    pytest tests/test_trajectory.py
"""

import pytest

# A sample recorded trace, shaped exactly like Tracer.steps in src/tracing.py.
# This one includes a tool failure followed by a successful retry (recovery).
RECORDED_TRACE = [
    {"type": "llm", "name": "_fake_llm",
     "args": {"_positional": ["User asks: refund order 4471. What should I do?"]},
     "output": "call_tool:lookup_order"},
    {"type": "decision", "name": "route",
     "args": {"options": ["lookup_order", "answer_directly"]},
     "output": "lookup_order"},
    # First tool call fails (simulated transient error).
    {"type": "tool", "name": "lookup_order",
     "args": {"order_id": "4471"},
     "output": {"ok": False, "error": "timeout"}},
    # Agent decides to retry rather than give up.
    {"type": "decision", "name": "retry",
     "args": {"reason": "timeout"},
     "output": "retry_lookup_order"},
    # Retry succeeds.
    {"type": "tool", "name": "lookup_order",
     "args": {"order_id": "4471"},
     "output": {"ok": True, "status": "shipped", "total": 42.0}},
    {"type": "tool", "name": "issue_refund",
     "args": {"order_id": "4471", "amount": 42.0},
     "output": {"ok": True, "refund_id": "rf_991"}},
    {"type": "decision", "name": "final_answer", "args": {},
     "output": "Your refund of $42.00 has been issued."},
]


def steps_of_type(trace, step_type):
    return [s for s in trace if s["type"] == step_type]


def tool_names(trace):
    return [s["name"] for s in trace if s["type"] == "tool"]


def test_tools_called_in_expected_order():
    """The agent must look up the order before issuing a refund."""
    names = tool_names(RECORDED_TRACE)
    assert names == ["lookup_order", "lookup_order", "issue_refund"]
    # lookup must precede refund (never refund an order we never looked up).
    assert names.index("issue_refund") > names.index("lookup_order")


def test_refund_amount_matches_looked_up_total():
    """The refund argument must be derived from the tool result, not invented."""
    tools = steps_of_type(RECORDED_TRACE, "tool")
    successful_lookup = next(
        s for s in tools if s["name"] == "lookup_order" and s["output"]["ok"]
    )
    refund = next(s for s in tools if s["name"] == "issue_refund")
    assert refund["args"]["amount"] == successful_lookup["output"]["total"]


def test_failure_then_retry_recovery():
    """A transient tool failure must be followed by a retry decision + success."""
    lookups = [s for s in RECORDED_TRACE
               if s["type"] == "tool" and s["name"] == "lookup_order"]
    assert len(lookups) == 2, "expected one failed lookup and one retry"
    assert lookups[0]["output"]["ok"] is False
    assert lookups[1]["output"]["ok"] is True

    decisions = [s["output"] for s in steps_of_type(RECORDED_TRACE, "decision")]
    assert "retry_lookup_order" in decisions, "agent should decide to retry on failure"


def test_no_refund_before_successful_lookup():
    """Guard against the dangerous ordering: refund issued off a failed lookup."""
    seen_successful_lookup = False
    for step in RECORDED_TRACE:
        if step["type"] == "tool" and step["name"] == "lookup_order" and step["output"]["ok"]:
            seen_successful_lookup = True
        if step["type"] == "tool" and step["name"] == "issue_refund":
            assert seen_successful_lookup, "refund issued before a successful lookup"

Notice what this stage still can't tell you. It confirms the agent called process_refund with the right order ID and the right amount. It does not confirm a refund actually posted anywhere. That confirmation lives one stage further out, and it's the one most agent-testing writeups never reach.

Handling Non-Determinism: Majority Vote and Semantic Similarity

Every stage above runs into the same problem eventually: the same input, run twice, can produce two different outputs. That's not a bug in your test, it's a property of the system, and treating it like a bug (chasing "flakiness" until a test always passes) usually means you've quietly weakened the assertion until it stops testing anything.

Two patterns hold up. First, run N times and require a majority, not unanimity: 5 runs with at least 4 passing, rather than trusting a single run to be representative. A scenario that passes 5-of-5 today and 2-of-5 next week just told you something real changed (a prompt edit, a model version bump, an ambiguous tool description), which one run would have hidden. Second, assert on semantic similarity instead of exact match: two correct responses can be worded completely differently, so compare meaning (embedding similarity, or a judge model's yes/no on equivalence) rather than string equality.

Here's a helper that runs a flaky-by-nature scenario N times and asserts on majority pass rate using semantic similarity instead of exact match:

"""Majority-vote helper for non-deterministic agent runs.

Agents are stochastic: the same prompt can produce different phrasings, and an
occasional run is simply wrong. Asserting on a single run makes a test flaky.
Instead, run the agent N times and require at least K of N runs to clear a
similarity threshold against a reference answer. That converts a flaky signal
into a stable pass/fail without pretending the agent is deterministic.

Similarity here uses ``difflib.SequenceMatcher`` (standard library) so the
helper runs with no extra dependencies. Swap in a semantic embedding similarity
for production use; the K-of-N structure stays the same.

Run it directly to see a 4-of-5 majority pass::

    python lib/majority_vote.py
"""

from __future__ import annotations

import difflib
from typing import Callable, List


def similarity(candidate: str, reference: str) -> float:
    """Return a 0..1 similarity ratio between two strings.

    Uses a token-set Jaccard blended with difflib's sequence ratio so that both
    word overlap and ordering contribute. Deterministic and dependency-free.
    """
    a_tokens = set(candidate.lower().split())
    b_tokens = set(reference.lower().split())
    if not a_tokens or not b_tokens:
        jaccard = 0.0
    else:
        jaccard = len(a_tokens & b_tokens) / len(a_tokens | b_tokens)
    seq = difflib.SequenceMatcher(None, candidate.lower(), reference.lower()).ratio()
    return (jaccard + seq) / 2.0


def majority_vote(
    agent: Callable[[], str],
    reference: str,
    n: int = 5,
    k: int = 4,
    threshold: float = 0.6,
) -> bool:
    """Run ``agent`` ``n`` times; pass if at least ``k`` runs clear ``threshold``.

    ``agent`` is a zero-arg callable returning the agent's text answer. Keeping
    it zero-arg lets callers bind their own prompt via a closure/partial.
    """
    passes = 0
    for _ in range(n):
        answer = agent()
        if similarity(answer, reference) >= threshold:
            passes += 1
    return passes >= k


# --- Demonstration: a flaky-by-nature agent that is right most of the time --


def _demo_flaky_agent() -> Callable[[], str]:
    """Return a stateful stub agent.

    It gives a good answer on runs 1,2,3,5 and a wrong answer on run 4, so the
    demo produces exactly a 4-of-5 majority pass, deterministically.
    """
    good = "The health check returns HTTP 200 when the service is healthy."
    bad = "It always returns 500 no matter what."
    scripted = [good, good, good, bad, good]
    state = {"i": 0}

    def agent() -> str:
        answer = scripted[state["i"] % len(scripted)]
        state["i"] += 1
        return answer

    return agent


def main() -> None:
    reference = "A healthy health check returns HTTP 200."
    agent = _demo_flaky_agent()

    # Show each run's similarity so the 4-of-5 outcome is transparent.
    agent_for_report = _demo_flaky_agent()
    scores: List[float] = [round(similarity(agent_for_report(), reference), 3) for _ in range(5)]
    print(f"Per-run similarity vs reference: {scores}")

    passed = majority_vote(agent, reference, n=5, k=4, threshold=0.4)
    print(f"Majority vote (k=4 of n=5): {'PASS' if passed else 'FAIL'}")

    # Matching pytest-style assertion (also runs here as a sanity check).
    assert passed, "expected a 4-of-5 majority pass"


if __name__ == "__main__":
    main()

A scenario that drops from 5-of-5 to 3-of-5 overnight is a real signal worth investigating. A scenario that's always exactly 5-of-5 on an exact-match assertion is usually a sign the assertion is too loose to ever fail, not a sign the agent is unusually stable.

Stage 5: Behavioral E2E on the Running App

Here's the stage every guide up to this point in the arc, and nearly every published piece on this topic, stops short of. Stages 1 through 4 can all pass, cleanly, at 5-of-5, with a rubric score above threshold and a trajectory that called the exact right tool with the exact right arguments, and the underlying application can still be untouched. The agent said "Refund processed for order 4821." The tool call trace shows process_refund(order_id="4821", amount=42.00) executed with no error. None of that confirms a refund row exists, that the order status actually flipped, or that the user looking at their account a minute later sees anything different.

That gap exists because every stage so far verifies the agent's decision, not the application's outcome. A tool call that returns {"status": "success"} satisfies the deterministic eval, the judge, and the trajectory assertion, whether or not anything downstream actually changed. Verifying the response is not the same task as verifying the action.

Closing that gap means testing the application the same way a user would experience it after the agent acted, not the agent's output in isolation. A minimal version of that is querying the app's own API or database independently of the agent, after the tool call, to confirm the state actually changed:

"""Behavioral end-to-end test: the response-vs-action gap.

This is the layer that response-level evals never see. An agent can *say* it
placed an order, score perfectly on an LLM-judge rubric, and yet no order row
was ever written. The only way to catch that is to check the world the agent
was supposed to change, independently of what the agent reported.

So this test does two things:
  1. Calls the agent's checkout tool and reads its (self-reported) response.
  2. Independently queries the application's OWN API (a real orders endpoint)
     to verify the order actually exists with the expected total.

If step 1 claims success but step 2 finds no matching order, the test fails.
That mismatch is invisible to any eval that only reads the agent's text.

The test is illustrative and network-dependent: it targets a local app on
http://localhost:8000 and skips cleanly if that app is not running, so the file
is always safe to collect. Run with::

    pytest tests/test_behavioral_e2e_illustration.py
"""

import uuid

import pytest
import requests

APP_BASE_URL = "http://localhost:8000"
TIMEOUT = 3  # seconds


def app_is_running(base_url: str = APP_BASE_URL) -> bool:
    """Cheap liveness probe so we can skip when the app is not up."""
    try:
        requests.get(f"{base_url}/health", timeout=TIMEOUT)
        return True
    except requests.RequestException:
        return False


# --- Stand-in for the agent's checkout tool --------------------------------
#
# In your real suite this calls the actual agent / tool. Here it posts to the
# same app so the file is self-contained. The point is the *assertion pattern*,
# not this particular implementation.


def agent_checkout(sku: str, quantity: int, idempotency_key: str) -> dict:
    """Invoke the agent's checkout tool; return its self-reported response."""
    resp = requests.post(
        f"{APP_BASE_URL}/agent/checkout",
        json={"sku": sku, "quantity": quantity, "idempotency_key": idempotency_key},
        timeout=TIMEOUT,
    )
    resp.raise_for_status()
    return resp.json()


def fetch_orders(idempotency_key: str) -> list:
    """Query the app's OWN orders API, independent of the agent's report."""
    resp = requests.get(
        f"{APP_BASE_URL}/orders",
        params={"idempotency_key": idempotency_key},
        timeout=TIMEOUT,
    )
    resp.raise_for_status()
    return resp.json().get("orders", [])


@pytest.mark.skipif(
    not app_is_running(),
    reason=f"App not reachable at {APP_BASE_URL}; skipping behavioral e2e test.",
)
def test_checkout_actually_creates_an_order():
    idempotency_key = f"test-{uuid.uuid4()}"

    # 1. What the agent SAYS happened.
    reported = agent_checkout(sku="SKU-123", quantity=2, idempotency_key=idempotency_key)
    assert reported.get("status") == "success", (
        "agent reported a non-success status; nothing further to verify"
    )

    # 2. What ACTUALLY happened in the system of record. This independent check
    #    is the whole point: a response-level eval would stop at step 1.
    orders = fetch_orders(idempotency_key)
    assert len(orders) == 1, (
        "response-vs-action gap: agent claimed success but the orders API has "
        f"{len(orders)} matching rows (expected exactly 1)"
    )

    order = orders[0]
    assert order["sku"] == "SKU-123"
    assert order["quantity"] == 2
    # Cross-check the agent's self-reported total against the persisted row.
    assert order["total"] == reported.get("total"), (
        "agent's reported total does not match the persisted order total"
    )

That's a hand-rolled version of the idea, useful for a single endpoint on a small agent. It gets expensive fast: every new tool, every new flow, every UI surface the agent's actions touch needs its own behavioral check, written and maintained by hand, against an environment that has to exist and stay in sync with the code.

How Autonoma Verifies What Your Agent Actually Does

The pattern Stage 5 documents (response-correct, action-wrong) is the exact failure mode that response-level evals structurally cannot see, no matter how good the judge rubric or the trajectory assertion gets. An agent can clear every check in Stages 1 through 4 and still leave a button unclicked, a record unwritten, or a screen unchanged, because none of those checks ever looks at the running application itself.

Agent Response Layer"Refund processed." Right tool,right arguments, no errorverified by Stages 1-4the gapApplication Action LayerDid the row get written? Didthe UI actually update?no eval above sees thisAutonoma is the layer that closes this gap: it checks the app, not just the words

The agent can be response-correct and action-wrong at the same time. Behavioral E2E is the only stage that checks the right side.

That's the layer Autonoma is built for, and it's deliberately not another eval framework, not an observability platform, and not a model benchmark. Autonoma's Planner reads your codebase (the routes, components, and flows your agent's tools are supposed to affect) and plans end-to-end test cases around what should actually happen after a tool call, including generating the database state each scenario needs to start from. The Execution Agent runs those planned tests against an isolated PreviewKit environment per pull request, Autonoma's managed preview-environments layer, driving the real UI the same way a user would after the agent's action fired. The GenerationReviewer then classifies each result, a genuine bug in the application, an agent execution error, or a mismatch between the plan and what the code now does, so a failure doesn't just get flagged, it gets triaged. Diffs Agent keeps that suite current on every PR by analyzing the code diffs, so the behavioral coverage doesn't quietly rot the next time a tool's downstream effect changes.

Mapped against this guide's arc: Stages 1 through 4 answer whether the agent decided correctly. Autonoma answers the question none of them can, whether the application the agent acted on ended up in the state it was supposed to. Both answers matter, and neither substitutes for the other.

Stage 6: Gate Merges With Regression in CI

A suite that only runs on your laptop protects nothing. The value of everything above compounds only if it runs automatically, on every pull request, and fails the merge when something regresses, including regressions nobody wrote code to cause. A model-provider update that silently changes how the underlying LLM phrases refusals, or a tool description edit that makes two tools ambiguous to the model, can break trajectory and judge assertions without a single line of your code changing.

The deterministic suite (Stage 2) should run on every commit, fast and cheap, since it needs no API call and no live model. The judge and trajectory suites (Stages 3 and 4) are slower and cost real API calls, so most teams run them on every PR rather than every commit, with the majority-vote pattern from earlier absorbing normal run-to-run variance instead of re-running the entire matrix on each push. Behavioral E2E (Stage 5) needs a deployed environment to run against, which is usually the long pole in the pipeline, so it runs per PR against an isolated PreviewKit environment, Autonoma's managed preview-environments layer, and it's the stage most teams end up gating last simply because it's the most expensive to stand up correctly.

Here's a GitHub Actions workflow that runs the deterministic, judge, and trajectory suites on every PR and fails the merge on regression:

name: agent-tests

# Gate every pull request (and pushes to main) on the agent test suite.
# The deterministic, judge, and trajectory suites run here so a silent
# model-provider update or a broken tool integration is caught before merge
# rather than in production.

on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Check out code
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: pip

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      # Deterministic structured-output checks. Fast, free, no API key.
      - name: Deterministic output suite
        run: pytest tests/test_structured_output.py -v

      # Trajectory / trace-replay checks. Also deterministic, no API key.
      - name: Trajectory suite
        run: pytest tests/test_trajectory.py -v

      # LLM-as-judge suite. Skips gracefully if OPENAI_API_KEY is absent, so
      # forks without secrets still pass; the canonical repo runs it for real.
      - name: LLM-judge suite
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
        run: pytest tests/test_llm_judge.py -v

Wire this in and a silent provider-side change stops being something you discover from a support ticket three weeks later. It shows up as a red check on the specific PR that happened to be open when the model changed underneath you, with the failing scenario and the majority-vote pass rate right there in the log, which is a dramatically better place to find out than a customer complaint.

Worth watching separately: cost and latency. A model update can keep every assertion green while doubling your per-call token spend or adding half a second to every tool call, and neither shows up as a failure unless you assert on it. Log token counts and latency alongside pass/fail, and flag the PR if either drifts past a threshold from the rolling baseline.

Putting the Six Stages Together

None of these six stages replace each other, and skipping any one of them leaves a specific, predictable blind spot. Tracing gives you the record everything else reads from, without it every later assertion is guessing at what actually happened inside a run. Deterministic evals catch structural bugs cheaply, before you've spent a single judge-model call on a response that was never going to validate anyway. LLM-as-judge catches quality problems in free text that no exact-match assertion could ever express, provided the rubric is specific enough to threshold reliably. Trajectory assertions catch the case where the response looks fine but the agent got there the wrong way, calling a tool out of order or with an argument that happened to work this one time.

Behavioral E2E catches the failure none of the first four can see on their own: an agent that is correct on paper and wrong in the running application, which is precisely the gap that makes "the tests are all green" feel like false comfort the first time a user reports something the suite never flagged. CI regression makes sure all five keep holding as the model, the prompts, and the code underneath all keep changing, turning a one-time test run into a standing guarantee instead of a snapshot that goes stale the day after you wrote it.

Treat this as a sequence to build in order, not a checklist to attempt all at once. Start with tracing and deterministic evals, the cheapest and least ambiguous to get right. Add the judge harness once you trust the rubric. Add trajectory assertions once you have enough real traces to know what correct looks like. Behavioral E2E and CI regression come last, not because they matter less, but because they depend on everything upstream already being stable. Autonoma is the layer that makes the final two stages operational: it runs the behavioral check on the isolated PR environment and keeps the coverage aligned to the code diff, so the action-level proof does not become another stale suite to maintain by hand.

If you're building out the layers this guide moves through quickly, the deeper coverage lives one stage in either direction: AI agent reliability testing goes further into scoring consistency across runs over time, testing AI agents that take actions goes deeper into the trajectory layer with more failure scenarios, and agent regression testing expands on gating CI against exactly this kind of drift as your suite grows past a handful of scenarios. And if the three-actor pattern behind Stage 3's judge harness is new to you, agent simulation testing builds that same pattern out into a full user-simulator-plus-judge harness worth reading alongside this one.

Frequently Asked Questions

Testing an AI agent end to end means running six connected stages: tracing every LLM call and tool call, deterministic evals on structured output, LLM-as-judge scoring on free text, tool-call trajectory assertions, behavioral E2E checks on what the application actually did, and a CI gate that catches regressions across all five. Each stage catches a different failure mode the others cannot see.

Testing an AI agent means verifying that the agent itself makes correct decisions, calls the right tools, and produces correct outputs and application side effects. Using an AI agent to test software is a different task entirely: it means an agent writes or runs test scripts against some other application. This guide covers the first category exclusively.

Validate structured outputs (JSON, enums, typed fields) with deterministic assertions using plain pytest and schema validation. Validate free-text outputs with an LLM-as-judge harness scored against an explicit, multi-criteria rubric rather than a vague 'is this good' prompt. For either type, run non-deterministic scenarios multiple times and require a majority pass rather than trusting a single run.

Run the same scenario multiple times (5 runs with a 4-of-5 majority-pass threshold is a reasonable default) instead of asserting on a single run, and assert on semantic similarity or property-based conditions instead of exact string matches. A drop in pass rate over time is a real signal worth investigating; a test that always passes exactly is often a sign the assertion is too loose to catch anything.

Trajectory testing replays a captured trace of an agent's run and asserts on which tools were called, in what order, and with what arguments, including whether the agent recovered correctly from a tool failure. It catches cases where the final response looks correct but the agent reached it through the wrong sequence of tool calls.

Testing an autonomous agent follows the same six-stage arc: trace every LLM and tool call, run deterministic evals on structured output, score free text with an LLM-as-judge, assert on the tool-call trajectory, verify the running application with behavioral E2E, and gate all five in CI. The more autonomy an agent has over multi-step tool use, the more the trajectory and behavioral stages matter, because an autonomous agent can chain several correct-looking decisions into a wrong final action.

Related articles

Three layers of CrewAI testing shown side by side: the crewai test CLI producing an aggregate score, DeepEval inspecting individual agent and tool spans, and a Scenario delegation test checking a handoff between two crew agents

CrewAI Evaluation and Testing: How to Test CrewAI Agents

CrewAI evaluation and testing explained end to end: the crewai test CLI, DeepEval span-level assertions, and multi-agent delegation testing, with runnable code.

A user-simulator terminal and an agent terminal exchanging messages, with a judge's gavel and scale above them, illustrating the three-actor pattern behind agent simulation testing

What Is Agent Simulation Testing? A 3-Actor Harness

Agent simulation testing explained and built in raw Python: a three-actor harness (user simulator, agent, judge) with a tool-failure recovery scenario.

A horizontal agent trajectory diagram showing a tool call passing a right-tool checkpoint but failing an argument-accuracy checkpoint

How to Test AI Agents That Take Actions (Tool Calls)

A runnable guide to testing tool-calling agents: right tool, right order, right arguments, mocked vs live calls, failure handling, and non-determinism.

A chatbot test pipeline moving from manual QA through scripted and semantic assertions into an automated CI gate that samples the model N times before allowing a merge

Chatbot Automation Testing: Why Assertions Fail

Chatbot automation testing that survives non-deterministic replies: the migration to a CI gate, n-run sampling, threshold gating, and real GitHub Actions YAML.