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:
- Install dev deps:
pip install pytest pytest-asyncio pytest-socket respx httpx. - Set
OPENAI_API_KEY=dummyin the test environment. - Run
pytest --disable-socket -q. - 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.OpenAIglobally inconftest.pyunless 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-rolledSimpleNamespace. - If you refactor the wrapper module, update the patch target string. String-based patches are brittle to moves; consider using
patch.objecton 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.