{"slug": "how-to-test-ai-agents-without-calling-more-llms", "title": "How to Test AI Agents Without Calling More LLMs", "summary": "A developer argues that AI-agent testing should separate orchestration correctness from language quality, using deterministic checks for tool order, retry limits, and state transitions instead of relying on expensive LLM judges. The approach uses dependency injection and scripted fakes to test execution contracts on every commit, reserving semantic evaluations for a smaller, calibrated workflow.", "body_md": "AI-agent testing often starts with an expensive loop: call the agent, send its answer to another model, ask for a quality score, and hope the score is stable enough for CI.\n\nLLM judges can be useful for genuinely semantic questions. They are a poor default for verifying tool order, retry limits, validation, budgets, state transitions, or error handling. Those behaviors have explicit contracts and can usually be tested with ordinary deterministic techniques.\n\nThe most effective agent test suite separates **orchestration correctness** from **language quality**. Test the first on every commit with fakes, traces, and rules. Evaluate the second with a smaller, calibrated model-and-human workflow.\n\nAn agent may produce variable language while still following a predictable execution contract. Useful deterministic checks include:\n\nNone of these questions requires a second model. Most do not require a first model either.\n\n| Layer | Model access | Purpose |\n|---|---|---|\n| Pure unit tests | None | Routing, validation, budgeting, state reducers |\n| Orchestration tests | Scripted fake | Tool order, retries, fallbacks, termination |\n| Tool contract tests | None or sandbox | Schemas, timeouts, error mapping |\n| Trace replay tests | None | Regression checks against recorded execution metadata |\n| Model integration tests | Real model | Provider compatibility and a small set of end-to-end paths |\n| Semantic evaluations | Real model and/or human | Helpfulness, groundedness, tone, nuanced correctness |\n\nThe lower layers should contain most of the suite. They are fast, reproducible, and actionable. The upper layers are valuable, but they should be deliberately smaller.\n\nDependency injection makes the orchestration testable without network calls.\n\n```\ntype ModelReply =\n  | { type: 'tool_call'; tool: string; args: unknown }\n  | { type: 'final'; text: string; usage: { input: number; output: number } };\n\ninterface ModelClient {\n  generate(input: {\n    messages: Array<{ role: string; content: string }>;\n    tools: string[];\n  }): Promise<ModelReply>;\n}\n\ninterface ToolClient {\n  execute(name: string, args: unknown): Promise<unknown>;\n}\n\ninterface Clock {\n  now(): number;\n}\n```\n\nProduction code receives real implementations. Tests provide scripted implementations with known behavior.\n\nA scripted model returns a predefined sequence and fails if the agent makes an unexpected extra call.\n\n```\nclass ScriptedModel implements ModelClient {\n  private index = 0;\n\n  constructor(private readonly replies: ModelReply[]) {}\n\n  async generate(): Promise<ModelReply> {\n    const reply = this.replies[this.index];\n    if (!reply) {\n      throw new Error(`Unexpected model call at index ${this.index}`);\n    }\n\n    this.index += 1;\n    return structuredClone(reply);\n  }\n\n  assertConsumed(): void {\n    if (this.index !== this.replies.length) {\n      throw new Error(\n        `Expected ${this.replies.length} model calls, received ${this.index}`,\n      );\n    }\n  }\n}\n```\n\nThis fake does not imitate model intelligence. It controls the branch that the orchestration must handle. One script can request a tool and then return a final answer; another can repeatedly request an invalid tool call to verify termination.\n\nThe trace should expose behavior without coupling tests to prose output.\n\n```\ntype TraceStep = {\n  sequence: number;\n  name: string;\n  kind: 'run' | 'model' | 'tool' | 'policy' | 'fallback';\n  status: 'ok' | 'error' | 'blocked';\n  parentId: string | null;\n  attempt?: number;\n  metadata?: Record<string, string | number | boolean | null>;\n};\n\ntype AgentTrace = {\n  status: 'ok' | 'error' | 'blocked';\n  steps: TraceStep[];\n};\n```\n\nUse a monotonic sequence assigned by the recorder for causal assertions. Do not infer retry behavior from consecutive names: parallel work can interleave events, and unrelated steps can separate attempts.\n\nKeep the trace schema stable and metadata-first. Tests should assert execution contracts, not depend on raw prompts or complete tool results.\n\nSmall domain-specific helpers make failures easier to understand than generic array comparisons.\n\n```\nfunction stepIndex(trace: AgentTrace, name: string): number {\n  return trace.steps.findIndex((step) => step.name === name);\n}\n\nfunction expectStepBefore(\n  trace: AgentTrace,\n  first: string,\n  second: string,\n): void {\n  const firstIndex = stepIndex(trace, first);\n  const secondIndex = stepIndex(trace, second);\n\n  if (firstIndex < 0) throw new Error(`Missing trace step: ${first}`);\n  if (secondIndex < 0) throw new Error(`Missing trace step: ${second}`);\n  if (firstIndex >= secondIndex) {\n    throw new Error(`Expected ${first} before ${second}`);\n  }\n}\n\nfunction countSteps(trace: AgentTrace, name: string): number {\n  return trace.steps.filter((step) => step.name === name).length;\n}\n```\n\nWhen operations run in parallel, assert parentage and required dependencies rather than total ordering. Two sibling tools may complete in either order and both be correct.\n\n``` js\ntest('retrieves policy before generating an answer', async () => {\n  const model = new ScriptedModel([\n    {\n      type: 'final',\n      text: 'Use the documented return process.',\n      usage: { input: 420, output: 18 },\n    },\n  ]);\n\n  const { trace } = await runSupportAgent(\n    { model, tools: fakeTools, clock: fakeClock },\n    'How do returns work?',\n  );\n\n  expectStepBefore(trace, 'retrieve_policy', 'generate_answer');\n  expect(countSteps(trace, 'retrieve_policy')).toBe(1);\n  model.assertConsumed();\n});\n```\n\nThe test says exactly what failed: a missing step, an invalid dependency order, or an unexpected model call. No quality score is needed.\n\n``` js\ntest('stops a tool after two failed attempts', async () => {\n  const tools = new FakeTools({\n    lookup_invoice: [\n      new TimeoutError(),\n      new TimeoutError(),\n      { invoiceId: 'should-never-be-returned' },\n    ],\n  });\n\n  const { trace } = await runInvoiceAgent(\n    { model: scriptedToolCaller, tools, clock: fakeClock },\n    'Find the latest invoice',\n  );\n\n  const attempts = trace.steps.filter(\n    (step) => step.name === 'lookup_invoice',\n  );\n\n  expect(attempts.map((step) => step.attempt)).toEqual([1, 2]);\n  expect(tools.calls('lookup_invoice')).toHaveLength(2);\n  expect(trace.status).toBe('error');\n});\n```\n\nThis catches an unbounded retry loop without waiting for a real service or model.\n\nA blocked request should produce no downstream model or tool activity.\n\n``` js\ntest('blocks unauthorized export before external calls', async () => {\n  const { trace } = await runExportAgent(\n    { model: modelThatMustNotRun, tools: toolsThatMustNotRun, clock: fakeClock },\n    { action: 'export_all_accounts', authorized: false },\n  );\n\n  expect(trace.status).toBe('blocked');\n  expect(stepIndex(trace, 'authorize_export')).toBeGreaterThanOrEqual(0);\n  expect(trace.steps.some((step) => step.kind === 'model')).toBe(false);\n  expect(trace.steps.some((step) => step.kind === 'tool')).toBe(false);\n});\n```\n\nThe test uses structured input instead of asking a model to recognize a particular malicious phrase. A separate security suite can test prompt-injection resistance with representative text fixtures.\n\nWall-clock assertions are often flaky under CI load. Inject a fake clock or scheduler and advance it deliberately.\n\n```\nclass FakeClock implements Clock {\n  private value = 0;\n\n  now(): number {\n    return this.value;\n  }\n\n  advance(ms: number): void {\n    this.value += ms;\n  }\n}\n\ntest('uses fallback after the configured timeout', async () => {\n  const clock = new FakeClock();\n  const tools = new FakeTools({ primary_search: [new TimeoutError()] });\n\n  const { trace } = await runSearchAgent(\n    { model: scriptedToolCaller, tools, clock },\n    'pricing',\n  );\n\n  expect(stepIndex(trace, 'fallback_search')).toBeGreaterThanOrEqual(0);\n});\n```\n\nKeep a small number of real timing tests for integration boundaries. Do not make every unit test depend on an overloaded runner finishing within an arbitrary number of milliseconds.\n\nModel fakes can return explicit usage values. That lets you verify budget logic without paying for tokens.\n\n``` js\ntest('stops before exceeding the session token budget', async () => {\n  const model = new ScriptedModel([\n    { type: 'final', text: 'first', usage: { input: 700, output: 200 } },\n    { type: 'final', text: 'second', usage: { input: 700, output: 200 } },\n  ]);\n\n  const { trace } = await runBudgetedAgent(\n    { model, tools: fakeTools, clock: fakeClock, maxTokens: 1_200 },\n    'continue',\n  );\n\n  expect(trace.steps.some((step) => step.name === 'token_budget_block')).toBe(true);\n  expect(countSteps(trace, 'model_call')).toBe(1);\n});\n```\n\nThis proves the enforcement behavior. A separate provider integration test can verify that production usage fields are mapped correctly into the internal token model.\n\nRecorded metadata traces can drive tests without replaying sensitive content or calling a model. Store a compact fixture describing model decisions, tool outcomes, usage, and expected invariants.\n\nAvoid treating the entire trace as a snapshot that must match byte for byte. IDs, timestamps, and harmless implementation details make snapshots brittle. Normalize the trace and assert durable properties:\n\nUpdate a fixture only when the behavioral contract intentionally changes.\n\nAgents fail at boundaries. Include fixtures for:\n\nVerify that the original error category is preserved, cleanup runs, and no further model or tool work occurs after a terminal state.\n\nDeterministic tests cannot prove that an answer is clear, grounded, polite, or semantically correct. Real-model evaluation remains useful for those questions.\n\nUse a curated dataset and an explicit rubric. Calibrate judge scores against human labels, track disagreement, and review threshold failures. Because model behavior can change, compare distributions and trends rather than treating one score from one run as an unquestionable fact.\n\nA practical schedule is:\n\n| Trigger | Recommended checks |\n|---|---|\n| Local save | Unit and scripted orchestration tests |\n| Every commit | Trace invariants, policy, retries, budgets, tool contracts |\n| Pull request | Deterministic suite plus a small real-model smoke set |\n| Nightly or weekly | Larger semantic evaluation and regression trends |\n| Release candidate | Human review of high-risk or changed workflows |\n\nTeams with stricter cost or reliability requirements can move real-model tests out of pull requests entirely. The important point is to make that trade-off explicit.\n\nAgent output is probabilistic, but agent architecture does not need to be opaque. Validation, tool access, retries, fallbacks, budgets, state transitions, and trace structure can all have deterministic contracts.\n\nPut those contracts behind injectable interfaces, drive them with scripted dependencies, and assert behavior through a stable metadata trace. Save real models and LLM judges for the semantic questions that ordinary code cannot answer.\n\nThe next article will turn these ideas into fast CI quality gates with reusable trace rules, baseline comparison, and actionable failure reports.", "url": "https://wpnews.pro/news/how-to-test-ai-agents-without-calling-more-llms", "canonical_source": "https://dev.to/raju_dandigam/how-to-test-ai-agents-without-calling-more-llms-2ga6", "published_at": "2026-07-30 16:35:04+00:00", "updated_at": "2026-07-30 17:02:11.034325+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "artificial-intelligence"], "entities": [], "alternates": {"html": "https://wpnews.pro/news/how-to-test-ai-agents-without-calling-more-llms", "markdown": "https://wpnews.pro/news/how-to-test-ai-agents-without-calling-more-llms.md", "text": "https://wpnews.pro/news/how-to-test-ai-agents-without-calling-more-llms.txt", "jsonld": "https://wpnews.pro/news/how-to-test-ai-agents-without-calling-more-llms.jsonld"}}