artisanplaybook .dev
ARTICLES / AI & LLMS

Testing AI features: why asserting on exact LLM output breaks every time

It's tempting to test an AI feature the same way you'd test any other endpoint — call it, assert on the response. The problem is that an LLM's exact wording isn't a stable contract the way a database query's output is. Two calls with identical input can come back phrased differently, and providers update models behind the same API version without warning. A test suite built on exact-output assertions isn't testing your code — it's testing the model's mood that day.

ON THIS PAGE

The trap, concretely

public function test_it_summarizes_the_article(): void
{
    $response = $this->postJson('/api/summarize', ['text' => $longArticle]);

    $response->assertJson([
        'summary' => 'This article discusses the impact of remote work on productivity.',
    ]);
}

This passes the day you write it, because you ran it once, saw what the model returned, and pasted it into the assertion. It's fragile in exactly the way that matters: run the same test tomorrow, hit the real API, and a rephrased-but-equally-correct summary fails the test — not because anything is wrong, but because the assertion was never actually testing "is this a good summary," it was testing "does this match the one specific string I happened to get once."

Worse, if this test hits the real API on every run, your test suite now costs money per run, takes several seconds per assertion, and occasionally fails due to the provider's own rate limits or downtime — none of which has anything to do with whether your code works.

Separate what you're actually testing

There are really two different things worth verifying, and conflating them is what causes this pain:

  1. Does your code correctly call the API, parse the response, and handle it? — this is regular application logic, deterministic, and should be tested like any other integration.
  2. Is the model's output actually good? — this is a quality question about the AI's behavior, inherently fuzzy, and not something a pass/fail unit test is the right tool for.

Almost all of your day-to-day test suite should be testing the first thing. The second thing needs a different kind of check entirely, run far less often.

Faking the provider, not the model's judgment

Since the LLM call itself is just an HTTP request under the hood, Http::fake() works exactly the way it does for any other external API — fake the provider's response shape, and test that your code handles it correctly:

public function test_it_stores_the_summary_from_a_successful_response(): void
{
    Http::fake([
        'api.openai.com/*' => Http::response([
            'choices' => [
                ['message' => ['content' => 'A short summary of the article.']],
            ],
        ]),
    ]);

    $response = $this->postJson('/api/summarize', ['text' => $longArticle]);

    $response->assertOk();
    $this->assertDatabaseHas('summaries', ['content' => 'A short summary of the article.']);
}

This test is fast, free, and deterministic — it verifies your code correctly extracts choices.0.message.content and stores it, which is exactly the part of the system that's actually yours to get right. It says nothing about whether a real summary would be good, and that's fine — that's not what this test is for.

Testing what happens when the model doesn't cooperate

This is where fake responses earn their keep the most: providers don't always return what you asked for, even with structured output requested, and your code needs to survive that without your test suite depending on actually provoking it from a live model:

public function test_it_handles_malformed_json_from_the_model_gracefully(): void
{
    Http::fake([
        'api.openai.com/*' => Http::response([
            'choices' => [
                ['message' => ['content' => '{"summary": "incomplete json...']], // truncated/invalid
            ],
        ]),
    ]);

    $response = $this->postJson('/api/summarize', ['text' => $longArticle]);

    $response->assertStatus(422); // or however your app is meant to handle it
}
public function test_it_retries_on_a_rate_limit_response(): void
{
    Http::fake([
        'api.openai.com/*' => Http::sequence()
            ->push(['error' => ['message' => 'Rate limited']], 429)
            ->push(['choices' => [['message' => ['content' => 'Success on retry.']]]], 200),
    ]);

    $response = $this->postJson('/api/summarize', ['text' => $longArticle]);

    $response->assertOk();
}

Http::sequence() lets you script a specific sequence of responses — a rate limit followed by success, several timeouts in a row, a malformed payload — and verify your retry logic and error handling actually behave the way you intend, on demand, without needing to wait around for a real provider to misbehave on its own schedule.

If you need to check output quality, treat it as a separate, smaller suite

Sometimes you do want to know "does the model still produce a reasonable summary for this input" — genuinely useful, especially after changing a prompt. This is usually called an eval, and it deserves different rules than your regular test suite:

  • Run it separately from your normal CI test run — on a schedule, or manually before a prompt change ships, not on every commit, since it's slow and costs real money per run.
  • Assert on properties, not exact text — length within a range, presence of key facts, valid JSON matching your schema, absence of specific banned content — rather than string equality.
  • Expect to review failures manually, at least at first — a quality check that says "this summary seems worse" often needs a human to confirm that judgment, unlike a normal assertion that's either right or wrong with no ambiguity.
// A property check, not an exact match — this survives natural wording variation
public function test_summary_is_reasonable_length_and_mentions_key_topic(): void
{
    $summary = $this->realAiClient->summarize($longArticle); // real call, run separately from normal suite

    $this->assertLessThan(500, strlen($summary));
    $this->assertStringContainsStringIgnoringCase('remote work', $summary);
}

Snapshot testing LLM output is usually a trap, not a shortcut

Snapshot testing feels appealing here — capture the current output once, diff against it forever. For deterministic code, that's fine. For LLM output, a snapshot test will drift out of sync with reality constantly, since the model's exact phrasing isn't stable even when its quality is. You end up either re-approving snapshots so often the test provides no real signal, or ignoring failures because they're expected — both of which mean the test has stopped actually protecting anything. Snapshot the structure your code produces (the parsed fields, the shape of the stored record) rather than the model's literal wording, and you get the regression protection without the constant false alarms.

Checklist before trusting an AI feature's test suite

  • Do any assertions check for exact LLM-generated text, rather than the structure or fields your own code is responsible for producing?
  • Is the regular test suite using Http::fake() to stay fast, free, and deterministic, with real-API calls isolated to a separate, deliberately-run eval suite?
  • Are malformed-response and rate-limit scenarios tested explicitly via Http::fake()/Http::sequence(), rather than only the happy path?
  • If snapshot testing is in use anywhere near AI output, is it snapshotting parsed structure rather than literal model wording?

Related reading

ARCHIVE →