41·模块九:测试与调试高级

Mock 测试与模拟对象

testingmockingunittest.mock

第 41 课 - Mock 测试与模拟对象

学习目标

完成本课学习后,你将能够:

  1. 理解 Mock 的概念与作用:明白为什么在单元测试中需要模拟对象,以及它如何帮助我们隔离被测试的代码。
  2. 掌握 unittest.mock 的核心工具:熟练使用 Mock, MagicMock, patch 装饰器/上下文管理器。
  3. 为外部依赖编写测试:学会模拟网络请求、数据库查询、文件系统等外部交互,使测试快速、可靠且无副作用。
  4. 验证交互行为:使用 assert_called_* 系列方法检查模拟对象是否被正确调用。

核心概念

1. 什么是 Mock?

想象你在拍摄一部动作电影。主角(你的代码)要完成一场复杂的打斗戏(复杂的业务逻辑)。但在拍摄主角打一个反派(调用一个外部服务或复杂组件)的特写镜头时,导演不会真的让主角去打演员。他们会用一个"替身"或"假人"(Mock 对象)来代替,这样可以专注于拍摄主角的动作和表情,而不受替身演员自身因素(如真实打斗的不可预测性、伤害风险、拍摄进度)的影响。

在编程中,Mock(模拟)对象 就是代码中的"替身"。它用于替代真实的、复杂的、不可靠的或依赖外部环境的组件(如数据库、网络API、文件系统、硬件设备),以便我们能独立、快速、可控地测试我们的核心逻辑。

2. 为什么需要 Mock?

  • 隔离测试:我们只想测试"主角"(一个函数或类)的逻辑是否正确,而不关心"替身"(外部依赖)是否正常工作。
  • 提高速度:真实的数据库查询或网络请求可能很慢。用 Mock 可以瞬间返回预设结果。
  • 测试难以触发的情况:如何模拟一个网络连接失败或一个特定的错误码?Mock 可以轻松设置。
  • 避免副作用:测试不应真的发送邮件、修改数据库或支付款项。Mock 可以"假装"做了这些事。

3. Python 的 Mock 工具箱:unittest.mock

Python 的标准库 unittest.mock 提供了强大的 Mock 功能。核心成员包括:

  • Mock:一个基础的模拟对象。你访问它的任何属性或调用它的任何方法,它都会返回一个新的 Mock 实例,并记录下这次访问/调用。
  • MagicMockMock 的子类,更强大。它对 Python 的魔术方法(如 __str__, __len__)提供了默认实现,能更好地模拟真实对象的行为。
  • patch:这是最常用、最核心的工具。它用于临时替换("打补丁")测试作用域内的某个对象(如一个类、一个函数)为一个 Mock 对象,测试结束后自动恢复原样。

代码示例

假设我们有一个模块 weather_service.py,它包含一个函数,用于从真实的网络API获取天气信息。

weather_service.py (被测试的代码)

import requests

def get_city_temperature(city: str) -> float:
    """从天气API获取指定城市的温度。"""
    # 假设这是一个真实的API调用
    response = requests.get(f"http://weather-api.example.com?city={city}")
    response.raise_for_status()  # 如果请求失败则抛出异常
    data = response.json()
    return data['temperature']

现在我们要测试另一个模块 app.py 中使用了 get_city_temperature 的函数。

app.py (我们的业务逻辑)

from weather_service import get_city_temperature

def check_weather_alert(city: str, threshold: float = 35.0) -> str:
    """检查城市温度是否超过阈值,并返回警告信息。"""
    current_temp = get_city_temperature(city)
    if current_temp > threshold:
        return f"警告:{city}温度{current_temp}℃,已超过{threshold}℃!"
    else:
        return f"{city}天气正常,温度{current_temp}℃。"

问题:我们如何测试 check_weather_alert 函数?直接调用它会发起真实的网络请求,这会使测试变慢、依赖外部服务、且无法稳定测试"高温警告"的场景。

解决方案:使用 unittest.mock 来模拟 get_city_temperature 函数。

test_app.py (测试文件)

import unittest
from unittest.mock import patch, MagicMock
from app import check_weather_alert

class TestCheckWeatherAlert(unittest.TestCase):

    # 使用 @patch 装饰器,在运行测试方法期间,将 'app.get_city_temperature' 替换为一个 Mock 对象。
    # 替换后的对象(mock_temp_service)会作为第一个参数注入到测试方法中。
    @patch('app.get_city_temperature')
    def test_high_temperature_returns_warning(self, mock_temp_service):
        # 设置 Mock 的返回值。模拟函数返回一个高温值。
        mock_temp_service.return_value = 36.5

        # 调用被测试的函数,它现在会使用我们的 Mock 而不是真实的服务。
        result = check_weather_alert("北京")

        # 断言:检查业务逻辑是否正确处理了高温情况。
        self.assertIn("警告", result)
        self.assertIn("36.5", result)
        # 断言:检查我们的 Mock 函数是否被以正确的参数调用了一次。
        mock_temp_service.assert_called_once_with("北京")

    @patch('app.get_city_temperature')
    def test_normal_temperature_returns_normal_message(self, mock_temp_service):
        mock_temp_service.return_value = 25.0
        result = check_weather_alert("上海")
        self.assertIn("正常", result)
        self.assertIn("25.0", result)
        mock_temp_service.assert_called_once_with("上海")

    # 我们甚至可以测试当天气服务抛出异常时,我们的应用如何处理。
    @patch('app.get_city_temperature')
    def test_service_unavailable(self, mock_temp_service):
        # 设置 Mock 在被调用时抛出一个 ConnectionError 异常。
        mock_temp_service.side_effect = ConnectionError("网络连接失败")
        
        # 这里需要测试我们的代码是否有适当的错误处理(例如返回一个友好的错误信息)。
        # 假设我们的 check_weather_alert 没有处理这个异常,那么它会将异常向上抛出。
        # 我们可以用 assertRaises 来验证这一点。
        with self.assertRaises(ConnectionError):
            check_weather_alert("广州")

if __name__ == '__main__':
    unittest.main()

运行测试python -m unittest test_app -v

实践练习

练习 1:基础 Mocking

  1. 创建一个函数 get_random_color(),它模拟调用一个不存在的外部颜色库,并返回 "red"。
  2. 编写测试,使用 @patch 装饰器来模拟这个外部调用,验证你的函数是否被正确调用并返回了预设值。 提示:你需要模拟的可能是一个模块中的函数,例如 my_colors_lib.get_random

练习 2:模拟文件操作

你有一个函数 read_config(filename),它使用 Python 内置的 open() 读取配置文件。写一个测试,在不创建真实文件的情况下,模拟 open() 函数返回一个特定的字符串内容。 预期输出:测试通过,且没有真实的文件 I/O 操作发生。 关键:如何用 @patch('builtins.open', ...) 来模拟内置函数?

练习 3:复杂场景 - 模拟类的方法

假设你有一个 PaymentProcessor 类,其 charge_user(user_id, amount) 方法内部会调用第三方支付API StripeClient.charge(amount)。写一个测试,模拟 StripeClient 的实例及其 charge 方法,以验证你的 PaymentProcessor 是否正确地将参数传递给了支付网关。

常见错误

  1. Patch 的目标位置错误:初学者最常犯的错误。你必须 patch 你代码使用它的位置,而不是它定义的位置。
    • 错误:@patch('weather_service.get_city_temperature') (在 app.py 的测试中)
    • 正确:@patch('app.get_city_temperature') (因为 app.pyweather_service 导入了它)
  2. 过度 Mock:为了一行简单的、确定性的代码(如 math.sqrt)也去创建 Mock,这增加了测试的复杂度而无实际收益。只 Mock 你真正需要隔离的边界(I/O、外部服务、复杂依赖)。
  3. 忘记重置/清理 Mock 状态:虽然使用 @patch 装饰器会自动处理,但在某些复杂场景(如在循环中多次 patch)下,手动管理 Mock 的状态(如调用 reset_mock())很重要,以免上次测试的调用记录影响到下一次测试的断言。
  4. 不验证 Mock 的交互:仅仅 Mock 了一个函数并设置了返回值,但没有用 assert_called_* 系列方法去验证它是否真的被以正确的方式调用过。这可能隐藏了重要的集成问题。

小结

本节课我们深入探索了软件测试中的强大武器——Mock 测试。核心要点如下:

  • 目的:Mock 用于隔离被测试代码,使其与外部依赖(网络、数据库、文件系统等)解耦,从而实现快速、可靠、无副作用的单元测试。
  • 核心工具
    • Mock/MagicMock:创建模拟对象,用于替代真实对象。
    • patch:上下文管理器或装饰器,用于临时替换代码中的某个对象为 Mock,是使用最广泛的工具。
  • 关键实践
    • 使用 return_value 设置 Mock 的返回值。
    • 使用 side_effect 设置 Mock 调用时引发的异常或执行其他逻辑。
    • 使用 assert_called_with, assert_called_once_with 等方法验证 Mock 是否被正确调用。
  • 注意事项:正确选择 patch 的目标位置,避免过度 Mock,并确保测试的独立性。

掌握了 Mock 技术,你便拥有了编写高质量单元测试的关键能力,能够自信地为各种复杂的、依赖外部环境的代码编写测试。这为我们下一课学习更底层的调试工具(如 pdb)打下了坚实的基础。

练习编辑器

python
Loading...

继续学习

完成本课后,建议继续学习下一课「调试工具(pdb, IDE调试)」 以巩固所学知识。