Mocking API Responses in Python Tests

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

Two Ways to Fake a Dependency

The previous lesson used a hand-written class — FakeClientForSummarize — to stand in for the real OpenAI client. That approach is called a fake: a simplified but real implementation of the same interface, built by you, that behaves consistently according to rules you wrote. Python's standard library offers a second approach through the unittest.mock module: a mock, which is a generic object that records how it was called and returns whatever you configure, without you writing a real class at all.

Both techniques solve the same underlying problem — replacing the real client.responses.create(...) call with something fast, free, and deterministic — but they trade off differently, and knowing when to reach for each one is a practical skill this lesson builds.

unittest.mock.Mock and MagicMock

Mock (and its more permissive sibling MagicMock) is an object that accepts any attribute access or method call and, by default, returns another Mock object. You configure the specific behavior you need by setting attributes or using return_value.

from unittest.mock import MagicMock


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


def test_summarize_with_magicmock():
    fake_client = MagicMock()
    fake_client.responses.create.return_value.output_text = "  Concise summary.  "

    result = summarize(fake_client, "Some article text.")

    assert result == "Concise summary."
    print("PASS: summarize works with a MagicMock-based client")

fake_client = MagicMock() creates an object where fake_client.responses is automatically another MagicMock, and fake_client.responses.create is callable and also a MagicMock. Setting .return_value.output_text configures what calling create(...) returns: an object whose .output_text attribute is the string you specified. This is remarkably little code compared to writing a FakeResponse and FakeClient class by hand, which is the main appeal of MagicMock for quick, narrowly-scoped tests.

Asserting on How a Mock Was Called

Mocks automatically record every call made to them, which lets you verify not just what your code returns, but how it used its dependency — exactly the kind of check the previous lesson made by hand with last_kwargs.

from unittest.mock import MagicMock


def test_summarize_calls_create_with_expected_model():
    fake_client = MagicMock()
    fake_client.responses.create.return_value.output_text = "ok"

    summarize(fake_client, "Text to summarize.")

    fake_client.responses.create.assert_called_once()
    _, kwargs = fake_client.responses.create.call_args
    assert kwargs["model"] == "gpt-5.6-terra"
    assert "Text to summarize." in kwargs["input"]
    print("PASS: summarize calls create() with the correct model and input")

assert_called_once() fails the test if create was called zero times or more than once — useful for catching bugs like an accidental retry loop that calls the API twice for one logical request. call_args holds the positional and keyword arguments from the most recent call, letting you inspect exactly what was sent. This kind of assertion catches an entire class of bugs — wrong model string, missing parameter, malformed prompt — that a test only checking the final return value would miss entirely.

Patching: Replacing a Real Object Temporarily

Dependency injection (Lesson 2) is the preferred design because it makes tests straightforward. But sometimes you are testing code you cannot easily refactor — a third-party library, legacy code, or a function that constructs its own client internally, as in summarize_bad from the previous lesson. unittest.mock.patch handles this case by temporarily replacing an object at a given import path for the duration of a test.

from unittest.mock import patch, MagicMock


def summarize_bad(text: str) -> str:
    from openai import OpenAI

    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


@patch("openai.OpenAI")
def test_summarize_bad_with_patch(mock_openai_class):
    mock_instance = MagicMock()
    mock_instance.responses.create.return_value.output_text = "Patched summary."
    mock_openai_class.return_value = mock_instance

    result = summarize_bad("Some text.")

    assert result == "Patched summary."
    print("PASS: summarize_bad works when OpenAI() is patched")

@patch("openai.OpenAI") replaces the OpenAI class, at the exact import path used inside summarize_bad, with a MagicMock for the duration of the test, then restores the original afterward automatically — even if the test raises an exception. The patched class is passed into the test function as an argument (mock_openai_class); configuring mock_openai_class.return_value controls what OpenAI() returns when summarize_bad calls it. This works, but notice it is more fragile than dependency injection: the patch target ("openai.OpenAI") must exactly match where the name is looked up, not necessarily where it is defined, which is a frequent source of confusing patch failures — patching "openai.OpenAI" does nothing if the code being tested did from openai import OpenAI at module import time, because in that case you must patch the name inside the module that imported it (for example "myapp.services.OpenAI").

patch can also be used as a context manager instead of a decorator, which is often clearer when you only need the mock for part of a test:

from unittest.mock import patch, MagicMock


def test_summarize_bad_with_patch_context_manager():
    with patch("openai.OpenAI") as mock_openai_class:
        mock_instance = MagicMock()
        mock_instance.responses.create.return_value.output_text = "Context-managed."
        mock_openai_class.return_value = mock_instance

        result = summarize_bad("Some text.")

    assert result == "Context-managed."
    print("PASS: summarize_bad works with patch as a context manager")

pytest's monkeypatch Fixture

pytest provides its own patching mechanism, monkeypatch, as a built-in fixture. It is often preferred within pytest-based suites because it integrates directly with fixture injection and automatically undoes every change at the end of the test, with no need for a decorator or with block.

def test_summarize_bad_with_monkeypatch(monkeypatch):
    mock_instance = MagicMock()
    mock_instance.responses.create.return_value.output_text = "Monkeypatched."

    import openai
    monkeypatch.setattr(openai, "OpenAI", lambda **kwargs: mock_instance)

    result = summarize_bad("Some text.")

    assert result == "Monkeypatched."
    print("PASS: summarize_bad works with monkeypatch")

monkeypatch.setattr(openai, "OpenAI", ...) replaces the OpenAI attribute on the openai module object directly, for the duration of the current test only. Because monkeypatch is a fixture, pytest automatically restores the original attribute after the test finishes, regardless of whether the test passed, failed, or raised an unexpected error — you never need to remember a manual cleanup step.

Fakes Versus Mocks: When to Use Each

AspectHand-written fakeMock / MagicMock
Setup effortMore code upfrontVery little code
Behavior realismYou control exact, consistent behaviorPermissive; can silently accept wrong calls
Catches interface driftYes, if kept in sync with the real clientNo, MagicMock accepts any attribute or call
Best forReused across many tests; complex stateful behaviorSmall, one-off assertions on a single call
RiskFake can go stale versus the real APIOver-mocking hides bugs a real object would catch

A fake is worth the extra effort when you need the same simulated client across dozens of tests, or when you need to simulate multi-step behavior (for example, returning a tool call on the first call and a final answer on the second). Mock/MagicMock is worth its convenience for smaller, more localized tests, especially ones focused on verifying a single interaction. Many real test suites use both: a shared fake client for broad coverage, and targeted MagicMock usage for one-off edge cases.

Common Mistakes

  • Patching the wrong import path. patch("openai.OpenAI") only works if the code under test looks up OpenAI through the openai module at call time; if it was imported with from openai import OpenAI, the name to patch is the one inside the consuming module.
  • Over-mocking until the test verifies nothing real. A MagicMock() accepts any attribute access without complaint, so a typo like fake_client.repsonses.create silently returns another mock instead of failing — always assert on the specific calls and values that matter, not just that "something" was returned.
  • Forgetting that MagicMock accepts wrong arguments silently. Calling a mocked method with the wrong keyword arguments does not raise an error the way a real client library or a well-built fake with a fixed signature would, so a test can pass even though the real call would fail — configuring fakes with spec=RealClass (via MagicMock(spec=OpenAI)) constrains the mock to only the real object's actual attributes, catching this category of mistake.

Best Practices

  • Prefer dependency injection with a hand-written fake for anything tested repeatedly, and reserve patch/monkeypatch for code you cannot easily refactor to accept an injected client.
  • Use spec= (or spec_set=) when creating a MagicMock that stands in for a real class, so the mock raises an AttributeError on typos or nonexistent methods instead of silently succeeding.
  • Always assert on both the return value and the call arguments when the correctness of the request matters, not just the final output your function produces.

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 Mocking API Responses in Python Tests and get answers drawn from it.

Signed-in readers only.