DEV Community

Cover image for Testing Laravel AI features without burning API credits
Saqueib Ansari
Saqueib Ansari

Posted on • Originally published at qcode.in

Testing Laravel AI features without burning API credits

The expensive part of testing AI features is not the API bill. It is the false confidence. If your Laravel tests only prove that a controller returns 200, you are not testing AI behavior. You are testing that your app can make a network request.

The fix is simple: treat the model like an external dependency and test your AI layer as a deterministic contract. That means faking model output, faking tool-call payloads, simulating streaming chunks, and forcing ugly failures on purpose.

This is cheaper, but more importantly, it makes AI behavior reviewable before production. That is the real win.

Put a boundary around the model first

If your controller, job, or Livewire component talks to OpenAI directly, your tests get brittle fast. You end up asserting on transport details, not product behavior.

A better shape is to put your AI integration behind one application service. That service can return a small, app-specific result object like AiReply, DraftResult, or SupportAnswer. Your controllers test app behavior. A narrower set of tests covers the provider integration.

That boundary matters because your fake data should match your contract, not the provider's entire payload. Providers change fields. Your app should not care unless the change affects behavior.

Here is a small example:

<?php

namespace App\Services\Ai;

final class ProductCopyService
{
    public function __construct(private OpenAiResponsesClient $client) {}

    public function generate(string $name, string $audience): ProductCopyResult
    {
        $response = $this->client->create([
            'model' => 'gpt-5.4',
            'input' => "Write concise product copy for {$name} aimed at {$audience}.",
        ]);

        return new ProductCopyResult(
            headline: data_get($response, 'output_text', ''),
            tokensUsed: (int) data_get($response, 'usage.total_tokens', 0),
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Now your app tests do not need to know every provider field. They only need to know what a valid ProductCopyResult looks like.

If you are using Laravel's AI SDK, the same principle still applies. Keep your app-facing behavior behind a boundary and fake at the transport or client seam. Laravel gives you strong testing primitives for this pattern through the HTTP client fake tools and general testing utilities.

Fake model responses, not just success codes

Most teams stop too early. They fake a 200 OK with some text and call it done. That only covers the happy path where the model behaves exactly how you hoped.

You want at least three classes of fake responses:

  1. A valid answer your app should accept.
  2. A structurally valid answer your app should reject or normalize.
  3. A provider error your app should handle cleanly.

For direct HTTP integrations, Http::fake() is enough. The important part is the payload shape. Make the fake look like the real provider format your parser expects.

<?php

use Illuminate\Support\Facades\Http;
use function Pest\Laravel\postJson;

it('stores generated copy without calling the real API', function () {
    Http::fake([
        'api.openai.com/*' => Http::response([
            'output_text' => 'Ship faster with a Laravel-first AI workflow.',
            'usage' => ['total_tokens' => 148],
        ], 200),
    ]);

    $response = postJson('/products/copy', [
        'name' => 'QCode Deploy',
        'audience' => 'Laravel teams',
    ]);

    $response
        ->assertOk()
        ->assertJsonPath('headline', 'Ship faster with a Laravel-first AI workflow.');

    Http::assertSent(function ($request) {
        return str($request->url())->contains('/v1/responses')
            && $request['model'] === 'gpt-5.4';
    });
});
Enter fullscreen mode Exit fullscreen mode

That last assertion matters. It proves your code sent the request you think it sent, without leaking the test into provider internals.

Test malformed-but-plausible output

AI failures are usually not crashes. They are subtly wrong outputs that still look reasonable.

Examples:

  • JSON returned as text inside Markdown fences.
  • Missing fields in a structured payload.
  • Tool-call arguments that are syntactically valid but semantically wrong.
  • Overlong output that should trigger truncation or validation.

Those are the tests that save you from production cleanup jobs.

it('rejects product copy when the model returns empty content', function () {
    Http::fake([
        'api.openai.com/*' => Http::response([
            'output_text' => '',
            'usage' => ['total_tokens' => 83],
        ], 200),
    ]);

    $response = postJson('/products/copy', [
        'name' => 'QCode Deploy',
        'audience' => 'Laravel teams',
    ]);

    $response
        ->assertStatus(422)
        ->assertJsonPath('message', 'AI returned an unusable result.');
});
Enter fullscreen mode Exit fullscreen mode

That is the mindset shift: test whether your code can judge model output, not whether the model is smart.

Tool calls need contract tests, not vibes

Once your feature uses tool calling, naive tests break down. You are no longer asserting against one blob of output. You are testing a mini workflow: model asks for a tool, your app executes it, and the final answer depends on that result.

If you are on OpenAI's current API shape, the Responses API and its function-calling guide make this explicit. Tool calls and tool outputs are separate items, correlated by call_id. That detail is worth testing because bad correlation bugs are easy to miss in manual demos.

A good pattern is to keep fixtures tiny and intentional. Do not dump entire provider payloads into your test unless you need them. Include only the fields your parsing logic actually uses.

it('executes the stock lookup tool and returns the final answer', function () {
    Http::fakeSequence()
        ->push([
            'output' => [[
                'type' => 'function_call',
                'call_id' => 'call_123',
                'name' => 'lookupInventory',
                'arguments' => json_encode(['sku' => 'QC-DEPLOY']),
            ]],
        ], 200)
        ->push([
            'output_text' => 'QCode Deploy is in stock and ships this week.',
        ], 200);

    $tool = Mockery::mock(InventoryLookup::class);
    $tool->shouldReceive('forSku')
        ->once()
        ->with('QC-DEPLOY')
        ->andReturn(['in_stock' => true, 'eta' => 'this week']);

    $this->app->instance(InventoryLookup::class, $tool);

    $response = $this->postJson('/support/ask', [
        'question' => 'Can I get QCode Deploy this week?',
    ]);

    $response
        ->assertOk()
        ->assertJsonPath('answer', 'QCode Deploy is in stock and ships this week.');
});
Enter fullscreen mode Exit fullscreen mode

What should you assert here?

  • The tool was called exactly once.
  • The parsed arguments match your expected schema.
  • The final user-facing answer reflects tool output, not hallucinated data.
  • Unexpected tool names or invalid arguments are handled safely.

Failure modes worth forcing

Tool calling tends to fail in boring, expensive ways:

  • The model requests a tool that no longer exists.
  • The arguments are missing required keys.
  • The tool succeeds, but returns data your final formatter cannot handle.
  • The model loops and keeps asking for the same tool.

Those are not edge cases. They are normal production states. If you do not have tests for them, your AI feature is still a demo.

Streaming tests should validate transcript flow

Streaming is where many Laravel AI tests become theater. Teams assert that a stream started, maybe that the response status was fine, and move on. That misses the real risk: partial content, broken event order, dropped tool updates, and cleanup logic that never runs.

The right mental model is to test transcript flow, not socket magic.

OpenAI's current streaming docs describe semantic event types rather than one opaque stream blob. Laravel's newer AI and response tooling also leans into streaming as a first-class pattern. Your tests should reflect that by asserting against emitted chunks or normalized events.

One clean approach is to normalize incoming stream events into an internal DTO before they hit the UI. Then your tests can fake a short event sequence and assert on the rendered transcript.

it('streams partial output in order', function () {
    $events = [
        ['type' => 'response.created'],
        ['type' => 'response.output_text.delta', 'delta' => 'Ship'],
        ['type' => 'response.output_text.delta', 'delta' => ' faster'],
        ['type' => 'response.output_text.delta', 'delta' => ' with Laravel.'],
        ['type' => 'response.completed'],
    ];

    $stream = new FakeAiEventStream($events);

    $chunks = iterator_to_array(
        app(StreamedAnswerFormatter::class)->toClientChunks($stream)
    );

    expect($chunks)->toBe([
        'Ship',
        ' faster',
        ' with Laravel.',
    ]);
});
Enter fullscreen mode Exit fullscreen mode

This looks almost too simple, which is a good sign. Streaming tests should not require a real SSE connection to be useful. They should prove three things:

  • chunks arrive in the expected order
  • partial state is accumulated correctly
  • terminal events trigger cleanup or persistence

If your app stores transcript messages after stream completion, assert that explicitly. If cancellation should avoid persistence, write that test too.

Do not skip interrupted streams

Interrupted streams are where production bugs hide. Simulate a stream that ends after two deltas and never emits completion. Your app should not mark that run as successful. It should either retry, surface a recoverable error, or store an incomplete state intentionally.

That is the difference between a polished AI feature and a support ticket generator.

Force ugly failures on purpose

You should spend more time testing failure states than model brilliance. AI providers fail in ordinary infrastructure ways and weird AI-specific ways.

The minimum failure set I want in a Laravel codebase is:

  • timeout
  • rate limit
  • provider 500
  • malformed JSON or missing fields
  • empty output
  • tool-call validation failure
  • stream interruption

Laravel's fake HTTP layer makes most of this straightforward. Use fake sequences when retries matter. Use exceptions when transport failure matters more than response parsing.

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;

it('falls back gracefully when the provider times out', function () {
    Http::fake(function () {
        throw new ConnectionException('Timed out contacting AI provider.');
    });

    $response = $this->postJson('/support/ask', [
        'question' => 'Summarize this order issue',
    ]);

    $response
        ->assertStatus(503)
        ->assertJsonPath('message', 'AI is temporarily unavailable.');
});

it('retries once after a rate limit response', function () {
    Http::fakeSequence()
        ->push(['error' => ['message' => 'Rate limit']], 429)
        ->push(['output_text' => 'Final answer after retry.'], 200);

    $response = $this->postJson('/support/ask', [
        'question' => 'Retry example',
    ]);

    $response
        ->assertOk()
        ->assertJsonPath('answer', 'Final answer after retry.');
});
Enter fullscreen mode Exit fullscreen mode

These tests are not glamorous, but they are where trust comes from. Anyone can demo a good model response. Fewer teams can prove their app behaves well when the provider is slow, inconsistent, or partially broken.

Make AI behavior reviewable

Saving credits is useful. Making AI changes reviewable is what actually improves engineering quality.

A solid team workflow usually looks like this:

Keep canonical fixtures small

Use a few realistic payload fixtures for:

  • plain text success
  • structured output success
  • tool-call round trip
  • streaming transcript
  • provider failure

Do not store giant provider dumps unless a parsing bug requires them. Small fixtures are readable in pull requests.

Assert on normalized outputs

Your tests should mostly assert on app-level results:

  • rendered answer
  • saved database record
  • emitted event
  • retry path
  • fallback message

That keeps tests stable even if you switch providers later.

Snapshot only after normalization

If you use snapshots, snapshot your own DTO or trimmed JSON shape, not the raw provider payload. Raw payload snapshots turn into noise fast.

Keep one or two live integration tests

I still want a tiny number of opt-in tests that hit the real provider, usually outside the default CI path. Not many. Just enough to catch authentication drift, model deprecations, or a broken request shape.

But your day-to-day suite should run with zero credits and near-zero randomness.

That is the rule of thumb:

Unit and feature tests should prove your application can handle AI deterministically. Real API calls should be rare verification, not the foundation of confidence.

If your Laravel AI tests still depend on a live model to feel meaningful, the problem is not your budget. The problem is that your AI boundary is too loose.

Tighten the boundary, fake the right layers, and test the awkward cases first. That is how you ship AI features without burning credits or trust.


Read the full post on QCode: https://qcode.in/how-to-test-laravel-ai-features-without-burning-api-credits/

Top comments (0)