Unit Testing OpenAI SDK Integration Code
Structuring Code So It Can Be Tested
Before writing a single test, the code under test has to be organized in a way that allows a test to substitute something else for the real OpenAI client. This is the single most important design decision for testability, and it is called dependency injection: instead of a function reaching out and creating its own client internally, the client is passed in as a parameter (or attached to an object that is passed in).
from openai import OpenAI
# Hard to test: the client is created inside the function.
def summarize_bad(text: str) -> str:
client = OpenAI()
response = client.responses.create(
model="gpt-5.6-terra",
input=f"Summarize this in one sentence:\n\n{text}",
)
return response.output_text
# Easy to test: the client is a parameter.
def summarize(client, text: str) -> str:
response = client.responses.create(
model="gpt-5.6-terra",
input=f"Summarize this in one sentence:\n\n{text}",
)
return response.output_text.strip()
summarize_bad cannot be unit tested without either making a real network call or monkeypatching the OpenAI class itself (messy and fragile). summarize accepts client as an argument, so a test can pass in anything with a matching .responses.create(...) method — a real client, a hand-built fake, or a unittest.mock.Mock. This is why so much SDK-calling code in production systems takes a client (or a thin wrapper class holding one) as a constructor or function argument rather than instantiating it internally: it is the seam that makes testing possible at all. The next lesson goes deeper into building fakes and mocks; this lesson focuses on how to structure the tests themselves once that seam exists.
Why Dependency Injection Matters Here Specifically
It might seem like unnecessary ceremony to thread a client argument through your code. The reason it matters more here than in ordinary code is that the OpenAI client performs I/O — network calls with real latency, real cost, and real failure modes (rate limits, timeouts, malformed responses). A test suite that instantiates a real client and calls a real endpoint on every run is:
- Slow — network round trips dominate test runtime, and a suite of hundreds of tests becomes unusable.
- Costly — every test run spends real money, which adds up fast in continuous integration where tests run on every push.
- Flaky — network issues, rate limits, or model output variance can make a test fail for reasons unrelated to a code change.
- Unsafe to run without credentials — anyone without an API key (a new contributor, a CI runner without secrets configured) cannot run the tests at all.
Dependency injection removes all four problems for the majority of your test suite, while leaving room for a small number of deliberately-marked integration tests that do use the real client (covered later in this unit).
Test Structure: Arrange, Act, Assert
A well-written unit test follows a simple three-part shape, often called Arrange-Act-Assert (AAA):
- Arrange — set up the inputs and any fakes/mocks the test needs.
- Act — call the function under test exactly once.
- Assert — check that the result matches what you expect.
class FakeResponse:
def __init__(self, text: str):
self.output_text = text
class FakeClientForSummarize:
def __init__(self, canned_text: str):
self._canned_text = canned_text
self.last_kwargs = None
class _Responses:
def create(inner_self, **kwargs):
self.last_kwargs = kwargs
return FakeResponse(self._canned_text)
self.responses = _Responses()
def test_summarize_returns_stripped_output_text():
# Arrange
fake_client = FakeClientForSummarize(canned_text=" A short summary. ")
# Act
result = summarize(fake_client, "Some long article text.")
# Assert
assert result == "A short summary."
print("PASS: summarize strips whitespace from output_text")
def test_summarize_passes_the_input_text_into_the_prompt():
fake_client = FakeClientForSummarize(canned_text="ok")
summarize(fake_client, "Article about testing.")
assert "Article about testing." in fake_client.last_kwargs["input"]
print("PASS: summarize includes the source text in the model input")
FakeResponse mimics the shape of the real SDK's response object closely enough for summarize to work against it — it only needs an output_text attribute, because that is the only attribute summarize reads. FakeClientForSummarize mimics the client closely enough to have .responses.create(...), and it additionally records the keyword arguments it was called with in last_kwargs, which lets the second test verify what was sent to the model, not just what came back. This is a recurring and important testing technique: testing the request your code builds is just as valuable as testing how it handles the response, because a bug that sends the wrong prompt is invisible if you only ever look at the returned text.
Using pytest Fixtures to Avoid Repetition
As a test suite grows, repeating the same setup code (fake_client = FakeClientForSummarize(...)) in every test becomes noisy and easy to get subtly wrong. pytest fixtures solve this by letting you define setup once and have it automatically injected into any test function that names it as a parameter.
import pytest
@pytest.fixture
def fake_client():
return FakeClientForSummarize(canned_text="Fixture-provided summary.")
def test_summarize_uses_fixture_client(fake_client):
result = summarize(fake_client, "Any input text works here.")
assert result == "Fixture-provided summary."
print("PASS: summarize works with a fixture-provided fake client")
@pytest.fixture
def sample_article():
return (
"Unit tests verify deterministic code. Evaluations measure model "
"output quality. Both are necessary in a production AI system."
)
def test_summarize_with_realistic_article(fake_client, sample_article):
result = summarize(fake_client, sample_article)
assert isinstance(result, str)
assert len(result) > 0
print("PASS: summarize handles a realistic multi-sentence article")
@pytest.fixture marks a function as a reusable piece of setup. Any test function that lists fake_client as a parameter automatically receives whatever fake_client() returns, freshly created for that test — pytest handles the wiring. This matters for two reasons: first, it removes duplication, so a change to how the fake client is constructed only needs to happen in one place; second, it guarantees test isolation, because each test gets its own fresh fixture instance rather than accidentally sharing mutable state with another test (a common source of tests that pass individually but fail when run together).
Fixtures are typically placed in a conftest.py file at the root of your test directory (or a subdirectory) when they need to be shared across multiple test files — pytest automatically discovers fixtures defined there without any import statement needed in the test files themselves.
# conftest.py
import pytest
@pytest.fixture
def fake_client():
return FakeClientForSummarize(canned_text="Shared fixture summary.")
Note:
FakeClientForSummarizeandFakeResponseare illustrative. Real SDK response objects carry more fields (id,model,usage, and so on); a fake only needs to implement the attributes your code actually reads.
When to Reach for Integration Tests Instead
Unit tests with fakes verify your code's logic against an assumed response shape. That assumption can go stale — if OpenAI changes a field name or you misremember the shape of a real response, every unit test can pass while the real integration is broken. This is what a small number of separately-marked integration tests are for: making a real (or realistically recorded) API call to confirm the assumed shape is still accurate, run far less frequently than the unit suite (for example, nightly in CI rather than on every commit).
import pytest
@pytest.mark.integration
def test_summarize_against_real_api(real_client, sample_article):
result = summarize(real_client, sample_article)
assert isinstance(result, str)
assert len(result) > 0
print("PASS: summarize works against the real OpenAI API")
The @pytest.mark.integration marker lets you run pytest -m "not integration" for the fast day-to-day suite and pytest -m integration separately when you want to confirm the real contract still holds, typically requiring OPENAI_API_KEY to be set and incurring real cost.
Common Mistakes
- Testing implementation details instead of behavior. Asserting the exact internal call sequence of a library, rather than the observable output of your own function, creates tests that break on harmless refactors and provide little confidence about actual correctness.
- Sharing mutable fake objects across tests without fixtures. A module-level fake client reused across many tests can accumulate state (like
last_kwargs) from a previous test, causing confusing failures that depend on test execution order. - Not asserting on the request, only the response. A test that only checks the final return value can miss a bug where the wrong prompt, wrong model name, or wrong parameters were sent to
client.responses.create(...).
Best Practices
- Inject the client as a parameter or constructor argument everywhere your code calls the OpenAI SDK, so tests never need a real network connection to exercise your logic.
- Use fixtures for any setup shared by more than one or two tests, and keep fixtures narrowly scoped so each test starts from a clean, predictable state.
- Separate fast unit tests from slower, costlier integration tests using markers, and run the fast suite far more often than the slow one.