n4nAI

Mocking the OpenAI SDK for unit tests

Learn how to mock openai sdk unit tests in Python with unittest.mock and respx for deterministic CI runs without hitting live LLM APIs.

n4n Team3 min read719 words

Audio narration

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

Calling the OpenAI Python SDK inside application code makes unit tests slow and nondeterministic unless you mock openai sdk unit tests properly. The SDK’s client methods perform real HTTP requests, so patching at the right layer keeps your CI green and your test suite under a second. This guide walks through concrete patterns for both the synchronous and asynchronous clients, including streaming and tool calls, using pytest and standard mocking libraries.

Step 1: Isolate the SDK behind a thin wrapper

Don’t instantiate OpenAI directly inside every function. Create a single factory so tests can patch one import site. When you mock openai sdk unit tests, a single seam beats scattering patches across the codebase. Whether you target api.openai.com or an OpenAI-compatible gateway such as n4n.ai, the client construction is identical because the SDK speaks the same protocol.

import os
from openai import OpenAI, AsyncOpenAI

def get_client() -> OpenAI:
    return OpenAI(
        api_key=os.environ.get("OPENAI_API_KEY", "dummy"),
        base_url=os.environ.get("OPENAI_BASE_URL"),  # None uses default
    )

def get_async_client() -> AsyncOpenAI:
    return AsyncOpenAI(
        api_key=os.environ.get("OPENAI_API_KEY", "dummy"),
        base_url=os.environ.get("OPENAI_BASE_URL"),
    )

A service module imports these factories:

from .llm import get_client

def summarize(text: str) -> str:
    client = get_client()
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": f"Summarize: {text}"}],
        temperature=0.0,
    )
    return resp.choices[0].message.content

Step 2: Build a faithful fake response

The SDK returns pydantic models like ChatCompletion. You can construct a real one, but that forces you to populate every required field. A MagicMock with spec gives you attribute access while failing if you typo a field name.

from openai.types.chat import ChatCompletion
from unittest.mock import MagicMock

def mock_chat_completion(content: str, model: str = "gpt-4o-mini") -> MagicMock:
    mock = MagicMock(spec=ChatCompletion)
    mock.choices = [MagicMock(message=MagicMock(content=content))]
    mock.model = model
    mock.usage = MagicMock(prompt_tokens=5, completion_tokens=10, total_tokens=15)
    mock.id = "chatcmpl-test"
    mock.created = 1234567890
    mock.object = "chat.completion"
    return mock

For tool calls, set message.tool_calls to a list of mocks with id, function.name, and function.arguments. Keep the fake minimal but schema-valid; over-mocking hides real API changes.

Why not use VCR.py?

VCR records real HTTP interactions. For LLM APIs, responses are nondeterministic and may contain sensitive data. Recording also couples tests to a specific model version. Mocking at the client or HTTP layer is lighter and explicit.

Step 3: Patch the client in a pytest test

Patch the OpenAI symbol in the module where you imported it, not in the openai package. This is the most common mistake when engineers first mock openai sdk unit tests.

import pytest
from unittest.mock import patch, MagicMock
from myapp import summarize
from tests.fakes import mock_chat_completion

def test_summarize_returns_content():
    fake_client = MagicMock()
    fake_client.chat.completions.create.return_value = mock_chat_completion("Summary.")
    with patch("myapp.llm.OpenAI", return_value=fake_client):
        result = summarize("Long article about testing")
    assert result == "Summary."
    fake_client.chat.completions.create.assert_called_once()
    call_kwargs = fake_client.chat.completions.create.call_args.kwargs
    assert call_kwargs["model"] == "gpt-4o-mini"
    assert call_kwargs["temperature"] == 0.0
    assert "Summarize:" in call_kwargs["messages"][0]["content"]

Run with pytest tests/test_summarize.py -q. A passing test with no network activity confirms the mock works. Add pytest-socket and --disable-socket to fail if any test sneaks a real connection.

Step 4: Mock streaming responses

Streaming returns a generator of ChatCompletionChunk objects. Your service likely iterates and yields text. Fake the iterator with a generator function.

from openai.types.chat import ChatCompletionChunk
from unittest.mock import MagicMock

def fake_stream(texts):
    for text in texts:
        chunk = MagicMock(spec=ChatCompletionChunk)
        chunk.choices = [MagicMock(delta=MagicMock(content=text))]
        yield chunk

def test_streaming_summarize():
    fake_client = MagicMock()
    fake_client.chat.completions.create.return_value = fake_stream(["Hello", " world"])
    with patch("myapp.llm.OpenAI", return_value=fake_client):
        from myapp import summarize_stream
        out = "".join(summarize_stream("input"))
    assert out == "Hello world"

If your code uses for chunk in stream:, the mock generator behaves identically to the real SDK. For async streaming, define an async def generator and assign it to an AsyncMock return value.

Step 5: Mock at the HTTP layer with respx

Patching the method hides transport concerns: timeouts, retries, and status codes. When you need to test error handling or fallback logic, intercept the underlying httpx call with respx.

import respx
import httpx
import pytest

@respx.mock
def test_openai_timeout_raises():
    respx.post("https://api.openai.com/v1/chat/completions").mock(
        side_effect=httpx.ConnectTimeout("timed out")
    )
    from myapp import summarize
    with pytest.raises(httpx.ConnectTimeout):
        summarize("test")

If you point base_url at a gateway that performs automatic fallback when a provider is degraded, the client still issues one POST. Mock that single route and return a 200 with a JSON body matching the ChatCompletion schema to simulate success after a retry.

@respx.mock
def test_gateway_fallback_success():
    respx.post("https://gateway.example/v1/chat/completions").mock(
        return_value=httpx.Response(
            200,
            json={
                "id": "chatcmpl-1",
                "object": "chat.completion",
                "model": "gpt-4o-mini",
                "choices": [{"message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
                "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
            },
        )
    )
    from myapp import summarize
    assert summarize("test") == "ok"

To exercise retry logic, mock a 429 then a 200 on the same route using respx.post(...).mock(side_effect=[httpx.Response(429), httpx.Response(200, json=...)]).

Step 6: Handle the async client

Async code needs AsyncMock and pytest-asyncio.

import pytest
from unittest.mock import patch, AsyncMock
from myapp import summarize_async
from tests.fakes import mock_chat_completion

@pytest.mark.asyncio
async def test_async_summarize():
    fake = AsyncMock()
    fake.chat.completions.create.return_value = mock_chat_completion("async ok")
    with patch("myapp.llm.AsyncOpenAI", return_value=fake):
        result = await summarize_async("text")
    assert result == "async ok"

The same fake_stream pattern works for async streaming if you make it an async def generator and use AsyncMock with return_value set to the async generator.

Step 7: Test tool-call handling

Tool calls are just another shape of response. Build a fake that includes tool_calls and assert your parser extracts the right function name.

def mock_tool_call():
    mock = MagicMock(spec=ChatCompletion)
    call = MagicMock()
    call.id = "call_1"
    call.function.name = "get_weather"
    call.function.arguments = '{"city": "SF"}'
    mock.choices = [MagicMock(message=MagicMock(tool_calls=[call], content=None))]
    return mock

Patch the client, invoke your agent loop, and verify it dispatches to the local get_weather implementation. This is where mocking beats recorded fixtures: you control the exact argument string.

Step 8: Verify success in CI

A complete verification flow:

  1. Install dev deps: pip install pytest pytest-asyncio pytest-socket respx httpx.
  2. Set OPENAI_API_KEY=dummy in the test environment.
  3. Run pytest --disable-socket -q.
  4. Confirm zero real HTTP calls and all tests green.
pytest --disable-socket -q

If you use coverage, assert that lines calling client.chat.completions.create are exercised by mocks, not skipped. A clean run with no socket activity is your proof that you correctly mock openai sdk unit tests.

Practical caveats

  • Never patch openai.OpenAI globally in conftest.py unless every test should use it. Prefer patching per test to keep failures localized.
  • Keep fake responses minimal but schema-valid. A MagicMock(spec=...) catches attribute typos better than a hand-rolled SimpleNamespace.
  • If you refactor the wrapper module, update the patch target string. String-based patches are brittle to moves; consider using patch.object on the imported symbol.
  • For integration tests that hit a real sandbox, use a separate pytest marker and never run them in the default CI gate.

Following these steps lets you mock openai sdk unit tests with confidence, keeping pipelines fast and deterministic while still covering retry, streaming, and tool-call paths.

Tagsmockingopenai-sdkunit-testingpython

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 →