n4nAI

What belongs in a CI test suite for AI agents

Practical guide to building a CI test suite for AI agents: mock the LLM, test tool use, contract providers, and gate on cost and latency.

n4n Team3 min read694 words

Audio narration

Coming soon — every post will get a voice note here.

Most teams bolt tests onto their AI agents after the first production outage. A disciplined CI test suite for AI agents treats the model as a nondeterministic external dependency and verifies the parts you control: tool wiring, prompt assembly, output parsing, and policy boundaries.

1. Pin the model boundary first

Define an explicit interface between your agent logic and the model. If you don’t, tests will couple to a specific SDK shape and break on every library upgrade.

from pydantic import BaseModel

class ChatMessage(BaseModel):
    role: str
    content: str

class ModelResponse(BaseModel):
    choices: list[ChatMessage]

def call_model(messages: list[ChatMessage], **kwargs) -> ModelResponse:
    # wraps OpenAI / gateway client
    ...

The contract should specify the expected output format (JSON mode, function call, or plain text) and the maximum tokens. Tests against this boundary stay stable even when you swap providers.

2. Unit-test the deterministic scaffolding

The agent’s non-LLM code is where most bugs ship. Test prompt templating, context truncation, and tool schema generation in isolation.

def build_prompt(user_input: str, history: list[str]) -> list[ChatMessage]:
    sys = ChatMessage(role="system", content="You are a terse helper.")
    truncated = history[-3:]
    return [sys] + [ChatMessage(role="user", content=h) for h in truncated] + [ChatMessage(role="user", content=user_input)]

def test_prompt_limits_history():
    hist = [f"msg {i}" for i in range(10)]
    msgs = build_prompt("hi", hist)
    assert len(msgs) == 5  # system + 3 history + current
    assert msgs[-1].content == "hi"

Run these on every commit. They execute in milliseconds and catch regressions in the code you fully own.

3. Mock the LLM for fast feedback

A CI test suite for AI agents needs a sub-second path that never hits the network. Replace the client with a fake that returns canned responses shaped to your contract.

import pytest

class FakeModelClient:
    def __init__(self, canned: str):
        self.canned = canned
    def chat(self, messages, **kwargs) -> ModelResponse:
        return ModelResponse(choices=[ChatMessage(role="assistant", content=self.canned)])

@pytest.fixture
def fake_client():
    return FakeModelClient('{"tool": "calc", "expr": "2+2"}')

def test_agent_parses_tool_call(fake_client):
    agent = Agent(model_client=fake_client)
    result = agent.run("what is 2+2")
    assert result.tool == "calc"
    assert result.args["expr"] == "2+2"

Keep the fake in a shared test fixture. When the model schema changes, update the fixture once.

4. Run golden-output tests with tolerance

Against the mock, store expected parsed outputs, not raw text. If you later run against a real model, compare structure and critical values, not string equality.

{
  "input": "schedule meeting with bob tomorrow 3pm",
  "expect": {
    "tool": "calendar.create",
    "args": {"attendee": "bob", "time": "15:00"}
  }
}

A test loader reads these files, feeds the input through the agent with a mocked or recorded model, and asserts the parsed action matches. Use fuzzy matching for times or entities:

assert out.tool == case["expect"]["tool"]
assert out.args["attendee"] == case["expect"]["args"]["attendee"]
# tolerate +/- 1 hour from recorded tz

5. Evaluate tool use and side effects

Agents fail by calling the wrong tool or sending destructive arguments. Test the tool registry and permission layer directly.

def test_agent_cannot_rm_root(fake_client):
    fake_client.canned = '{"tool": "shell", "args": {"cmd": "rm -rf /"}}'
    agent = Agent(model_client=fake_client, allowlist={"shell": ["ls", "cat"]})
    with pytest.raises(PermissionError):
        agent.run("clean up")

Mock external services behind tools so you assert the call happened with correct params. Tradeoff: over-strict allowlists cause false positives in CI; log denied calls instead of failing until you trust the policy.

6. Contract-test provider integrations

If your agent calls real APIs in a staging step, record interactions and replay them. This catches breaking changes in request shape without burning quota on every push.

# record once against a gateway or provider
pytest tests/contract.py --record --provider openai
# CI replays from fixtures
pytest tests/contract.py

When you route through a gateway like n4n.ai, you get automatic fallback when a provider is degraded and per-token metering from one OpenAI-compatible endpoint covering 240+ models. That lets a contract test assert the same request succeeds across model families without rewriting clients. Forward provider cache-control hints to keep replay costs low.

7. Add slow, noisy end-to-end canaries

Real models drift. Run a nightly job with live calls on a small set of representative tasks. Set loose thresholds: success if 8/10 tasks produce a valid tool call or answer.

# .github/workflows/canary.yml
name: agent-canary
on: schedule [cron: "0 3 * * *"]
jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - run: pytest tests/e2e.py --live --min-pass-rate 0.8

Never block merges on these. They are signal, not gate. If the canary goes red for three days, promote a case to the golden set.

8. Gate merges on cost and latency budgets

A CI test suite for AI agents should fail the build when an agent suddenly burns 10x tokens for the same task. Instrument the mocked and recorded paths to emit metrics.

def test_token_budget(fake_client, token_counter):
    agent = Agent(model_client=fake_client, instruments=token_counter)
    agent.run("summarize logs")
    assert token_counter.total < 2000

Capture p95 latency from recorded fixtures and fail if a prompt grows beyond your context window headroom. This catches template bloat early.

Common pitfalls and tradeoffs

Testing exact LLM text. Deterministic string asserts on model output are flaky by design. Test parsed intent and side effects instead.

Only testing the happy path. Adversarial inputs (“ignore previous instructions”) should be a fixed fixture. Your policy layer must reject them; assert that.

Skipping provider contract tests. SDKs change. A recorded contract test catches a renamed parameter before users do.

Treating the gateway as a mock. A real fallback path behaves differently under load. Use it in canaries, not in unit tests.

Ignoring cost in CI. If you don’t measure tokens, a prompt regression ships and shows up as a 40% bill increase.

Build the suite in this order: boundary, units, mocks, golden, tools, contracts, canaries, budgets. Each layer is cheaper than the one below it and catches a different class of failure. The CI test suite for AI agents you ship today should be mostly mocking and parsing—reserve live calls for the nightly job.

Tagsci-testingtest-suiteagent-testingautomation

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All testing & qa for ai agents posts →