Python单元测试Mock实战:pytest-mock五大高频场景解析

📅 2026/7/17 4:39:10
Python单元测试Mock实战:pytest-mock五大高频场景解析
1. 项目概述为什么我们需要pytest-mock在软件测试的世界里尤其是单元测试我们经常遇到一个棘手的问题如何测试一个依赖于外部服务的函数比如你的函数需要调用一个数据库查询、一个第三方API接口或者一个复杂的文件系统操作。直接测试意味着你需要搭建一个完整的、真实的外部环境这往往费时费力且测试结果不稳定网络波动、数据库状态变化都会影响测试。更糟糕的是你无法模拟一些异常情况比如API返回500错误或者数据库连接超时。这时Mock模拟技术就成了我们的“救星”。它的核心思想是“替换”——用一个可控的、假的替身对象替换掉测试中那些不可控的、慢的、或者有副作用的外部依赖。这样测试就能聚焦于被测函数本身的逻辑变得快速、稳定且可重复。pytest-mock是pytest测试框架的一个插件它提供了一个名为mocker的 Fixture让我们能够以一种非常简洁、符合pytest风格的方式来使用 Python 标准库中的unittest.mock。它极大地简化了 Mock 对象的创建、注入和断言过程。简单来说pytest-mock让你写 Mock 测试像写普通测试一样自然。本教程不会重复官方文档的基础语法而是直接切入五个在真实开发中高频出现的场景。我会带你一步步拆解问题展示如何用pytest-mock优雅地解决并分享我在实践中踩过的坑和总结的技巧。无论你是刚接触 Mock 的新手还是想提升测试代码质量的老手相信都能从中获得启发。2. 场景一模拟第三方API调用如支付接口这是 Mock 最经典的用武之地。假设我们有一个处理订单支付的函数process_payment它内部调用了第三方支付服务PaymentGateway的charge方法。2.1 问题还原与代码示例我们先来看没有 Mock 的“痛苦”测试方式# payment.py import requests class PaymentGateway: def charge(self, amount, token): 调用真实第三方支付接口 # 这是一个真实的外部HTTP请求 response requests.post( https://api.payment.com/charge, json{amount: amount, token: token}, timeout10 ) response.raise_for_status() return response.json() def process_payment(order_id, amount, user_token): gateway PaymentGateway() try: result gateway.charge(amount, user_token) if result[status] success: return {order_id: order_id, status: paid, txn_id: result[txn_id]} else: return {order_id: order_id, status: failed, error: result[message]} except requests.exceptions.RequestException as e: return {order_id: order_id, status: error, error: str(e)}直接测试这个函数你需要有有效的支付令牌、可访问的第三方服务并且测试会产生真实的金融交易这显然不可行。2.2 使用pytest-mock进行模拟现在我们用pytest-mock来改造测试。核心是使用mocker.patch来替换PaymentGateway.charge方法。# test_payment.py import pytest from payment import process_payment def test_process_payment_success(mocker): 测试支付成功的场景。 # 1. 创建 Mock 对象模拟 charge 方法 mock_charge mocker.patch(payment.PaymentGateway.charge) # 2. 配置 Mock 的行为让它返回一个模拟的成功响应 mock_charge.return_value { status: success, txn_id: mock_txn_123456 } # 3. 执行被测函数 result process_payment(order_001, 100.0, fake_token_abc) # 4. 断言函数返回结果符合预期 assert result[order_id] order_001 assert result[status] paid assert result[txn_id] mock_txn_123456 # 5. 断言 Mock 方法被以预期的参数调用 mock_charge.assert_called_once_with(100.0, fake_token_abc) def test_process_payment_api_failure(mocker): 测试第三方API返回业务逻辑失败如余额不足。 mock_charge mocker.patch(payment.PaymentGateway.charge) mock_charge.return_value { status: failed, message: Insufficient funds } result process_payment(order_002, 200.0, fake_token_def) assert result[status] failed assert Insufficient funds in result[error] mock_charge.assert_called_once_with(200.0, fake_token_def) def test_process_payment_network_error(mocker): 测试网络请求异常如超时的场景。 import requests mock_charge mocker.patch(payment.PaymentGateway.charge) # 使用 side_effect 来模拟方法抛出异常 mock_charge.side_effect requests.exceptions.Timeout(Connection timed out) result process_payment(order_003, 50.0, fake_token_ghi) assert result[status] error assert Connection timed out in result[error]2.3 核心技巧与避坑指南技巧一理解mocker.patch的目标字符串mocker.patch(‘payment.PaymentGateway.charge’)中的字符串参数是关键。它遵循“导入路径”原则。因为我们在test_payment.py中from payment import process_payment而在process_payment内部创建了PaymentGateway实例并调用其charge方法。所以我们需要 Mock 的是payment模块里的PaymentGateway类的charge方法。如果你在payment.py中通过from some_lib import PaymentGateway导入那么目标字符串可能就是‘payment.some_lib.PaymentGateway.charge’。一个快速定位的方法是查看被测函数运行时实际查找该方法的命名空间路径。技巧二return_valuevsside_effectreturn_value当你想让 Mock 方法每次调用都返回一个固定的值或对象时使用。side_effect功能更强大。它可以是一个异常类或实例让 Mock 方法被调用时抛出该异常如上例模拟网络错误。它也可以是一个可迭代对象如列表Mock 方法每次被调用会依次返回其中的元素。它还可以是一个函数根据传入的参数动态决定返回值。避坑点Mock 的生效范围mocker.patch默认只在当前测试函数内生效。这是好事保证了测试之间的隔离。但如果你需要在一个类或模块的所有测试中共享同一个 Mock可以使用mocker.patch作为装饰器或配合pytest.fixture(scope‘class’)使用。不过我强烈建议保持 Mock 的局部性避免测试间不可预见的相互影响。3. 场景二模拟数据库查询如SQLAlchemy SessionWeb 应用后端测试中数据库操作是另一个需要被 Mock 的重灾区。我们不想在跑单元测试时真的去读写数据库。3.1 问题还原与代码示例假设我们有一个用户服务通过 SQLAlchemy 的 Session 来查询用户。# user_service.py from sqlalchemy.orm import Session from models import User def get_user_by_email(db_session: Session, email: str) - User: 根据邮箱获取用户如果不存在则返回None。 user db_session.query(User).filter(User.email email).first() return user def create_user_if_not_exists(db_session: Session, email: str, name: str) - User: 如果用户不存在则创建。 user get_user_by_email(db_session, email) if user is None: new_user User(emailemail, namename) db_session.add(new_user) db_session.commit() db_session.refresh(new_user) user new_user return user3.2 使用pytest-mock模拟SQLAlchemy链式调用模拟 SQLAlchemy 的链式调用如session.query(...).filter(...).first()需要一点技巧因为我们需要 Mock 一个调用链。# test_user_service.py import pytest from user_service import get_user_by_email, create_user_if_not_exists from models import User from unittest.mock import Mock def test_get_user_by_email_found(mocker): 测试找到用户的情况。 # 1. 创建一个模拟的 User 对象 mock_user Mock(specUser) mock_user.id 1 mock_user.email testexample.com mock_user.name Test User # 2. 模拟链式调用query().filter().first() # 首先模拟 session.query(User) 的返回值 mock_query mocker.Mock() # 然后模拟 .filter(...) 的返回值它本身也是一个 Mock mock_filter mocker.Mock() # 最后模拟 .first() 的返回值是我们预设的 mock_user mock_filter.first.return_value mock_user # 将 filter 方法链接到 query 上 mock_query.filter.return_value mock_filter # 3. 模拟 db_session 本身并让其 query 方法返回我们构建的 mock_query mock_session mocker.Mock(specSession) mock_session.query.return_value mock_query # 4. 执行测试 result get_user_by_email(mock_session, testexample.com) # 5. 断言 assert result is mock_user # 验证调用链session.query 被以 User 为参数调用 mock_session.query.assert_called_once_with(User) # 验证 filter 被以正确的条件调用 (User.email ‘testexample.com’) # 注意SQLAlchemy filter 的参数是一个二进制表达式对象我们这里用 mocker.ANY 来匹配任何参数 mock_query.filter.assert_called_once_with(mocker.ANY) # 更精确的断言可以检查 filter 的调用参数 call_arg mock_query.filter.call_args[0][0] # 这里可以进一步断言 call_arg 的左右部分但通常对于单元测试验证调用发生即可 mock_filter.first.assert_called_once() def test_get_user_by_email_not_found(mocker): 测试未找到用户的情况。 mock_query mocker.Mock() mock_filter mocker.Mock() mock_filter.first.return_value None # 模拟 first() 返回 None mock_query.filter.return_value mock_filter mock_session mocker.Mock(specSession) mock_session.query.return_value mock_query result get_user_by_email(mock_session, notfoundexample.com) assert result is None def test_create_user_if_not_exists_new_user(mocker): 测试创建新用户的场景。 这里需要模拟 get_user_by_email 返回 None以及 session 的 add, commit, refresh 方法。 # 模拟 get_user_by_email 返回 None mocker.patch(user_service.get_user_by_email, return_valueNone) mock_session mocker.Mock(specSession) # 模拟新创建的用户对象 new_mock_user Mock(specUser) # 关键模拟 User 类的构造函数使其返回我们的 mock 对象 # 注意这里 Mock 的是当前测试文件视角下的 ‘models.User’ 类 mock_user_class mocker.patch(user_service.User) mock_user_class.return_value new_mock_user result create_user_if_not_exists(mock_session, newexample.com, New User) # 断言调用了 session.add 和 session.commit mock_session.add.assert_called_once_with(new_mock_user) mock_session.commit.assert_called_once() mock_session.refresh.assert_called_once_with(new_mock_user) assert result is new_mock_user # 验证 User 构造函数被以正确的参数调用 mock_user_class.assert_called_once_with(emailnewexample.com, nameNew User)3.3 核心技巧与避坑指南技巧一使用spec或autospec提高 Mock 安全性在上面的例子中我们创建 Mock 时使用了specUser或specSession。spec参数告诉 Mock 对象它应该“模仿”哪个真实的类或对象。这样如果你不小心访问了该真实对象不存在的属性或方法Mock 会抛出AttributeError而不是默默地返回一个新的 Mock 对象。这能及早发现笔误让测试更可靠。mocker.create_autospec是更强大的方式它会自动根据提供的 spec 对象复制其整个接口。技巧二Mock 链式调用的构建顺序模拟a.b().c()这样的链式调用需要从内向外构建。先创建c的 Mock 并设置其返回值再创建b的 Mock 并设置其返回值即调用b()返回的对象的c方法最后 Mocka的b属性。理解这个顺序是关键。避坑点注意导入和 Mock 的路径在test_create_user_if_not_exists_new_user中我们 Mock 的是‘user_service.User’而不是‘models.User’。因为被测函数create_user_if_not_exists是在user_service模块内部执行的它内部通过from models import User导入了User类。所以在user_service模块的命名空间里User指向的就是我们导入的那个类。Mock 这个路径才能生效。这是一个非常常见的错误点务必确保 Mock 的路径是被测代码实际查找的路径。4. 场景三模拟时间与随机数datetime, random有些函数的行为依赖于当前时间如生成订单号、检查缓存过期或随机数如分配随机奖励。为了让测试可重复我们必须能够“冻结”时间或“固定”随机种子。4.1 问题还原与代码示例# utils.py import datetime import random import string def generate_order_id(prefixORD): 生成带时间戳的订单ID格式: ORD-YYYYMMDD-HHMMSS-XXXX (XXXX为随机码) now datetime.datetime.now() timestamp now.strftime(%Y%m%d-%H%M%S) random_suffix .join(random.choices(string.digits, k4)) return f{prefix}-{timestamp}-{random_suffix} def is_cache_valid(cached_time, ttl_seconds300): 检查缓存是否在有效期内。 now datetime.datetime.now() elapsed (now - cached_time).total_seconds() return elapsed ttl_seconds4.2 使用pytest-mock冻结datetime.now()我们可以 Mockdatetime.datetime类使其now方法返回一个我们指定的固定时间。# test_utils.py import pytest import datetime from utils import generate_order_id, is_cache_valid def test_generate_order_id_fixed_time(mocker): 测试在固定时间下生成订单ID。 # 1. 创建一个固定的 datetime 对象 fixed_now datetime.datetime(2023, 10, 27, 14, 30, 15) # 2. Mock datetime.datetime.now使其返回我们的固定时间 # 注意目标是 ‘utils.datetime.datetime’因为函数是在 utils 模块中调用 now 的。 mock_now mocker.patch(utils.datetime.datetime) mock_now.now.return_value fixed_now # 3. 为了固定随机数我们也可以 Mock random.choices mock_choices mocker.patch(utils.random.choices) mock_choices.return_value [1, 2, 3, 4] # 固定返回 ‘1234’ result generate_order_id() # 4. 断言结果符合预期 assert result ORD-20231027-143015-1234 # 验证 now() 被调用 mock_now.now.assert_called_once() # 验证 random.choices 被以正确的参数调用 mock_choices.assert_called_once_with(string.digits, k4) def test_generate_order_id_different_prefix(mocker): 测试不同的前缀。 mocker.patch(utils.datetime.datetime.now).return_value datetime.datetime(2023, 1, 1, 0, 0, 0) mocker.patch(utils.random.choices, return_value[0, 0, 0, 0]) result generate_order_id(prefixINV) assert result INV-20230101-000000-0000 def test_is_cache_valid_valid(mocker): 测试缓存有效的情况。 fixed_now datetime.datetime(2023, 10, 27, 14, 30, 0) mocker.patch(utils.datetime.datetime.now).return_value fixed_now # 缓存时间是 5 分钟前 (300秒TTL刚好有效) cached_time fixed_now - datetime.timedelta(seconds299) assert is_cache_valid(cached_time) is True def test_is_cache_valid_expired(mocker): 测试缓存过期的情况。 fixed_now datetime.datetime(2023, 10, 27, 14, 30, 0) mocker.patch(utils.datetime.datetime.now).return_value fixed_now # 缓存时间是 5 分零1秒前 (已过期) cached_time fixed_now - datetime.timedelta(seconds301) assert is_cache_valid(cached_time) is False def test_is_cache_valid_custom_ttl(mocker): 测试自定义TTL。 fixed_now datetime.datetime(2023, 10, 27, 14, 30, 0) mocker.patch(utils.datetime.datetime.now).return_value fixed_now cached_time fixed_now - datetime.timedelta(seconds59) # TTL 设为 60 秒此时应有效 assert is_cache_valid(cached_time, ttl_seconds60) is True # TTL 设为 30 秒此时应过期 assert is_cache_valid(cached_time, ttl_seconds30) is False4.3 核心技巧与避坑指南技巧一精确的 Mock 目标路径再次强调路径的重要性。datetime是 Python 内置模块但在我们的utils模块中我们通过import datetime引入了它。所以在utils.py文件中datetime.datetime.now()的查找路径是utils模块内的datetime属性。因此Mock 的目标是‘utils.datetime.datetime’。如果你在函数内部直接from datetime import datetime那么 Mock 目标就变成了‘utils.datetime.now’。理解代码的导入方式是正确 Mock 的第一步。技巧二使用freezegun库处理复杂时间场景对于更复杂的时间场景如涉及time.time(),datetime.date.today()等或者你想避免繁琐的路径 Mock我强烈推荐使用第三方库freezegun。它可以像装饰器或上下文管理器一样使用轻松地将时间“冻结”在某个点。import freezegun from utils import generate_order_id freezegun.freeze_time(2023-10-27 14:30:15) def test_generate_order_id_with_freezegun(): # 在这个函数内所有 datetime.now(), time.time() 等都会返回冻结的时间 # 但注意freezegun 可能无法冻结 C 扩展模块中的时间函数对于 random 也需要单独 Mock pass避坑点注意 Mock 的副作用和恢复mocker.patch默认会在测试函数结束后自动恢复被 Mock 的对象。这是pytest-mock的一大优点。但是如果你手动使用unittest.mock.patch或者在某些复杂嵌套中需要确保 Mock 被正确清理否则会影响其他测试。使用mockerfixture 可以省去这个烦恼。5. 场景四模拟文件系统操作如open, os.path读写文件、检查路径是否存在等操作在单元测试中同样需要被隔离。5.1 问题还原与代码示例# file_processor.py import os import json def load_config(config_path): 从JSON文件加载配置。如果文件不存在或格式错误返回空字典。 if not os.path.exists(config_path): return {} try: with open(config_path, r, encodingutf-8) as f: return json.load(f) except (json.JSONDecodeError, UnicodeDecodeError): return {} def process_data_file(input_path, output_path): 处理数据文件假设是一个简单的行数统计并写入新文件。 if not os.path.isfile(input_path): raise FileNotFoundError(fInput file not found: {input_path}) line_count 0 with open(input_path, r, encodingutf-8) as infile: for line in infile: line_count 1 result {file: input_path, line_count: line_count} with open(output_path, w, encodingutf-8) as outfile: json.dump(result, outfile, indent2) return result5.2 使用pytest-mock模拟os.path和open我们将模拟os.path.exists,os.path.isfile以及内置的open函数。# test_file_processor.py import pytest import os import json from unittest.mock import mock_open from file_processor import load_config, process_data_file def test_load_config_file_exists_and_valid(mocker): 测试配置文件存在且JSON有效的情况。 # 1. 模拟 os.path.exists 返回 True mocker.patch(file_processor.os.path.exists, return_valueTrue) # 2. 模拟 open 函数和文件读取内容 mock_config_data {host: localhost, port: 8080} # mock_open 是一个辅助函数用于模拟 open() 返回的文件对象 # read_data 需要是字符串所以我们将字典转为JSON字符串 mock_file mock_open(read_datajson.dumps(mock_config_data)) mocker.patch(builtins.open, mock_file) result load_config(/fake/path/config.json) assert result mock_config_data # 验证 open 被以正确的参数调用 mock_file.assert_called_once_with(/fake/path/config.json, r, encodingutf-8) def test_load_config_file_not_exists(mocker): 测试配置文件不存在的情况。 mocker.patch(file_processor.os.path.exists, return_valueFalse) result load_config(/fake/path/missing.json) assert result {} # 因为 os.path.exists 返回 False所以 open 不应该被调用 # 我们可以断言 open 没有被调用但更简单的方法是依赖 Mock 的默认行为未调用 def test_load_config_file_invalid_json(mocker): 测试配置文件存在但JSON格式无效的情况。 mocker.patch(file_processor.os.path.exists, return_valueTrue) # 模拟文件内容是非JSON字符串 mock_file mock_open(read_data{invalid json) mocker.patch(builtins.open, mock_file) result load_config(/fake/path/bad.json) assert result {} def test_process_data_file_success(mocker): 测试成功处理数据文件。 # 模拟输入文件存在 mocker.patch(file_processor.os.path.isfile, return_valueTrue) # 模拟输入文件内容 input_content line1\nline2\nline3\n mock_input_file mock_open(read_datainput_content) # 模拟输出文件的写入操作我们需要一个可写的 mock mock_output_file mock_open() # 因为 process_data_file 会调用两次 open一次读一次写 # 我们需要让 mock 在第一次调用时返回读的 mock第二次返回写的 mock。 # 我们可以用 side_effect 来实现。 def side_effect_open(file, moder, **kwargs): if mode r: # 返回模拟的读文件对象 return mock_input_file.return_value elif mode w: # 返回模拟的写文件对象 return mock_output_file.return_value mocker.patch(builtins.open, side_effectside_effect_open) result process_data_file(/fake/input.txt, /fake/output.json) assert result {file: /fake/input.txt, line_count: 3} # 验证 os.path.isfile 被以正确的路径调用 mocker.patch(file_processor.os.path.isfile).assert_called_once_with(/fake/input.txt) # 验证写文件的 open 被调用 # 注意open 被调用了两次我们需要检查写的那次调用 open_calls mocker.patch(builtins.open).call_args_list assert len(open_calls) 2 # 检查第二次调用写文件的参数 write_call open_calls[1] assert write_call[0] (/fake/output.json, w) assert write_call[1][encoding] utf-8 # 验证 json.dump 被调用并且写入了正确的内容 # 我们需要获取 mock 文件对象的 write 或 dump 调用情况。这里更简单的方法是断言结果。 # 实际上mock_output_file.return_value.write 会被 json.dump 调用。 def test_process_data_file_input_not_found(mocker): 测试输入文件不存在应抛出异常。 mocker.patch(file_processor.os.path.isfile, return_valueFalse) with pytest.raises(FileNotFoundError, matchfInput file not found: /fake/nonexistent.txt): process_data_file(/fake/nonexistent.txt, /fake/output.json)5.3 核心技巧与避坑指南技巧一使用unittest.mock.mock_openmock_open是专门为模拟open()函数设计的工具。它创建一个 Mock 对象当被调用时会返回一个模拟的文件对象这个文件对象有read(),write(),__enter__(用于 with 语句) 等方法。通过read_data参数可以预设文件内容。技巧二处理多次open调用当一个函数需要打开多个文件如读一个写一个时open会被调用多次。我们可以通过side_effect来根据不同的调用参数如文件路径、模式‘r’/‘w’返回不同的 Mock 文件对象如上例所示。这比简单地 Mock 一个返回值更灵活。技巧三Mock 内置函数open的路径是‘builtins.open’在 Python 中open是内置函数。当你在模块中使用open()时Python 会在内置命名空间builtins中查找它。因此Mock 的目标路径是‘builtins.open’。pytest-mock的mocker对此有很好的支持。避坑点注意文件编码和换行符在模拟open时务必匹配被测代码中使用的参数特别是encoding。如果你的代码用open(..., encoding‘utf-8’)而 Mock 时没有指定可能会导致测试通过但实际运行失败。同样在设置read_data时要注意字符串中的换行符\n是否与测试用例的预期一致。6. 场景五模拟类属性、静态方法及副作用验证有时我们需要 Mock 类的属性包括类属性、实例属性、静态方法或类方法并验证函数执行过程中产生的副作用如是否调用了某个方法调用了多少次参数是什么。6.1 问题还原与代码示例# notification.py import smtplib from email.mime.text import MIMEText class EmailNotifier: # 类属性用于配置 SMTP_SERVER smtp.example.com SMTP_PORT 587 def __init__(self, sender): self.sender sender self._connection_count 0 # 一个内部状态记录连接次数 def _connect(self): 模拟建立SMTP连接这是一个‘私有’方法 self._connection_count 1 # 这里应该是真实的 smtplib.SMTP 连接测试中需要 Mock return smtplib.SMTP(self.SMTP_SERVER, self.SMTP_PORT) staticmethod def format_subject(template, user): 静态方法格式化邮件主题。 return template.format(usernameuser[name]) def send_welcome_email(self, user_email, user_info): 发送欢迎邮件。 subject self.format_subject(Welcome, {username}!, user_info) body fHello {user_info[name]}, welcome to our service. # 建立连接并发送简化版 conn self._connect() # ... 这里省略实际的 login, sendmail, quit 等操作 # 我们只关心 _connect 被调用了并且用到了类属性 print(f[模拟发送] To: {user_email}, Subject: {subject}) return True6.2 使用pytest-mock进行全方位模拟与验证这个例子涵盖了 Mock 实例方法、静态方法、类属性并验证调用情况。# test_notification.py import pytest from notification import EmailNotifier import smtplib def test_send_welcome_email(mocker): 综合测试Mock 静态方法、实例的‘私有’方法、类属性并验证调用。 # 1. 创建真实的 Notifier 实例但内部方法会被 Mock notifier EmailNotifier(senderadminexample.com) # 2. Mock 静态方法 format_subject # 注意目标路径是 ‘notification.EmailNotifier.format_subject’ mock_format mocker.patch(notification.EmailNotifier.format_subject) mock_format.return_value Welcome, Mocked User! # 3. Mock 实例方法 _connect # 我们需要在实例创建后针对这个特定实例的 _connect 方法进行 Mock。 # 使用 mocker.patch.object mock_connect mocker.patch.object(notifier, _connect) # 让 _connect 返回一个 Mock 的 SMTP 连接对象 mock_smtp_conn mocker.Mock() mock_connect.return_value mock_smtp_conn # 4. Mock 类属性通过 Mock 类本身 # 我们可以直接修改类属性但更好的方式是在测试期间临时替换它。 mocker.patch.object(EmailNotifier, SMTP_SERVER, mock-smtp.example.com) mocker.patch.object(EmailNotifier, SMTP_PORT, 2525) # 5. 准备测试数据 test_user {email: usertest.com, name: Test User} test_user_info {name: Test User} # 6. 执行被测方法 result notifier.send_welcome_email(test_user[email], test_user_info) # 7. 进行一系列断言 # 7.1 断言静态方法被以正确的参数调用 mock_format.assert_called_once_with(Welcome, {username}!, test_user_info) # 7.2 断言 _connect 方法被调用了一次 mock_connect.assert_called_once() # 断言 _connect 方法内部使用了被我们修改后的类属性 # 实际上_connect 方法内部使用了 self.SMTP_SERVER 和 self.SMTP_PORT # 由于我们 patch 了类属性所以它看到的是 mock 值。 # 但 _connect 方法本身被 Mock 了所以真实的 smtplib.SMTP 调用并没有发生。 # 如果我们想验证 _connect 的逻辑应该单独测试它。这里我们只验证它被调用了。 # 7.3 断言结果 assert result is True # 7.4 验证内部状态可选如果它是公共API的一部分或我们需要验证副作用 # 注意_connect 被 Mock 了所以真实的 _connection_count 不会增加。 # 如果我们想测试 _connection_count 的逻辑需要单独测试 _connect 方法或者不 Mock 它。 # 这里为了演示我们假设想验证这个副作用我们可以 spy 而不是完全替换 _connect。 # 使用 mocker.spy 来监视方法的调用但不改变其行为或部分改变。 # 让我们换一种方式重写这个测试的一部分来演示 spy。 def test_send_welcome_email_using_spy(mocker): 使用 spy 来监视 _connect 方法的调用同时让其执行原有代码但 Mock 掉 smtplib。 notifier EmailNotifier(senderadminexample.com) # Spy 实例的 _connect 方法 # spy 会包装真实方法记录调用信息但默认会执行原方法。 connect_spy mocker.spy(notifier, _connect) # Mock 静态方法 mocker.patch(notification.EmailNotifier.format_subject, return_valueSpy Subject) # 关键Mock 掉 _connect 方法内部调用的 smtplib.SMTP因为单元测试不能发真实邮件。 mock_smtp_class mocker.patch(notification.smtplib.SMTP) mock_smtp_instance mocker.Mock() mock_smtp_class.return_value mock_smtp_instance # 执行 test_user_info {name: Spy User} result notifier.send_welcome_email(spytest.com, test_user_info) # 断言 assert result is True # 验证 _connect 被调用了一次 assert connect_spy.call_count 1 # 验证 _connect 调用后内部的 _connection_count 增加了这是真实逻辑的副作用 assert notifier._connection_count 1 # 验证 smtplib.SMTP 被以正确的类属性参数调用 mock_smtp_class.assert_called_once_with(EmailNotifier.SMTP_SERVER, EmailNotifier.SMTP_PORT) def test_format_subject_static(): 单独测试静态方法无需 Mock。 result EmailNotifier.format_subject(Hello, {username}!, {name: Alice}) assert result Hello, Alice!6.3 核心技巧与避坑指南技巧一patchvspatch.objectvsspymocker.patch(‘target.path’)最常用用于替换模块、类或函数。mocker.patch.object(target, ‘attribute’)当你想替换一个已有对象类或实例的某个特定属性时使用。比如上例中替换某个实例的_connect方法或者替换类的SMTP_SERVER属性。它比patch更精确不需要写完整的导入路径。mocker.spy(object, ‘attribute’)用于“监视”一个方法或函数。它会让原方法正常执行但同时记录下它的调用次数、参数等信息。非常适合验证副作用同时又不想或不能完全替换原方法逻辑的情况。技巧二验证调用AssertionsMock 对象提供了一系列断言方法来验证其被调用的情况这是单元测试验证“行为”而非“状态”的重要手段assert_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_orderFalse): 验证一系列调用。call_count: 调用次数。技巧三Mock 类属性临时修改类属性可以使用mocker.patch.object(ClassName, ‘ATTR_NAME’, new_value)。测试结束后pytest-mock会自动将其恢复。这比直接修改ClassName.ATTR_NAME new_value更安全避免了测试间的污染。避坑点不要过度 MockMock 是为了隔离不稳定依赖让测试聚焦。但过度 Mock 会导致测试与实现细节耦合过紧变得脆弱实现一改测试就崩。一个原则是只 Mock 真正的“外部依赖”如网络、数据库、文件系统、时间。对于项目内部的其他模块如果它们稳定且快速可以考虑直接使用或进行集成测试。在上面的例子中我们 Mock 了smtplib.SMTP外部但选择直接测试静态方法format_subject内部纯逻辑。