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}, timeout=10 ) 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_effect
return_value:当你想让 Mock 方法每次调用都返回一个固定的值(或对象)时使用。side_effect:功能更强大。它可以是一个异常类或实例,让 Mock 方法被调用时抛出该异常(如上例模拟网络错误)。它也可以是一个可迭代对象(如列表),Mock 方法每次被调用会依次返回其中的元素。它还可以是一个函数,根据传入的参数动态决定返回值。
避坑点:Mock 的生效范围mocker.patch默认只在当前测试函数内生效。这是好事,保证了测试之间的隔离。但如果你需要在一个类或模块的所有测试中共享同一个 Mock,可以使用mocker.patch作为装饰器或配合@pytest.fixture(scope=‘class’)使用。不过,我强烈建议保持 Mock 的局部性,避免测试间不可预见的相互影响。
3. 场景二:模拟数据库查询(如SQLAlchemy Session)
Web 应用后端测试中,数据库操作是另一个需要被 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(email=email, name=name) 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(spec=User) mock_user.id = 1 mock_user.email = 'test@example.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(spec=Session) mock_session.query.return_value = mock_query # 4. 执行测试 result = get_user_by_email(mock_session, 'test@example.com') # 5. 断言 assert result is mock_user # 验证调用链:session.query 被以 User 为参数调用 mock_session.query.assert_called_once_with(User) # 验证 filter 被以正确的条件调用 (User.email == ‘test@example.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(spec=Session) mock_session.query.return_value = mock_query result = get_user_by_email(mock_session, 'notfound@example.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_value=None) mock_session = mocker.Mock(spec=Session) # 模拟新创建的用户对象 new_mock_user = Mock(spec=User) # 关键:模拟 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, 'new@example.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(email='new@example.com', name='New User')3.3 核心技巧与避坑指南
技巧一:使用spec或autospec提高 Mock 安全性在上面的例子中,我们创建 Mock 时使用了spec=User或spec=Session。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(prefix='ORD'): """生成带时间戳的订单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, k=4)) return f'{prefix}-{timestamp}-{random_suffix}' def is_cache_valid(cached_time, ttl_seconds=300): """检查缓存是否在有效期内。""" 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, k=4) 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(prefix='INV') 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(seconds=299) 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(seconds=301) 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(seconds=59) # TTL 设为 60 秒,此时应有效 assert is_cache_valid(cached_time, ttl_seconds=60) is True # TTL 设为 30 秒,此时应过期 assert is_cache_valid(cached_time, ttl_seconds=30) 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', encoding='utf-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(f"Input file not found: {input_path}") line_count = 0 with open(input_path, 'r', encoding='utf-8') as infile: for line in infile: line_count += 1 result = {'file': input_path, 'line_count': line_count} with open(output_path, 'w', encoding='utf-8') as outfile: json.dump(result, outfile, indent=2) 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_value=True) # 2. 模拟 open 函数和文件读取内容 mock_config_data = {'host': 'localhost', 'port': 8080} # mock_open 是一个辅助函数,用于模拟 open() 返回的文件对象 # read_data 需要是字符串,所以我们将字典转为JSON字符串 mock_file = mock_open(read_data=json.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', encoding='utf-8') def test_load_config_file_not_exists(mocker): """ 测试配置文件不存在的情况。 """ mocker.patch('file_processor.os.path.exists', return_value=False) 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_value=True) # 模拟文件内容是非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_value=True) # 模拟输入文件内容 input_content = "line1\nline2\nline3\n" mock_input_file = mock_open(read_data=input_content) # 模拟输出文件的写入操作,我们需要一个可写的 mock mock_output_file = mock_open() # 因为 process_data_file 会调用两次 open(一次读,一次写), # 我们需要让 mock 在第一次调用时返回读的 mock,第二次返回写的 mock。 # 我们可以用 side_effect 来实现。 def side_effect_open(file, mode='r', **kwargs): if mode == 'r': # 返回模拟的读文件对象 return mock_input_file.return_value elif mode == 'w': # 返回模拟的写文件对象 return mock_output_file.return_value mocker.patch('builtins.open', side_effect=side_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_value=False) with pytest.raises(FileNotFoundError, match=f"Input 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(username=user['name']) def send_welcome_email(self, user_email, user_info): """发送欢迎邮件。""" subject = self.format_subject('Welcome, {username}!', user_info) body = f"Hello {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(sender='admin@example.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': 'user@test.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(sender='admin@example.com') # Spy 实例的 _connect 方法 # spy 会包装真实方法,记录调用信息,但默认会执行原方法。 connect_spy = mocker.spy(notifier, '_connect') # Mock 静态方法 mocker.patch('notification.EmailNotifier.format_subject', return_value='Spy 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('spy@test.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.objectvsspy
mocker.patch(‘target.path’):最常用,用于替换模块、类或函数。mocker.patch.object(target, ‘attribute’):当你想替换一个已有对象(类或实例)的某个特定属性时使用。比如上例中替换某个实例的_connect方法,或者替换类的SMTP_SERVER属性。它比patch更精确,不需要写完整的导入路径。mocker.spy(object, ‘attribute’):用于“监视”一个方法或函数。它会让原方法正常执行,但同时记录下它的调用次数、参数等信息。非常适合验证副作用,同时又不想(或不能)完全替换原方法逻辑的情况。
技巧二:验证调用(Assertions)Mock 对象提供了一系列断言方法来验证其被调用的情况,这是单元测试验证“行为”而非“状态”的重要手段:
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_order=False): 验证一系列调用。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(内部纯逻辑)。