Python Mock 模拟与打桩:隔离外部依赖

FreeGuideOnline 最新 2026-06-16

什么是 Mock 与打桩

在单元测试中,我们习惯性地希望每个测试都是独立、可重复且快速的。但现实中的代码常常依赖于外部资源:网络API、数据库、文件系统、时间函数,甚至是尚未开发完成的模块。这些外部依赖会让测试变得缓慢、不稳定,甚至难以构造特定的边界条件。

Mock(模拟)与打桩(Stubbing) 正是为了解决这一问题而产生的技术。简单来说:

  • 打桩(Stub):用一个固定的、预定义的返回值替代真实的对象或函数,它的唯一作用就是提供你指定的数据,不关心是否被调用、调用次数等。
  • 模拟(Mock):更智能的“替身”,不仅能返回预定义的值,还能记录自己被调用的方式、次数、参数等信息,供你在测试中进行断言验证。

Python 的标准库 unittest.mock 提供了一个强大且灵活的模拟框架。本文将以 Python 的 unittest.mock 为核心,为你讲解如何利用 Mock 与打桩隔离外部依赖,编写干净、高速的单元测试。

核心对象:Mock 与 MagicMock

unittest.mock 模块中最基础的两个类是 MockMagicMock

Mock

Mock 是一个万能对象,你可以把它想象成一块“橡皮泥”。你可以在 Mock 对象上任意访问属性、调用方法,它们都会自动创建并返回一个新的 Mock 对象,且不会引发 AttributeErrorMock 对象会忠实地记录所有操作,方便后续验证。

from unittest.mock import Mock

# 创建一个 Mock 对象
fake_func = Mock()

# 你可以任意调用它,传入任何参数,它都不会报错
result = fake_func(10, "hello", key="value")
print(result)        # 返回另一个 Mock 对象

# 调用后,Mock 记录了你所有的操作
print(fake_func.called)              # True
print(fake_func.call_count)          # 1
print(fake_func.call_args)           # call(10, 'hello', key='value')

MagicMock

MagicMockMock 的子类,它额外实现了 Python 的大部分“魔术方法”(如 __len____str____iter__ 等)。当你需要模拟一个具有容器、上下文管理器等特殊行为的对象时,MagicMock 会让你省去大量手动配置的麻烦。

from unittest.mock import MagicMock

mock_obj = MagicMock()
mock_obj.__len__.return_value = 5
assert len(mock_obj) == 5

mock_obj.__getitem__.return_value = "item"
assert mock_obj[0] == "item"

日常使用建议:除非你明确只需要最基础的属性访问与调用记录,否则默认使用 MagicMock 可以避免很多意外。

模拟返回值与异常

通过配置 Mockreturn_valueside_effect,你可以精确控制模拟行为。

固定返回值:return_value

return_value 设置为一个具体的值,调用模拟对象时就会始终返回该值。

from unittest.mock import Mock

calculator = Mock()
calculator.add.return_value = 10

print(calculator.add(2, 3))   # 10
print(calculator.add(100, 1)) # 10,参数被忽略

动态行为:side_effect

side_effect 远比 return_value 强大。它可以被设置为:

  • 一个可调用对象:该对象将接收调用时的参数,并返回真实结果,用于模拟有逻辑的函数。
  • 一个异常类或实例:模拟调用时抛出异常。
  • 一个可迭代对象:每次调用依次返回可迭代对象中的一个元素。
from unittest.mock import Mock

# 模拟真实函数行为
def fake_add(a, b):
    return a + b

mock = Mock()
mock.side_effect = fake_add
print(mock(3, 5))   # 8

# 模拟抛出异常
mock.side_effect = KeyError("missing key")
# mock()   # 会抛出 KeyError

# 逐次返回不同的值
mock.side_effect = [1, 2, 3]
print(mock())   # 1
print(mock())   # 2
print(mock())   # 3

临时替换对象:patch

patchunittest.mock 中最常用的工具。它可以在测试期间临时性地将目标对象替换为模拟对象,测试结束后自动恢复,不会污染其他测试。

patch 可以以装饰器或者上下文管理器的形式使用。

装饰器用法

适合一整个测试函数都需要模拟依赖的场景。

from unittest.mock import patch

# 假设我们要测试一个调用外部API的函数
def get_user_name(user_id):
    # 真实环境会发起HTTP请求
    response = requests.get(f"https://api.example.com/users/{user_id}")
    return response.json()["name"]

@patch("mymodule.requests.get")   # 注意:patch的路径是 *导入后的* 模块路径
def test_get_user_name(mock_get):
    # 配置模拟对象的返回值
    mock_get.return_value.json.return_value = {"name": "Alice"}

    name = get_user_name(1)

    assert name == "Alice"
    mock_get.assert_called_once_with("https://api.example.com/users/1")

上下文管理器用法

适合只在部分代码块中需要替换依赖的场景。

from unittest.mock import patch

def process_data():
    with open("/etc/config") as f:
        return f.read()

def test_process_data():
    with patch("builtins.open") as mock_open:
        mock_open.return_value.__enter__.return_value.read.return_value = "mock content"
        result = process_data()
        assert result == "mock content"

patch.object

当只想替换对象的某个具体方法时,使用 patch.object 更加直观。

from unittest.mock import Mock, patch

class Database:
    def connect(self):
        pass
    def query(self, sql):
        pass

def test_query():
    db = Database()
    with patch.object(db, "query", return_value=["row1", "row2"]) as mock_query:
        result = db.query("SELECT * FROM users")
        assert result == ["row1", "row2"]
        mock_query.assert_called_once_with("SELECT * FROM users")

验证调用细节

模拟对象不仅仅是被动返回预先设定的值,更重要的是让我们能够断言依赖是如何被我们的代码调用的。这确保了业务逻辑的正确性。

常用断言方法:

  • assert_called() / assert_not_called():是否被调用过。
  • assert_called_once():是否恰好被调用一次。
  • assert_called_with(*args, **kwargs):最近一次调用是否使用了指定参数。
  • assert_called_once_with(*args, **kwargs):是否恰好调用一次,且参数匹配。
  • assert_any_call(*args, **kwargs):在调用历史中是否存在过指定参数的一次调用。
  • assert_has_calls(calls, any_order=False):调用序列是否包含指定的调用列表。
from unittest.mock import Mock

callback = Mock()
callback("setup", 1)
callback("process", 2)
callback("finish", 3)

callback.assert_any_call("process", 2)  # 通过

# 验证调用顺序(严格模式)
from unittest.mock import call
expected_calls = [call("setup", 1), call("process", 2), call("finish", 3)]
callback.assert_has_calls(expected_calls)  # 通过

高级控制与常见模式

模拟属性

Mock 对象上的任何属性访问都会生成一个子 Mock,但你可以直接为它们赋值来固定属性值。

mock_config = Mock()
mock_config.api_key = "test_key"
mock_config.timeout = 10

print(mock_config.api_key)   # test_key
print(mock_config.timeout)   # 10

spec 与 autospec:防止过度模拟

当你模拟一个对象时,可能会误调用不存在的方法,导致测试通过但实际代码运行失败。通过设置 specautospec 参数,可以创建一个拥有原始对象接口的 Mock,当访问不存在的方法或属性时会抛出 AttributeError

class Calculator:
    def add(self, a, b):
        return a + b

# 使用 spec 保证模拟对象只允许访问 Calculator 存在的属性
mock_calc = Mock(spec=Calculator)
mock_calc.add(1, 2)       # 正常运行
# mock_calc.subtract(1, 2) # 抛出 AttributeError,因为 Calculator 没有 subtract 方法

patch 装饰器支持 autospec=True,会自动推断被替换对象的规格。

@patch("mymodule.Calculator", autospec=True)
def test_calculator(MockCalculator):
    instance = MockCalculator.return_value
    instance.add.return_value = 5
    # 如果代码中误调用了 instance.subtract(),这里会暴露问题

模拟异步函数

如果你的代码使用了 async/awaitunittest.mock 提供了 AsyncMock(Python 3.8+)可以轻松模拟协程。

from unittest.mock import AsyncMock, patch
import asyncio

async def fetch_data():
    async with aiohttp.ClientSession() as session:
        async with session.get("url") as resp:
            return await resp.json()

@patch("mymodule.aiohttp.ClientSession.get", new_callable=AsyncMock)
async def test_fetch_data(mock_get):
    mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value={"key": "value"})
    result = await fetch_data()
    assert result == {"key": "value"}

实战:隔离外部依赖的完整示例

考虑一个简单的服务类,它从远程API获取用户资料,并写入本地数据库。

# user_service.py
import requests
import sqlite3

class UserService:
    def __init__(self, api_base):
        self.api_base = api_base

    def fetch_and_save_user(self, user_id, db_path):
        # 外部依赖1: HTTP请求
        response = requests.get(f"{self.api_base}/users/{user_id}", timeout=5)
        user_data = response.json()

        # 外部依赖2: 数据库写入
        conn = sqlite3.connect(db_path)
        cursor = conn.cursor()
        cursor.execute(
            "INSERT INTO users (id, name, email) VALUES (?, ?, ?)",
            (user_data["id"], user_data["name"], user_data["email"])
        )
        conn.commit()
        conn.close()
        return user_data

我们希望通过测试验证 fetch_and_save_user 能正确解析响应,并构建正确的SQL语句,而不需要实际网络和数据库。

# test_user_service.py
from unittest.mock import patch, MagicMock, call
from user_service import UserService

def test_fetch_and_save_user():
    service = UserService(api_base="https://fakeapi.com")

    # 准备模拟对象
    fake_user = {
        "id": 42,
        "name": "Mock User",
        "email": "mock@example.com"
    }

    # 1. 模拟 requests.get
    mock_response = MagicMock()
    mock_response.json.return_value = fake_user

    # 2. 模拟 sqlite3.connect
    mock_connection = MagicMock()
    mock_cursor = MagicMock()
    mock_connection.cursor.return_value = mock_cursor

    # 使用 patch 替换外部依赖
    with patch("user_service.requests.get", return_value=mock_response) as mock_get, \
         patch("user_service.sqlite3.connect", return_value=mock_connection) as mock_connect:

        result = service.fetch_and_save_user(42, "/fake/db.sqlite")

        # 验证返回值
        assert result == fake_user

        # 验证 HTTP 调用
        mock_get.assert_called_once_with("https://fakeapi.com/users/42", timeout=5)

        # 验证数据库操作
        mock_connect.assert_called_once_with("/fake/db.sqlite")
        mock_cursor.execute.assert_called_once_with(
            "INSERT INTO users (id, name, email) VALUES (?, ?, ?)",
            (42, "Mock User", "mock@example.com")
        )
        mock_connection.commit.assert_called_once()
        mock_connection.close.assert_called_once()

在这个测试中,没有发送任何网络请求,也没有创建真实数据库,但我们对代码的行为建立了充分的信心。

最佳实践与常见陷阱

  1. 模拟的是“接口”,不是“实现”
    只模拟你所依赖的外部边界,不要模拟被测代码内部的对象。过度模拟会导致测试脆弱且毫无价值。

  2. 始终指定 autospec 或 spec
    这可以避免你模拟出一个实际不存在的接口,及早发现设计缺陷。在大型项目中,它几乎是必须的。

  3. 优先使用 patch 的上下文管理器,而非装饰器
    上下文管理器让你的替换范围更明确,且测试函数签名更简洁。但当某个模拟行为贯穿整个测试时,装饰器更合适。

  4. 不要滥用 Mock,警惕“绿色测试”陷阱
    如果你发现自己模拟了十几个对象来测试一个函数,可能是设计本身存在问题,函数承担了太多职责,是时候重构了。

  5. 模拟内置函数和标准库
    模拟 opendatetimetime 等时要格外小心,因为它们被大量底层模块使用,可能产生意料之外的副作用。通常建议使用更专用的库(如 freezegun 模拟时间,或模拟更高层级的依赖)。

  6. 保持模拟配置的最小化与显式化
    不要在测试里堆积大量不相关的 mock.return_value 链,每一个配置都应当对应被测代码中实际触发的路径。多余的配置往往掩盖了未被执行的代码分支。

从打桩到模拟,再到行为验证,unittest.mock 为我们提供了完整的工具链。掌握这些技巧,你就能编写出快速、稳定且能真实反映业务逻辑的测试,让外部依赖不再是测试的阻碍。