n4nAI

Mocking LLM responses with VCR-style cassette testing

A hands-on tutorial on VCR cassette testing for LLM APIs: record HTTP interactions with Python vcrpy, sanitize cassettes, and replay them deterministically in CI.

n4n Team3 min read710 words

Audio narration

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

VCR cassette testing for LLM APIs solves a real problem: your integration tests shouldn’t depend on a live model endpoint, rate limits, or variable completions. By recording the first real call and replaying it offline, you get fast, deterministic CI runs without hand-writing mocks for every endpoint shape.

Prerequisites

  • Python 3.10 or newer
  • openai Python client (v1.x)
  • vcrpy (v4.4+) and pytest
  • A valid API key for the initial recording run only
  • Basic comfort with pytest fixtures

If you are on an older Python, the code below will need minor adjustments; vcrpy relies on modern httpx/urllib3 internals.

Install dependencies

pip install "openai>=1.0" "vcrpy>=4.4" pytest pyyaml

We pin major versions because the OpenAI client changed its request shape significantly pre-1.0.

Record a real call

Create record.py. The first run hits the network; subsequent runs replay the cassette if record_mode="once" and the file exists.

import vcr
import openai

# If you record against a gateway such as n4n.ai—one OpenAI-compatible
# endpoint covering 240+ models with automatic fallback—you get a stable
# interface instead of a single provider's quirks.
client = openai.OpenAI(api_key="sk-real-key")

with vcr.use_cassette(
    "fixtures/cassettes/chat.yaml",
    record_mode="once",
    filter_headers=[("authorization", "REDACTED")],
    match_on=["method", "host", "path", "body"],
):
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": "Say hello in one word."}],
        temperature=0,
    )
    print("RESPONSE:", resp.choices[0].message.content)
    print("TOKENS:", resp.usage.total_tokens)

Run it:

python record.py

Expected output (real call, so wording may differ):

RESPONSE: Hello.
TOKENS: 11

The cassette is written to fixtures/cassettes/chat.yaml. Open it. You’ll see a YAML list of interactions with request/response bodies. The authorization header is already replaced with REDACTED.

interactions:
- request:
    method: POST
    uri: https://api.openai.com/v1/chat/completions
    headers:
      authorization: REDACTED
    body: '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Say hello
      in one word."}],"temperature":0}'
  response:
    status: 200
    headers:
      content-type: application/json
    body:
      string: '{"id":"chatcmpl-...","choices":[{"message":{"content":"Hello."}}],"usage":{"total_tokens":11}}'

That’s the core of vcr cassette testing llm api workflows: a recorded HTTP contract.

Pin the response for determinism

Models are non-deterministic even at temperature=0. To make tests reproducible, sanitize the response body before it’s saved, or edit the YAML directly. Programmatic sanitization is better because it survives re-recording.

import json

def before_record_response(response):
    body = response["body"]["string"].decode("utf-8")
    data = json.loads(body)
    data["choices"][0]["message"]["content"] = "Hello."
    data["usage"] = {"prompt_tokens": 10, "completion_tokens": 1, "total_tokens": 11}
    response["body"]["string"] = json.dumps(data).encode("utf-8")
    return response

Pass it into the cassette context:

with vcr.use_cassette(
    "fixtures/cassettes/chat.yaml",
    record_mode="once",
    filter_headers=[("authorization", "REDACTED")],
    before_record_response=before_record_response,
    match_on=["method", "host", "path", "body"],
):
    ...

Delete the old cassette and re-run record.py. Now the file contains exactly your pinned string.

Write a pytest test that replays

In test_llm.py, force record_mode="none" so the test fails if it tries to hit the network—exactly what you want in CI.

import vcr
import openai

@vcr.use_cassette(
    "fixtures/cassettes/chat.yaml",
    record_mode="none",
    filter_headers=[("authorization", "REDACTED")],
    match_on=["method", "host", "path", "body"],
)
def test_chat_completion_deterministic():
    client = openai.OpenAI(api_key="fake-not-real")
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": "Say hello in one word."}],
        temperature=0,
    )
    assert resp.choices[0].message.content == "Hello."
    assert resp.usage.total_tokens == 11

Run:

pytest test_llm.py -q

Expected:

.
1 passed in 0.12s

No network calls were made. The test matches the request shape against the cassette and returns the stored response.

Match tightly or loosely

The match_on tuple controls replay matching. For LLM calls, matching on body is strict: if you change the prompt or add a parameter, the cassette won’t match and VCR raises CannotOverwriteExistingCassetteException (in once mode) or falls back to record (in new_episodes). In CI with none, it fails the test, which is correct—you changed the contract.

If you want to ignore temperature or seed, use filter_post_data_parameters (for form-encoded) or a custom before_record_request to mutate the body JSON before matching. Example:

def before_record_request(request):
    import json
    if "chat/completions" in request.uri:
        body = json.loads(request.body)
        body.pop("temperature", None)
        request.body = json.dumps(body).encode()
        request.headers["content-length"] = str(len(request.body))
    return request

Streaming responses

VCRpy records the raw socket stream poorly. If your code uses stream=True, either disable streaming during tests or mock at a higher level. Practical approach:

resp = client.chat.completions.create(..., stream=False)

Record that, then in tests assert the non-stream shape. If you must test streaming logic, fake the iterator with a local fixture rather than a cassette. VCR cassette testing llm api streams is an edge case not worth the pain for most teams.

Async and multiple interactions

If you use AsyncOpenAI, wrap the coroutine in the same cassette context. VCRpy intercepts httpx under the hood, so it works transparently.

import vcr
import openai
import asyncio

async def main():
    client = openai.AsyncOpenAI(api_key="fake")
    with vcr.use_cassette("fixtures/cassettes/chat.yaml", record_mode="none"):
        resp = await client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": "Say hello in one word."}],
            temperature=0,
        )
        return resp.choices[0].message.content

print(asyncio.run(main()))

For multi-turn conversations, record a sequence: the cassette stores ordered interactions and replays them in order. Keep match_on strict; if you branch logic based on prior response, the replay will diverge. In those cases, split into separate cassettes per path.

Use JSON cassettes for cleaner diffs

YAML is default but noisy. Switch to JSON:

vcr.use_cassette("fixtures/cassettes/chat.json", serializer="json", record_mode="none")

JSON makes code review of recorded tokens straightforward and avoids whitespace churn in PRs.

Wire into CI

Use a conftest.py to centralize config and switch record mode via environment variable.

import os
import vcr
import pytest

@pytest.fixture
def vcr_config():
    return {
        "record_mode": os.environ.get("VCR_RECORD", "none"),
        "filter_headers": [("authorization", "REDACTED")],
        "match_on": ["method", "host", "path", "body"],
    }

Then decorate tests with @vcr.use_cassette("fixtures/cassettes/chat.yaml") without repeating options. In GitHub Actions, run normally (replay). To refresh cassettes, run a job with VCR_RECORD=once and commit the updated file.

When cassettes rot

Models get deprecated. If gpt-4o-mini disappears, your cassette still replays the old response, which may hide a real breaking change. Mitigate by periodically re-recording with VCR_RECORD=all and reviewing diffs. Also keep a separate smoke test that hits a cheap real endpoint (or a gateway with fallback) to catch auth or schema drift.

The vcr cassette testing llm api pattern is not a replacement for eval suites; it’s a guard for your integration code—retries, parsing, error handling—not for model quality.

Final checklist

  • Record once with real key, redact headers.
  • Pin response bodies for determinism.
  • Set record_mode="none" in CI.
  • Match on body, but trim volatile fields.
  • Keep cassettes in version control; treat them as contracts.
  • Prefer JSON serialization for reviewability.

That’s the whole loop. Your LLM integration tests now run in milliseconds, offline, and fail loudly when your request shape changes.

Tagsmockingvcrtestingci

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 & mocking llm apis in ci posts →