Unit Testing OpenAI SDK Integration Code

Ma Mahalakshmi V Updated 19 Sep 2026
7 min read ·Lesson 165 of 224

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):

  1. Arrange — set up the inputs and any fakes/mocks the test needs.
  2. Act — call the function under test exactly once.
  3. 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: FakeClientForSummarize and FakeResponse are 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.

0 Comments

Reviewed before they appear

No comments yet.

OpenAI SDK
Introduction to the OpenAI SDK Setting Up Python Creating an API Key Your First Call — client.responses.create() and response.output_text Understanding Billing, Credits, and What a Request Costs Why Responses Replaced Chat Completions Anatomy of a Request: model, input, and instructions Anatomy of a Response: The Typed output Array, Not Just Text Roles: User, Assistant, and Developer/System Choosing a Model, and Reading the Models Page Instead of Memorizing Names Instructions vs. Input Writing Prompts That Get Consistent Results Few-Shot Examples Reasoning Models and the reasoning Parameter Debugging a Prompt That Misbehaves Why Streaming Matters for User Experience stream=True and Iterating Over Events Handling the Event Types You Actually Care About Background Mode for Long-Running Jobs Project — Add Live Streaming to Your Chatbot The Problem With Parsing Free Text JSON Schema and Strict Mode Pydantic Models With the SDK's Parse Helpers Handling Refusals and Validation Failures Project — A Resume-to-JSON Extractor Working With input_image input_file, PDFs, and the Files API Image Generation Speech-to-Text and Text-to-Speech Project: A PDF Question-Answering Script What Function Calling Is Defining a Tool Schema The Full Loop Multiple Tools Errors, Timeouts, and Untrusted Arguments Project: A Weather Assistant Web Search File Search and Vector Stores Code Interpreter Remote MCP Servers and Connectors Project: A Research Assistant What an Embedding Is, Without the Maths Generating and Storing Embeddings Similarity Search From Scratch Hosted Vector Stores vs. Rolling Your Own A Small RAG App Over a Folder of Notes Agents vs. a Single API Call — When You Need One pip install openai Giving Agents Tools Handoffs and Multi-Agent Triage Guardrails and Approvals Tracing and Observing What Your Agent Did A Multi-Agent Support Desk Error Codes and What Each One Means Retries, Timeouts, and Backoff Rate Limits and Spend Limits Prompt Caching and Cost Optimisation The Batch API for Bulk Work Async Clients and Concurrency Moderation and Safety Best Practices Designing the App Backend With FastAPI Streaming to a Simple Frontend Deploying and a Cost/Safety Checklist Why Web Search Is Useful for Current Information Using the Web Search Tool with the Responses API Configuring Search Behavior for Application Use Cases Understanding Citations and Source Attribution Where to Go Next Building a Research Assistant with Web Search Combining Web Search with Structured Outputs Handling Conflicting or Low-Quality Web Sources Reducing Unsupported Claims with Grounded Generation Testing Freshness-Sensitive AI Answers Production Considerations for Web-Grounded Applications Understanding File Search and Retrieval-Augmented Generation Creating and Organizing Vector Stores Uploading Documents for Retrieval Connecting Vector Stores to Responses API Requests Designing Document Metadata and Filtering Strategies Building a PDF Question-Answering Application Improving Retrieval Quality With Better Document Preparation Handling Missing Evidence and Retrieval Failures Combining File Search With Web Search Building a Production Knowledge-Base Assistant What the Code Interpreter Tool Is Designed For Running Python-Based Analysis Through the OpenAI SDK Uploading Datasets for Analysis Analyzing CSV and Spreadsheet Data Generating Charts and Data Summaries Handling Generated Files and Downloadable Artifacts Building a Data-Analysis Assistant Combining Code Execution with Structured Outputs Validating Generated Calculations and Results Security and Sandbox Considerations for Code Execution Understanding Multimodal Input with the OpenAI SDK Sending Images to a Model Image Analysis from URLs and Uploaded Files Extracting Text and Information from Screenshots Building an Image-Question-Answering Application Combining Image Input with Structured Output Analyzing Multiple Images in One Request Handling Image Quality and Input Limitations Designing Multimodal Prompts for Reliable Results Building a Practical Vision-Powered Python Application Understanding Speech-to-Text and Text-to-Speech Workflows Transcribing Audio with the OpenAI SDK Working with Uploaded Audio Files Handling Timestamps and Transcription Metadata Building a Meeting Transcription Workflow Generating Spoken Responses from Text Handling Long Audio and Processing Failures Combining Audio with Text and Tool Calling Building an End-to-End Python Voice Application What Embeddings Are and When to Use Them Generating Embeddings With the OpenAI API Preparing Text for Embedding Comparing Vectors With Cosine Similarity Building a Simple Semantic Search Engine in Python Storing Embeddings in a Database Metadata Filtering for Semantic Search Chunking Strategies for Better Retrieval Evaluating Semantic Search Quality Building a Document Similarity Application When Batch Processing Makes Sense Designing Large-Volume AI Processing Pipelines Using Asynchronous Python with the OpenAI SDK Running Concurrent Requests Safely Controlling Concurrency and Avoiding Rate Limits Tracking Batch Job Progress Handling Partial Failures in Bulk Workloads Retrying Failed Items Without Duplicating Successful Work Designing Resumable AI Processing Jobs Building a Production Batch-Processing Pipeline Batch Processing Makes Sense Large-Scale AI Processing Pipelines Async Python with OpenAI SDK Safe Concurrent Requests Concurrency & Rate Limits Batch Progress Tracking Partial Failure Handling Safe Retry Handling Resumable AI Jobs Production Batch Pipeline System–User Data Separation Reusable App Instructions Prompt Templates & Variables Extraction & Classification Prompts Summarization & Transformation Prompts Explicit Output Requirements Prompt Version Management Prompt Testing & Evaluation Reusable Python Prompt Library API Key Security Secure API Key Storage Secure Secret Management Prompt Injection Prevention Trusted vs. Untrusted Content Tool Argument Validation Sensitive Data Handling Secure Logging AI Action Authorization Production AI Security Checklist Why AI Applications Need Evaluation Beyond Unit Tests Unit Testing OpenAI SDK Integration Code Mocking API Responses in Python Tests Testing Structured Outputs Against Schemas Testing Tool-Calling Workflows Building a Small Evaluation Dataset Measuring Accuracy, Consistency, and Failure Rates Regression Testing Prompts and Model Changes Human Evaluation Versus Automated Evaluation Creating a Repeatable Evaluation Pipeline AI Request Monitoring Token Cost Management Usage Metrics Design Reducing Model Calls Prompt & Context Optimization Model Selection & Optimization AI Caching Strategies Interactive Latency Optimization Usage Dashboards & Budget Alerts Performance & Cost Checklist Every API Call Starts Fresh Fixing API Statelessness Server-Side Conversation Memory Limits of Response Chaining What We're Building Conversation Memory Challenges Preparing an OpenAI SDK Application for Deployment Environment-Specific Configuration for Development and Production Deploying a Python AI Service with Docker Container Health Checks and Startup Configuration Managing Secrets in Cloud Deployments Background Workers for Long-Running AI Tasks Queues and Asynchronous Job Architectures Scaling AI Workloads Horizontally Monitoring Production Incidents and Failures Production Deployment Checklist for OpenAI SDK Applications Reusable OpenAI Service Classes AI Client Dependency Injection Typed AI Responses Python Configuration Management AI Request Decorators Centralized AI Error Handling Clean SDK Abstractions Reusable OpenAI Utilities Internal AI Python Libraries SDK Integration Maintenance Production AI Chatbot Document Q&A System Web Research Assistant Customer Support Agent AI Data Analysis Assistant Image Analysis App Meeting Transcription & Summary Semantic Document Search Multi-Tool AI Agent Production OpenAI SDK App Why "It Looked Fine When I Tested It" Isn't Enough Timing Note Status Note Pre-Decision Status Note Current Availability Note
Ask about this post
AI Ask about this post

Ask questions about Unit Testing OpenAI SDK Integration Code and get answers drawn from it.

Signed-in readers only.