Your test suite takes four minutes to run. Sixty of those seconds are your AI agent tests waiting on OpenAI. Another thirty cents just vanished from your API budget because CI ran three times today. And the worst part: the tests still fail randomly because the model decided to phrase things slightly differently this time.
You built an AI agent in Laravel that classifies support tickets, routes them to the right team, and drafts a response. The logic is solid. The prompt engineering took two weeks. But every php artisan test run hits the live API, burns tokens, and produces flaky assertions against non-deterministic output.
This doesn't scale. You need a testing strategy that lets you verify agent behavior—tool selection, prompt construction, response parsing, orchestration flow—without paying OpenAI or Anthropic for the privilege of running your test suite.
TL;DR
Extract your LLM interaction behind an interface. Use Laravel's service container to swap implementations in tests. Fake HTTP responses for integration-level tests. Record real LLM responses as fixtures and replay them. Test orchestration logic, tool routing, and structured output parsing independently. Reserve live LLM calls for a small, explicitly tagged smoke test suite that runs on a schedule, not on every commit.
📋 Table of Contents
- The Real Cost of Testing Against Live LLMs
- 1. Extract the LLM Behind an Interface You Control
- 2. Container Bindings: Swap Real for Fake in Tests
- 3. Http::fake() for API-Level Interception
- 4. Response Fixtures: Record Once, Replay Forever
- 5. Test Agent Orchestration Without Any LLM
- 6. Structured Output Validation With Known Payloads
- 7. Prompt Snapshot Testing
- 8. Local Models via Ollama for Integration Smoke Tests
- 9. The Tagged Smoke Suite Pattern
- Choosing Your Testing Layers
The Real Cost of Testing Against Live LLMs
Before diving into techniques, let's quantify why this matters. A typical AI agent test that hits OpenAI's gpt-4o endpoint:
- Latency: 800ms–4s per call (network + inference)
- Cost: ~0.005–0.015 per call depending on context length
- Determinism: None. Temperature > 0 means different output every run
- Rate limits: Shared with your production quota
- Failure modes: Timeouts, 429s, model deprecations break tests unrelated to your code
A suite with 20 agent tests running against live APIs adds 30–60 seconds and 0.10–0.30 per run. Ten developers pushing three times a day? That's 30–90 daily just for test runs. And every random model variation creates a flaky test that someone has to investigate.
The goal isn't to never call a real LLM. It's to make live calls the exception—reserved for a small, scheduled verification suite—while your fast, deterministic test suite covers the logic you actually control.
1. Extract the LLM Behind an Interface You Control
Scenario: Your SupportTicketAgent class calls OpenAI directly inside its classify() method. Every test that instantiates the agent hits the network.
Why it matters: You can't test the agent's decision logic—how it picks a tool, how it formats the prompt, how it parses the response—without also testing OpenAI's uptime.
Solution: Define a contract that represents "something that can complete a chat interaction." Your agent depends on the contract, not on OpenAI's SDK directly.
<?php
namespace App\Ai\Contracts;
interface LanguageModelClient
{
/**
* Send a chat completion request and return the raw response.
*
* @param array<int, array{role: string, content: string}> $messages
* @param array<string, mixed> $options
*/
public function chat(array $messages, array $options = []): AiResponse;
}
<?php
namespace App\Ai\Contracts;
class AiResponse
{
public function __construct(
public readonly string $content,
public readonly ?array $toolCalls = null,
public readonly string $finishReason = 'stop',
public readonly array $usage = [],
) {}
}
Your production implementation wraps whatever SDK you use—openai-php/laravel, Prism, or a custom HTTP client:
<?php
namespace App\Ai\Clients;
use App\Ai\Contracts\AiResponse;
use App\Ai\Contracts\LanguageModelClient;
use OpenAI\Client;
class OpenAiClient implements LanguageModelClient
{
public function __construct(
private readonly Client $client,
private readonly string $model = 'gpt-4o',
) {}
public function chat(array $messages, array $options = []): AiResponse
{
$response = $this->client->chat()->create([
'model' => $options['model'] ?? $this->model,
'messages' => $messages,
'temperature' => $options['temperature'] ?? 0.1,
'tools' => $options['tools'] ?? null,
'response_format' => $options['response_format'] ?? null,
]);
$choice = $response->choices[0];
return new AiResponse(
content: $choice->message->content ?? '',
toolCalls: $choice->message->toolCalls ?? null,
finishReason: $choice->finishReason,
usage: [
'prompt_tokens' => $response->usage->promptTokens,
'completion_tokens' => $response->usage->completionTokens,
],
);
}
}
🧠 The important part: The interface returns your response object, not the SDK's. This means if you swap from OpenAI to Anthropic to a local model, your agent code and your test fakes never change. The SDK's response shape is contained entirely within the adapter.
2. Container Bindings: Swap Real for Fake in Tests
Scenario: You have the interface. Now you need Laravel to inject a fake during tests without modifying your agent class.
Solution: Register the binding in a service provider for production, then override it in your test case.
// app/Providers/AiServiceProvider.php
namespace App\Providers;
use App\Ai\Clients\OpenAiClient;
use App\Ai\Contracts\LanguageModelClient;
use Illuminate\Support\ServiceProvider;
use OpenAI;
class AiServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(LanguageModelClient::class, function ($app) {
return new OpenAiClient(
client: OpenAI::client(config('services.openai.key')),
model: config('ai.default_model', 'gpt-4o'),
);
});
}
}
In your test case, create a fake that returns predetermined responses:
// tests/Fakes/FakeLanguageModel.php
namespace Tests\Fakes;
use App\Ai\Contracts\AiResponse;
use App\Ai\Contracts\LanguageModelClient;
class FakeLanguageModel implements LanguageModelClient
{
/** @var array<int, array{messages: array, options: array}> */
public array $recordedCalls = [];
/** @var array<int, AiResponse> */
private array $queuedResponses = [];
public function queueResponse(AiResponse $response): static
{
$this->queuedResponses[] = $response;
return $this;
}
public function queueContent(string $content): static
{
return $this->queueResponse(new AiResponse(content: $content));
}
public function chat(array $messages, array $options = []): AiResponse
{
$this->recordedCalls[] = ['messages' => $messages, 'options' => $options];
if (empty($this->queuedResponses)) {
return new AiResponse(content: 'Default fake response');
}
return array_shift($this->queuedResponses);
}
}
Now your test base class binds the fake:
// tests/TestCase.php
namespace Tests;
use App\Ai\Contracts\LanguageModelClient;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Tests\Fakes\FakeLanguageModel;
abstract class TestCase extends BaseTestCase
{
protected FakeLanguageModel $fakeAi;
protected function setUp(): void
{
parent::setUp();
$this->fakeAi = new FakeLanguageModel();
$this->app->instance(LanguageModelClient::class, $this->fakeAi);
}
}
Every test that resolves LanguageModelClient from the container now gets the fake. No network calls. No API keys needed. Runs in milliseconds.
⚠️ Gotcha: If you're using
openai-php/laravel, it registers its own container binding forOpenAI\Client. You don't need to unbind that—your interface layer sits above it. TheOpenAI\Clientbinding becomes irrelevant in tests because nothing resolves it when your agent only depends onLanguageModelClient.
3. Http::fake() for API-Level Interception
Scenario: You want to test your OpenAiClient adapter itself—verify it correctly maps the SDK response to your AiResponse object—without hitting the real API. Or maybe you're testing a code path that uses Laravel's HTTP client directly for a custom AI endpoint.
Why it matters: Interface fakes skip the adapter. You still need to verify the adapter parses responses correctly, handles errors, and retries appropriately.
Solution: Laravel's Http::fake() intercepts all outgoing HTTP requests. For the OpenAI PHP client specifically, you can fake at the HTTP transport level:
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class OpenAiClientTest extends TestCase
{
public function test_it_maps_tool_calls_correctly(): void
{
Http::fake([
'api.openai.com/v1/chat/completions' => Http::response([
'id' => 'chatcmpl-test123',
'object' => 'chat.completion',
'model' => 'gpt-4o',
'choices' => [
[
'index' => 0,
'message' => [
'role' => 'assistant',
'content' => null,
'tool_calls' => [
[
'id' => 'call_abc123',
'type' => 'function',
'function' => [
'name' => 'classify_ticket',
'arguments' => '{"category":"billing","priority":"high"}',
],
],
],
],
'finish_reason' => 'tool_calls',
],
],
'usage' => ['prompt_tokens' => 150, 'completion_tokens' => 25],
], 200),
]);
$client = app(LanguageModelClient::class);
// In this test, we specifically want the REAL client, not the fake
// So we rebind it:
$realClient = new \App\Ai\Clients\OpenAiClient(
client: \OpenAI::client('test-key-not-real'),
model: 'gpt-4o',
);
$response = $realClient->chat([
['role' => 'user', 'content' => 'Classify this ticket: "I was charged twice"'],
], ['tools' => $this->classificationTools()]);
$this->assertEquals('tool_calls', $response->finishReason);
$this->assertCount(1, $response->toolCalls);
$this->assertEquals('classify_ticket', $response->toolCalls[0]['function']['name']);
}
}
💡 Practical note:
Http::fake()works cleanly when your AI SDK uses Laravel's HTTP client or Guzzle under the hood. Theopenai-php/clientpackage uses Guzzle, and Laravel's HTTP fake intercepts Guzzle requests made through Laravel's HTTP layer. If you're using the standalone OpenAI PHP client outside Laravel's HTTP facade, you may need to inject a mock Guzzle handler instead. Test this with your specific SDK version.
4. Response Fixtures: Record Once, Replay Forever
Scenario: You spent an hour tuning a prompt until the model returns exactly the right JSON structure for ticket classification. You want every future test run to validate against that exact response shape.
Why it matters: Hand-crafting fake responses is error-prone. You'll miss edge cases the real model produces—extra whitespace, unexpected field ordering, null vs missing keys.
Solution: Record real responses once, save them as JSON fixtures, and replay them deterministically.
// tests/Ai/Fixtures/SupportAgent/classifies_billing_ticket.json
{
"request": {
"model": "gpt-4o",
"messages": [
{"role": "system", "content": "You are a support ticket classifier..."},
{"role": "user", "content": "I was charged twice for my March subscription"}
],
"response_format": {"type": "json_object"}
},
"response": {
"content": "{\"category\":\"billing\",\"priority\":\"high\",\"sentiment\":\"frustrated\",\"suggested_team\":\"payments\"}",
"finish_reason": "stop",
"usage": {"prompt_tokens": 245, "completion_tokens": 31}
}
}
A fixture loader makes this ergonomic:
// tests/Support/AiFixtureLoader.php
namespace Tests\Support;
use App\Ai\Contracts\AiResponse;
use Tests\Fakes\FakeLanguageModel;
class AiFixtureLoader
{
public static function load(FakeLanguageModel $fake, string $fixturePath): void
{
$path = base_path("tests/Ai/Fixtures/{$fixturePath}.json");
$data = json_decode(file_get_contents($path), true);
$fake->queueResponse(new AiResponse(
content: $data['response']['content'],
finishReason: $data['response']['finish_reason'],
usage: $data['response']['usage'],
));
}
}
Usage in a test:
public function test_billing_ticket_routes_to_payments_team(): void
{
AiFixtureLoader::load($this->fakeAi, 'SupportAgent/classifies_billing_ticket');
$agent = app(SupportTicketAgent::class);
$result = $agent->classify('I was charged twice for my March subscription');
$this->assertEquals('billing', $result->category);
$this->assertEquals('payments', $result->suggestedTeam);
$this->assertEquals('high', $result->priority);
}
The recording step can be a simple artisan command you run manually:
// app/Console/Commands/RecordAiFixture.php
class RecordAiFixture extends Command
{
protected $signature = 'ai:record-fixture {name} {--prompt=}';
public function handle(LanguageModelClient $client): int
{
// Build messages from your agent's prompt builder
$messages = $this->buildMessages($this->option('prompt'));
$response = $client->chat($messages, $this->buildOptions());
$fixture = [
'request' => ['messages' => $messages],
'response' => [
'content' => $response->content,
'finish_reason' => $response->finishReason,
'usage' => $response->usage,
],
];
$path = base_path("tests/Ai/Fixtures/{$this->argument('name')}.json");
file_put_contents($path, json_encode($fixture, JSON_PRETTY_PRINT));
$this->info("Fixture recorded: {$path}");
return self::SUCCESS;
}
}
Run php artisan ai:record-fixture SupportAgent/classifies_billing_ticket --prompt="I was charged twice" once. Commit the JSON. Every test run replays it forever.
5. Test Agent Orchestration Without Any LLM
Scenario: Your agent doesn't just call an LLM once. It runs a loop: call the model → check if it wants to use a tool → execute the tool → feed the result back → repeat until done. You need to test this orchestration logic.
Why it matters: The orchestration—deciding when to loop, when to stop, which tool to dispatch—is your code. It's deterministic. Testing it against a live LLM adds cost and flakiness while testing the wrong thing.
Solution: Queue multiple responses in your fake to simulate a multi-turn conversation:
public function test_agent_executes_tool_and_returns_final_answer(): void
{
// Turn 1: Model requests a tool call
$this->fakeAi->queueResponse(new AiResponse(
content: '',
toolCalls: [[
'id' => 'call_001',
'type' => 'function',
'function' => [
'name' => 'lookup_customer',
'arguments' => '{"email":"user@example.com"}',
],
]],
finishReason: 'tool_calls',
));
// Turn 2: Model produces final answer after receiving tool result
$this->fakeAi->queueResponse(new AiResponse(
content: '{"resolution":"refund_issued","amount":49.99}',
finishReason: 'stop',
));
$agent = app(SupportTicketAgent::class);
$result = $agent->handle('I want a refund for order #1234', 'user@example.com');
// Verify orchestration made exactly 2 LLM calls
$this->assertCount(2, $this->fakeAi->recordedCalls);
// Verify the tool result was fed back in the second call
$secondCallMessages = $this->fakeAi->recordedCalls[1]['messages'];
$toolResultMessage = collect($secondCallMessages)
->firstWhere('role', 'tool');
$this->assertNotNull($toolResultMessage);
$this->assertStringContains('customer_found', $toolResultMessage['content']);
// Verify final output
$this->assertEquals('refund_issued', $result->resolution);
}
This test verifies your loop logic, tool dispatch, message assembly, and termination condition. Zero network calls. Runs in 2ms.
6. Structured Output Validation With Known Payloads
Scenario: Your agent expects JSON back from the LLM. You parse it into a DTO. But the model sometimes returns malformed JSON, adds markdown code fences, or includes extra fields.
Why it matters: You need to test your parsing layer against realistic-but-controlled inputs. This is where most production AI bugs actually live—not in the model's intelligence, but in the brittle code that consumes its output.
Solution: Test your response parser as a pure unit with adversarial inputs:
class AgentResponseParserTest extends TestCase
{
private AgentResponseParser $parser;
protected function setUp(): void
{
parent::setUp();
$this->parser = new AgentResponseParser();
}
public function test_parses_clean_json(): void
{
$result = $this->parser->parse('{"category":"billing","priority":"high"}');
$this->assertInstanceOf(TicketClassification::class, $result);
}
public function test_strips_markdown_code_fences(): void
{
$dirty = "```json\n{\"category\":\"billing\",\"priority\":\"high\"}\n```";
$result = $this->parser->parse($dirty);
$this->assertEquals('billing', $result->category);
}
public function test_handles_extra_whitespace_and_newlines(): void
{
$dirty = " \n {\"category\": \"billing\", \n \"priority\": \"high\"} \n";
$result = $this->parser->parse($dirty);
$this->assertEquals('high', $result->priority);
}
public function test_throws_on_missing_required_field(): void
{
$this->expectException(InvalidAiResponseException::class);
$this->parser->parse('{"category":"billing"}'); // missing priority
}
public function test_rejects_unexpected_category_value(): void
{
$this->expectException(InvalidAiResponseException::class);
$this->parser->parse('{"category":"alien_invasion","priority":"high"}');
}
}
And the parser itself:
class AgentResponseParser
{
private const VALID_CATEGORIES = ['billing', 'technical', 'account', 'feature_request'];
private const VALID_PRIORITIES = ['low', 'medium', 'high', 'critical'];
public function parse(string $raw): TicketClassification
{
$cleaned = $this->stripFormatting($raw);
$data = json_decode($cleaned, true, 512, JSON_THROW_ON_ERROR);
if (!in_array($data['category'] ?? null, self::VALID_CATEGORIES)) {
throw InvalidAiResponseException::badCategory($data['category'] ?? 'null');
}
if (!in_array($data['priority'] ?? null, self::VALID_PRIORITIES)) {
throw InvalidAiResponseException::badPriority($data['priority'] ?? 'null');
}
return new TicketClassification(
category: $data['category'],
priority: $data['priority'],
sentiment: $data['sentiment'] ?? 'neutral',
suggestedTeam: $data['suggested_team'] ?? null,
);
}
private function stripFormatting(string $raw): string
{
// Remove markdown code fences
$cleaned = preg_replace('/^```(?:json)?\s*/m', '', $raw);
$cleaned = preg_replace('/```\s*$/', '', $cleaned);
return trim($cleaned);
}
}
These tests are fast, deterministic, and catch the exact bugs that cause 3am pages when the model returns something slightly unexpected.
7. Prompt Snapshot Testing
Scenario: A teammate refactors your prompt builder. The tests still pass because the fake responses don't depend on the prompt content. But the actual prompt sent to the model changed subtly—a word removed, a constraint rephrased—and now production behavior is different.
Why it matters: Prompts are code. They should be subject to the same change detection as any other code artifact.
Solution: Snapshot the fully-assembled prompt and assert it doesn't change without explicit approval:
public function test_classification_prompt_matches_snapshot(): void
{
$builder = new ClassificationPromptBuilder();
$prompt = $builder->build(
ticket: 'I was charged twice',
context: ['customer_tier' => 'premium', 'previous_tickets' => 3],
);
// First run: creates the snapshot. Subsequent runs: compares.
$this->assertMatchesTextSnapshot($prompt);
}
Using Spatie's laravel-snapshot or a simple file-based approach:
trait AssertsSnapshots
{
protected function assertMatchesTextSnapshot(string $actual): void
{
$testName = $this->name();
$path = base_path("tests/Snapshots/{$testName}.txt");
if (!file_exists($path)) {
file_put_contents($path, $actual);
$this->markTestSkipped('Snapshot created. Run again to verify.');
return;
}
$expected = file_get_contents($path);
$this->assertEquals(
$expected,
$actual,
"Prompt changed. If intentional, delete the snapshot file and re-run."
);
}
}
When the snapshot fails, the developer sees exactly what changed in the prompt. If it's intentional, they delete the snapshot file and re-run to create the new baseline. This is the same pattern as Jest's snapshot testing, applied to AI prompts.
8. Local Models via Ollama for Integration Smoke Tests
Scenario: You've unit-tested everything with fakes. But you still want some test that verifies the full path—your code talks to a real model server, gets a response, parses it. You just don't want to pay for it.
Solution: Run Ollama locally with a small model like llama3.2:3b or phi4-mini for a tagged integration suite. These models run on a laptop GPU (or even CPU for small models) and cost nothing per call.
// config/ai.php
return [
'default_model' => env('AI_MODEL', 'gpt-4o'),
'base_url' => env('AI_BASE_URL', 'https://api.openai.com/v1'),
];
<!-- phpunit.xml -->
<php>
<env name="AI_BASE_URL" value="http://localhost:11434/v1"/>
<env name="AI_MODEL" value="llama3.2:3b"/>
</php>
Ollama exposes an OpenAI-compatible API at /v1/chat/completions. Your OpenAiClient adapter works unchanged—just point the base URL at localhost.
// tests/Integration/LocalModelTest.php
namespace Tests\Integration;
use Tests\TestCase;
/**
* @group ai-integration
*/
class LocalModelTest extends TestCase
{
public function test_agent_produces_valid_json_structure(): void
{
// Don't assert exact content—local models are less predictable.
// Assert STRUCTURE: valid JSON, required fields present, correct types.
$agent = app(SupportTicketAgent::class);
$result = $agent->classify('My payment failed and I cannot log in');
$this->assertNotEmpty($result->category);
$this->assertContains($result->category, TicketClassification::VALID_CATEGORIES);
$this->assertContains($result->priority, TicketClassification::VALID_PRIORITIES);
}
}
🔍 Why this matters: You're not testing model quality here. You're testing that your code survives a real inference cycle—serialization, deserialization, timeout handling, response parsing. The local model is a stand-in that exercises the same code path as production without the cost.
The key discipline: assert structure, not content. A 3B local model won't classify tickets as well as GPT-4o. If you assert exact category values, the test will be flaky. Assert that the response parses, that fields exist, that types are correct. Leave quality assertions to the live smoke suite.
9. The Tagged Smoke Suite Pattern
Scenario: You still need some verification that your prompts work against the actual production model. Fakes can't tell you if a prompt drift made GPT-4o start ignoring your "respond in JSON only" instruction.
Solution: A tiny, explicitly tagged test suite that calls the real API. It runs on a schedule—not on every commit.
// tests/Smoke/LiveModelSmokeTest.php
namespace Tests\Smoke;
use Tests\TestCase;
/**
* @group ai-smoke
* @group live-api
*/
class LiveModelSmokeTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
// DO NOT swap the fake. Use the real client.
// Remove the binding from the base TestCase.
$this->app->forgetInstance(\App\Ai\Contracts\LanguageModelClient::class);
}
public function test_classification_returns_valid_structure(): void
{
$agent = app(SupportTicketAgent::class);
$result = $agent->classify('I was charged twice for March');
$this->assertEquals('billing', $result->category);
$this->assertNotNull($result->suggestedTeam);
}
public function test_agent_handles_multi_turn_tool_use(): void
{
$agent = app(SupportTicketAgent::class);
$result = $agent->handle('What is my current plan?', 'test@example.com');
$this->assertNotEmpty($result->resolution);
}
}
Configure this as a separate PHPUnit group:
<!-- phpunit.xml -->
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
<testsuite name="Smoke">
<directory>tests/Smoke</directory>
</testsuite>
</testsuites>
Your CI pipeline:
# .github/workflows/tests.yml
- name: Fast test suite (no AI calls)
run: php artisan test --exclude-group=live-api
- name: AI smoke suite (scheduled)
if: github.event.schedule == 'nightly' || contains(github.event.head_commit.message, '[ai-smoke]')
run: php artisan test --group=live-api
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
The fast suite runs on every push. The smoke suite runs nightly or on-demand. Total live API calls per day: maybe 10–20. Cost: negligible. But you still catch prompt regressions within 24 hours.
Choosing Your Testing Layers
Not every project needs all nine techniques. Here's how I'd layer them based on agent complexity:
| Agent Complexity | Minimum Viable Testing | Recommended Additions |
|---|---|---|
| Single LLM call, simple prompt | Interface fake + response fixtures | Prompt snapshots |
| Multi-step with tool calling | All of the above + orchestration tests | Http::fake for adapter |
| User-facing with strict output format | All of the above + parser unit tests | Ollama integration + smoke suite |
| Multi-agent / complex routing | Everything above | Recorded conversation fixtures per agent path |
The decision framework I use:
- Can I test this without any model? → Unit test the logic. (Orchestration, parsing, validation, tool execution)
- Do I need a model response but not a real one? → Fake with fixtures. (Agent output, classification results, structured data)
- Do I need to verify the adapter works? → Http::fake or Ollama. (SDK mapping, error handling, retries)
- Do I need to verify the prompt still works with the real model? → Tagged smoke suite, scheduled runs. (Regression detection)
The layers build on each other. Layer 1 catches 90% of bugs. Layer 2 catches response-shape bugs. Layer 3 catches integration bugs. Layer 4 catches prompt drift. Each layer is more expensive and slower, so you run them less frequently.
The end state: php artisan test runs in 8 seconds, costs 0.00,andcatcheseverybugin∗your∗code.Thenightlysmokeruncosts0.02 and catches prompt regressions. Your CI bill goes from dollars per day to pennies per week. And your tests stop failing because the model used "utilise" instead of "utilize" in its response.