Python单元测试实战:pytest框架、Mock技术与测试覆盖率全解析
1. 项目概述:为什么单元测试是Python开发的“金钟罩”?
在Python开发的世界里,我们常常沉浸在实现功能的快感中,一行行代码构建起强大的应用。然而,当项目规模膨胀、团队成员增加、需求频繁变更时,一个幽灵便开始在代码库中游荡——它就是“回归缺陷”。你可能只是修改了一个看似无关紧要的函数,却意外地导致另一个模块的核心功能崩溃。这种“牵一发而动全身”的窘境,相信不少开发者都经历过。而单元测试,正是抵御这个幽灵最坚固的“盾牌”。
《测试为盾:Python 单元测试框架全景解析与实战指南》这个标题,精准地概括了我们的核心任务:不仅要理解Python生态中那些琳琅满目的测试工具(“全景解析”),更要掌握如何将它们运用到真实的项目开发中,形成一套可靠的防御体系(“实战指南”)。这面“盾牌”的价值,远不止于发现bug。一套健壮的单元测试套件,是代码可维护性的基石,是重构时的“安全网”,更是团队协作中不可或缺的“活文档”。它能清晰地告诉你:这段代码的设计意图是什么?它的边界条件在哪里?修改后,哪些行为必须保持不变?
对于Python开发者而言,无论是刚入门的新手,还是经验丰富的老兵,构建和维护有效的单元测试都是一项核心技能。新手可以通过测试来验证自己对语法的理解,建立信心;而资深开发者则依赖测试来保证复杂系统的稳定演进。接下来,我们将深入这面“盾牌”的内部,从设计思路到工具选型,从基础语法到高级技巧,为你构建一套完整的Python单元测试实战知识体系。
2. 核心框架全景解析:不止于unittest和pytest
提到Python单元测试,很多人脑海中首先蹦出的可能是标准库中的 unittest ,或者是第三方明星 pytest 。但Python的测试生态远比这丰富。选择适合的框架,是构建高效测试体系的第一步。我们需要像挑选趁手兵器一样,了解每件工具的特长与适用场景。
2.1 主流框架横向对比与选型策略
目前,社区主流的单元测试框架主要有以下几个,它们各有侧重:
-
unittest (PyUnit) :Python标准库的一部分,基于JUnit设计,提供了TestCase、TestSuite、TestLoader等经典xUnit架构组件。它的最大优势是“开箱即用”,无需额外安装,且结构严谨,适合大型项目或团队有严格测试规范的情况。但其语法略显繁琐,需要继承
unittest.TestCase并编写以test_开头的方法,断言方法也以assertEqual、assertTrue等形式存在,不够Pythonic。 -
pytest :目前事实上的社区标准。它并非标准库,但以其极简的语法、强大的功能和丰富的插件生态赢得了绝大多数开发者的青睐。pytest允许你使用普通的
assert语句进行断言,自动发现测试用例,并提供了参数化测试、夹具(fixture)依赖注入、插件系统等高级特性。对于追求开发效率和灵活性的项目,pytest通常是首选。 -
doctest :一个非常独特的工具,它允许你将测试用例直接写在函数、模块或类的文档字符串(docstring)中。doctest会解析这些文档字符串,寻找看起来像交互式Python会话的文本,并执行它们以验证结果是否匹配。它非常适合用来为API编写可执行的文档,确保示例代码永远是最新且正确的。但它不适合编写复杂或大量的测试逻辑。
如何选型? 我的实战经验是:
- 新项目或中小型项目 :毫不犹豫地选择 pytest 。它的低门槛和高生产力能让你快速建立起测试习惯。
- 遗留项目或大型企业级项目 :如果项目已经在使用 unittest ,并且有一套成熟的CI/CD流程围绕其构建,那么继续维护和扩展它是更稳妥的选择。不过,可以逐步引入pytest来运行现有的unittest用例,享受pytest的报告等好处。
- API库或需要强文档的项目 :将 doctest 作为补充,与pytest或unittest结合使用。用doctest确保示例正确,用pytest进行深入的功能和边界测试。
注意 :不要陷入“非此即彼”的思维。pytest可以完美地运行用unittest编写的测试用例。你完全可以在一个项目中混合使用,让pytest作为测试运行器,享受其更好的输出和插件功能,同时逐步将旧的unittest用例迁移或重写为更简洁的pytest风格。
2.2 超越框架:测试生态中的关键拼图
单元测试框架是核心,但一个完整的测试体系还需要其他组件的配合。理解这些“拼图”,能让你的测试策略更加立体。
-
测试运行器 (Test Runner) :负责发现、加载、运行测试并报告结果。
unittest自带运行器(python -m unittest),pytest自身就是一个更强大的运行器(pytest)。在IDE(如PyCharm, VSCode)或CI/CD工具(如Jenkins, GitHub Actions)中,通常也集成了这些运行器。 -
测试覆盖率工具 (Coverage) :衡量你的测试用例覆盖了多少源代码。
coverage.py是Python领域最常用的工具。它可以生成详细的报告,告诉你哪些行、哪些分支、哪些语句没有被测试到。高覆盖率不等于高质量测试,但低覆盖率一定意味着测试不足。通常与pytest结合使用:pytest --cov=my_project tests/。 -
Mock与Stub库 :单元测试的核心思想是“隔离”。你需要测试一个单元(如一个函数、一个类方法),而不应受到其依赖(如数据库、网络请求、外部API)的影响。
unittest.mock(Python 3.3+ 标准库)和第三方库pytest-mock(与pytest集成更好)就是用来创建“替身”对象的工具。你可以用Mock对象模拟一个数据库连接,用Stub返回预设的数据,从而让测试快速、稳定且不依赖外部环境。 -
参数化测试与夹具 (Fixtures) :这是提升测试代码DRY(Don‘t Repeat Yourself)原则的关键。 参数化测试 允许你为同一个测试函数提供多组输入和期望输出,避免编写大量重复的测试方法。 夹具 是pytest的核心概念之一,它提供了一种优雅的方式来设置测试前置条件(如创建临时数据库、初始化对象)和清理后置操作。夹具可以被多个测试用例共享,极大地减少了样板代码。
3. 从零到一:构建你的第一个测试堡垒
理论说得再多,不如动手实践。让我们从一个最简单的例子开始,演示如何使用pytest为一段业务逻辑代码编写测试。假设我们有一个处理用户折扣计算的模块 discount_calculator.py 。
3.1 被测代码与测试环境搭建
首先,创建我们的业务逻辑文件:
# discount_calculator.py
def calculate_discount(amount, user_type='regular', has_coupon=False):
"""
根据用户类型和优惠券计算最终价格。
:param amount: 原始金额
:param user_type: 用户类型,'regular'(普通), 'vip'(VIP)
:param has_coupon: 是否有优惠券
:return: 折后金额
"""
if amount <= 0:
raise ValueError("金额必须大于0")
discount = 0.0
if user_type == 'vip':
discount += 0.1 # VIP 9折
if has_coupon:
discount += 0.05 # 优惠券 95折
# 折扣上限为20%
final_discount = min(discount, 0.2)
return amount * (1 - final_discount)
接下来,建立测试环境。最佳实践是将测试代码放在单独的目录中,通常命名为 tests ,并与项目代码平行或作为子目录。我们使用 pytest ,所以需要先安装它(如果尚未安装): pip install pytest 。然后创建测试文件 test_discount_calculator.py 。注意,pytest默认会递归查找当前目录下所有以 test_ 开头或结尾的 .py 文件,并执行其中以 test_ 开头的函数。
3.2 编写基础测试用例:断言的艺术
现在,在 tests/test_discount_calculator.py 中编写我们的第一个测试:
# tests/test_discount_calculator.py
import pytest
from discount_calculator import calculate_discount
def test_calculate_discount_regular_no_coupon():
"""测试普通用户,无优惠券"""
result = calculate_discount(100.0)
# 使用简单的 assert 语句
assert result == 100.0
def test_calculate_discount_vip_no_coupon():
"""测试VIP用户,无优惠券"""
result = calculate_discount(100.0, user_type='vip')
# VIP 9折, 100 * 0.9 = 90
assert result == 90.0
def test_calculate_discount_regular_with_coupon():
"""测试普通用户,有优惠券"""
result = calculate_discount(100.0, has_coupon=True)
# 优惠券95折, 100 * 0.95 = 95
assert result == 95.0
def test_calculate_discount_vip_with_coupon():
"""测试VIP用户,有优惠券"""
result = calculate_discount(100.0, user_type='vip', has_coupon=True)
# VIP 9折 + 优惠券95折, 折扣为 1 - (0.9*0.95) = 0.145, 100 * 0.855 = 85.5
# 注意:函数逻辑是折扣叠加,但上限20%。这里是 10%+5%=15%折扣。
assert result == 85.0 # 100 * (1 - 0.15) = 85
def test_calculate_discount_discount_cap():
"""测试折扣上限(20%)"""
# 假设有一种情况折扣超过20%,这里用VIP+某种大额券(假设)来测试边界
# 我们的函数逻辑是 min(discount, 0.2),所以即使计算折扣25%,也只会按20%扣。
# 为了测试,我们假设一个场景,但当前函数不支持。我们可以测试VIP+优惠券的叠加就是15%,未超上限。
# 更全面的测试可能需要修改函数或增加参数,这里先测试现有逻辑边界。
result = calculate_discount(100.0, user_type='vip', has_coupon=True)
assert result == 85.0 # 15%折扣,未达上限
def test_calculate_discount_invalid_amount():
"""测试无效金额(边界和异常)"""
with pytest.raises(ValueError) as exc_info:
calculate_discount(0)
assert "金额必须大于0" in str(exc_info.value)
with pytest.raises(ValueError):
calculate_discount(-10)
在命令行中,进入项目根目录,运行 pytest 。pytest会自动发现并运行这些测试,你会看到绿色的 . 表示测试通过。如果某个测试失败(断言不成立),pytest会给出非常清晰的错误信息,包括期望值和实际值。
实操心得 :编写测试时,不要只覆盖“快乐路径”(正常情况)。像 test_calculate_discount_invalid_amount 这样测试异常和边界条件的用例,往往能发现更多潜在问题。使用 pytest.raises 来断言代码是否按预期抛出了异常。
3.3 使用参数化测试精简代码
上面的测试中, test_calculate_discount_vip_with_coupon 和 test_calculate_discount_discount_cap 的测试数据有重叠,而且如果我们要测试更多金额组合,代码会急剧膨胀。这时就该 参数化测试 登场了。
# tests/test_discount_calculator.py (续)
import pytest
from discount_calculator import calculate_discount
# ... 之前的测试函数 ...
@pytest.mark.parametrize(
"amount, user_type, has_coupon, expected",
[
(100, 'regular', False, 100.0), # 无折扣
(100, 'vip', False, 90.0), # VIP 9折
(100, 'regular', True, 95.0), # 优惠券95折
(100, 'vip', True, 85.0), # VIP+优惠券 85折 (15% off)
(200, 'vip', True, 170.0), # 金额变化
(50, 'regular', True, 47.5), # 小金额
]
)
def test_calculate_discount_parametrized(amount, user_type, has_coupon, expected):
"""使用参数化测试覆盖多种输入组合"""
result = calculate_discount(float(amount), user_type, has_coupon)
# 使用 pytest.approx 处理浮点数比较,避免精度问题
assert result == pytest.approx(expected)
@pytest.mark.parametrize 装饰器将多组参数“注入”到同一个测试函数中。pytest会为每一组参数单独运行一次测试,并在报告中清晰展示。这极大地减少了重复代码,并使添加新的测试用例变得非常简单。
重要提示 :进行浮点数比较时,永远不要直接使用
==。因为二进制浮点数的精度问题,0.1 + 0.2并不完全等于0.3。务必使用pytest.approx(expected)或math.isclose(a, b)来允许微小的误差。
4. 进阶实战:使用Fixture处理复杂依赖与状态
当测试代码需要依赖一些昂贵的资源(如数据库连接、网络会话、临时文件)或复杂的设置时,如果每个测试函数都自己创建和清理,代码会变得冗长且低效。pytest的 Fixture(夹具) 系统就是为了解决这个问题而生的。Fixture提供了可重用的、有生命周期的设置和清理机制。
4.1 创建与使用基础Fixture
假设我们的折扣计算器升级了,它需要从一个“用户服务”中获取用户的等级信息。我们不想在测试中真的调用这个可能很慢或不稳定的外部服务,这时就需要Mock。
首先,创建一个Fixture来模拟这个用户服务:
# tests/conftest.py
# conftest.py 是pytest的本地插件文件,其中定义的Fixture可以被该目录及其子目录下的所有测试文件自动发现和使用。
import pytest
from unittest.mock import Mock
@pytest.fixture
def mock_user_service():
"""提供一个模拟的用户服务"""
service = Mock()
# 预设普通用户的行为
service.get_user_tier.return_value = 'regular'
return service
现在,在我们的测试文件中,可以直接将这个Fixture作为参数传入测试函数,pytest会自动注入它:
# tests/test_advanced_discount.py
import pytest
from unittest.mock import Mock
# 假设我们有一个新的类
from discount_calculator import AdvancedDiscountCalculator
def test_advanced_calculator_with_regular_user(mock_user_service):
"""测试高级计算器与模拟用户服务的交互"""
calculator = AdvancedDiscountCalculator(mock_user_service)
result = calculator.calculate_for_user(100, user_id=123)
# 验证调用了用户服务
mock_user_service.get_user_tier.assert_called_once_with(123)
# 验证计算结果(假设普通用户无折扣)
assert result == 100
在这个测试中, mock_user_service Fixture被自动注入。我们验证了 get_user_tier 方法被以正确的参数调用了一次,并且计算结果符合预期。整个测试完全隔离了真实的外部服务,快速且稳定。
4.2 Fixture的作用域与生命周期管理
Fixture可以有不同的作用域,控制其创建和销毁的频率,这对于管理资源密集型对象至关重要。
-
function(默认) :每个测试函数运行一次。适用于轻量级、无状态的设置。 -
class:每个测试类运行一次。该类中的所有测试方法共享同一个Fixture实例。 -
module:每个测试模块(.py文件)运行一次。 -
session:整个pytest运行会话只运行一次。适用于全局的、非常昂贵的资源,如启动一个测试数据库容器。
# tests/conftest.py
import pytest
import tempfile
import os
@pytest.fixture(scope="session")
def test_database():
"""会话级Fixture:创建一个临时测试数据库,整个测试过程只创建一次"""
# 模拟创建数据库连接或文件
db_file = tempfile.NamedTemporaryFile(delete=False, suffix='.db')
db_path = db_file.name
db_file.close()
print(f"\n创建测试数据库于: {db_path}")
# 这里通常会有初始化数据库表结构的代码
# setup_db_schema(db_path)
yield db_path # 将资源提供给测试使用
# 测试结束后执行的清理代码
print(f"\n清理测试数据库: {db_path}")
os.unlink(db_path)
@pytest.fixture(scope="function")
def db_connection(test_database):
"""函数级Fixture:每个测试获取一个新的数据库连接,但基于同一个数据库文件"""
# 模拟创建连接
# conn = create_connection(test_database)
conn = Mock()
conn.database_path = test_database
yield conn
# 每个测试后关闭连接
# conn.close()
print(f"关闭连接到 {test_database}")
在上面的例子中, test_database 在测试会话开始时创建,所有测试结束后删除。而 db_connection 则为每个测试函数创建一个新的连接Mock,测试结束后“关闭”。 db_connection Fixture又依赖于 test_database Fixture,展示了Fixture之间的依赖关系。
实操心得 :合理使用Fixture作用域能显著提升测试速度。例如,将HTTP测试客户端的创建设为 module 或 session 级,避免为每个测试重复建立TCP连接。但要注意,如果测试会修改Fixture的状态(比如往数据库里写数据),那么使用 function 作用域来隔离每个测试是更安全的选择,或者需要在测试开始前回滚数据。
5. Mock的艺术:精准模拟与行为验证
单元测试的灵魂在于“隔离”。 unittest.mock 模块是Python中实现隔离的瑞士军刀。它主要提供三个核心概念: Mock 、 MagicMock 和 patch 。理解它们的区别和适用场景,是写出高质量单元测试的关键。
5.1 Mock与MagicMock的核心区别
- Mock :一个基本的Mock对象。你可以设置它的属性、返回值,并断言它如何被调用。它不会模仿任何特定的行为,除非你告诉它。
- MagicMock :是Mock的子类,它“神奇地”为大多数Python魔法方法(如
__len__,__iter__,__getitem__)提供了默认的Mock实现。这意味着你可以直接对MagicMock对象使用len(mock)或for item in mock而不会抛出AttributeError。在大多数需要模拟一个类或复杂对象的情况下,使用MagicMock更方便。
from unittest.mock import Mock, MagicMock
# Mock 对象没有默认的魔法方法
plain_mock = Mock()
try:
len(plain_mock)
except TypeError as e:
print(f"Mock 不支持 len: {e}") # 会抛出异常
# MagicMock 对象有默认的魔法方法
magic_mock = MagicMock()
magic_mock.__len__.return_value = 10
print(len(magic_mock)) # 输出: 10
# 你也可以直接设置返回值
magic_mock.__getitem__.return_value = 'item'
print(magic_mock[0]) # 输出: 'item'
5.2 使用patch进行猴子补丁
patch 是一个上下文管理器/装饰器,用于在测试期间临时替换一个对象(模块中的类、函数、属性)。这是模拟依赖项最常用、最强大的方式。
场景 :测试一个函数 send_report ,它内部调用了另一个模块 email_sender 中的 send 函数。我们不想在测试中真的发邮件。
# my_module.py
import email_sender
def send_report(user_email, report_data):
# ... 生成报告内容 ...
content = generate_content(report_data)
# 调用外部依赖
success = email_sender.send(to=user_email, content=content)
if not success:
raise RuntimeError("邮件发送失败")
return True
测试代码如下:
# tests/test_my_module.py
from unittest.mock import patch
import pytest
from my_module import send_report
def test_send_report_success():
"""测试报告发送成功路径"""
# 使用 patch 模拟 email_sender.send 函数
with patch('my_module.email_sender.send') as mock_send:
# 配置模拟行为:让它返回 True
mock_send.return_value = True
# 执行被测函数
result = send_report('test@example.com', {'data': 'test'})
# 断言结果
assert result is True
# 验证模拟函数被以正确的参数调用
mock_send.assert_called_once()
call_args = mock_send.call_args
assert call_args.kwargs['to'] == 'test@example.com'
assert 'report' in call_args.kwargs['content'].lower() # 假设内容里有'report'
def test_send_report_failure():
"""测试报告发送失败路径(异常抛出)"""
with patch('my_module.email_sender.send') as mock_send:
mock_send.return_value = False
# 断言会抛出 RuntimeError
with pytest.raises(RuntimeError, match="邮件发送失败"):
send_report('test@example.com', {'data': 'test'})
mock_send.assert_called_once()
关键点 : patch 的第一个参数是字符串,表示要模拟对象的 导入路径 。它必须是“在测试目标中看到的样子”。因为 send_report 函数内部使用的是 from my_module import email_sender ,所以在 my_module 的上下文中, email_sender 就是一个模块对象。因此,我们需要patch 'my_module.email_sender.send' ,而不是 'email_sender.send' 。这是一个常见的踩坑点。
5.3 行为验证:确保交互如你所期
Mock不仅用于提供假的返回值,更重要的是用于 行为验证 。你可以验证依赖是否被调用、调用了多少次、以及调用时的参数是什么。
mock_send.assert_called_once() # 断言只被调用了一次
mock_send.assert_called_with(to='test@example.com', content='...') # 断言以特定参数被调用
mock_send.assert_called_once_with(to='test@example.com', content='...') # 结合以上两者
# 更灵活的检查
args, kwargs = mock_send.call_args
assert kwargs['to'] == 'test@example.com'
assert mock_send.call_count == 1
# 检查多次调用的序列
mock_func = Mock()
mock_func('first')
mock_func('second')
assert mock_func.call_args_list == [call('first'), call('second')]
注意事项 :不要过度使用Mock。Mock的滥用会导致测试与实现细节过度耦合(测试“怎么做的”而不是“做了什么”),从而使测试变得脆弱。一个好的原则是:只Mock那些确实不稳定、速度慢或有副作用的 外部依赖 (如数据库、网络、文件系统、第三方API)。对于项目内部纯逻辑的模块,应尽量使用真实对象进行集成测试。
6. 测试覆盖率:度量你的“盾牌”完整性
测试覆盖率是一个重要的度量指标,它量化了你的测试用例执行了源代码的哪些部分。虽然100%的覆盖率不能保证没有bug,但低覆盖率一定意味着有大量代码路径从未被测试过,风险极高。 coverage.py 是Python领域的事实标准。
6.1 集成coverage.py与pytest
安装: pip install pytest-cov 。 pytest-cov 是pytest的一个插件,它无缝集成了coverage.py。
运行测试并收集覆盖率报告:
# 基本用法:运行测试并显示终端摘要
pytest --cov=my_project tests/
# 更常用的:生成详细的HTML报告,便于浏览
pytest --cov=my_project --cov-report=html tests/
运行后,会在当前目录生成一个 htmlcov 文件夹,打开其中的 index.html ,你可以看到一个交互式的报告。它会用不同颜色高亮显示哪些行被覆盖(绿色),哪些行被遗漏(红色),哪些行是分支未覆盖(黄色)。
6.2 解读覆盖率报告与提升策略
一份典型的覆盖率报告会包含几个关键指标:
- 语句覆盖率 (Statements) :测试执行了代码中多少百分比的语句。
- 分支覆盖率 (Branches) :测试覆盖了多少百分比的代码分支(如if/else条件)。这是比语句覆盖率更严格的指标。
- 函数覆盖率 (Functions) :测试调用了多少百分比的函数。
- 行覆盖率 (Lines) :类似于语句覆盖率,但以行为单位。
如何有效提升覆盖率?
- 优先覆盖核心逻辑和公共API :不要盲目追求数字。首先确保项目中最关键、最核心的业务逻辑和对外暴露的接口有充分的测试。
- 关注边缘和异常情况 :覆盖率报告中的红色行,常常是错误处理分支(如
except块)、边界条件检查(如if value is None)或默认参数分支。为这些“冷门”路径编写测试用例,往往能发现隐藏最深的bug。 - 利用覆盖率报告发现死代码 :如果某些代码行永远不被覆盖,并且不属于错误处理或合理的条件分支,那么它们可能是无用的“死代码”,可以考虑删除。
- 设置合理的覆盖率目标 :在项目根目录创建
.coveragerc配置文件,可以排除不需要覆盖的文件(如 migrations 目录、自动生成的代码、第三方库适配器等),并设置团队认可的最低覆盖率阈值。
# .coveragerc
[run]
omit =
*/tests/*
*/migrations/*
*/__pycache__/*
*/venv/*
*/site-packages/*
setup.py
[report]
# 设置忽略某些行(如只有pass的语句)
exclude_lines =
pragma: no cover
def __repr__
raise AssertionError
raise NotImplementedError
if __name__ == .__main__.:
pass
fail_under = 80 # 如果总覆盖率低于80%,则命令返回失败状态码
实操心得 :将覆盖率检查集成到CI/CD流水线中,并设置一个“门禁”(如 fail_under = 80 )。这样,任何导致覆盖率下降的代码合并请求都会被自动阻止,促使团队保持并提升测试质量。但同时也要警惕“为了覆盖率而测试”的陷阱,比如编写无意义的测试只为了覆盖某行代码。测试的核心价值在于其发现缺陷的能力,而非一个漂亮的数字。
7. 测试策略与项目结构:构建可持续的测试体系
编写单个测试用例是基础,但如何将成千上万个测试用例组织起来,并在不同阶段(开发、集成、部署)高效运行,则需要一个清晰的策略和项目结构。
7.1 测试目录结构规范
一个清晰的结构有助于团队协作和工具集成。以下是常见的Python项目测试布局:
my_project/
├── my_project/ # 主包目录
│ ├── __init__.py
│ ├── core/
│ │ ├── __init__.py
│ │ ├── calculator.py
│ │ └── validators.py
│ └── utils/
│ ├── __init__.py
│ └── helpers.py
├── tests/ # 测试根目录
│ ├── __init__.py # 可选,使tests成为一个包
│ ├── conftest.py # 项目级的Fixture和Hook定义
│ ├── unit/ # 单元测试
│ │ ├── __init__.py
│ │ ├── test_calculator.py
│ │ └── test_validators.py
│ ├── integration/ # 集成测试
│ │ ├── __init__.py
│ │ └── test_database_operations.py
│ └── functional/ # 功能/端到端测试(可选)
│ ├── __init__.py
│ └── test_user_workflow.py
├── pyproject.toml # 项目配置和依赖
└── README.md
分层测试策略 :
- 单元测试 (Unit Tests) :位于
tests/unit/。测试独立的函数、类或方法,使用Mock隔离所有外部依赖。运行速度极快,是测试金字塔的基石。 - 集成测试 (Integration Tests) :位于
tests/integration/。测试多个模块如何协同工作,或者模块与真实的外部资源(如测试数据库、内存缓存)的交互。运行速度中等。 - 功能/端到端测试 (Functional/E2E Tests) :位于
tests/functional/。从用户角度测试完整的业务流程,可能涉及启动整个应用、模拟用户界面操作等。运行速度最慢,也最脆弱。
7.2 使用pytest标记分类运行测试
随着测试套件增长,你可能不想每次运行所有测试。pytest的标记(mark)功能可以帮你对测试进行分类和筛选。
首先,在 pyproject.toml 或 pytest.ini 中注册自定义标记,避免警告:
# pyproject.toml (部分)
[tool.pytest.ini_options]
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"integration: marks tests as integration tests",
"smoke: a subset of tests for quick verification",
]
然后,在测试函数上使用装饰器标记:
# tests/unit/test_calculator.py
import pytest
@pytest.mark.smoke # 冒烟测试
def test_basic_addition():
assert 1 + 1 == 2
@pytest.mark.slow # 慢速测试
def test_heavy_computation():
# ... 耗时计算 ...
pass
# tests/integration/test_db.py
@pytest.mark.integration
def test_database_connection():
# ... 需要真实数据库 ...
pass
运行命令:
# 只运行标记为 smoke 的测试
pytest -m smoke
# 运行除了 slow 以外的所有测试
pytest -m "not slow"
# 同时满足多个标记 (AND)
pytest -m "smoke and not integration"
# 运行指定目录下的测试
pytest tests/unit/
# 运行指定文件中的测试
pytest tests/unit/test_calculator.py::test_basic_addition
7.3 持续集成中的测试实践
将测试集成到CI/CD(如GitHub Actions, GitLab CI, Jenkins)中是保证代码质量的自动化防线。一个典型的CI配置会做以下几件事:
- 安装依赖 :根据
requirements.txt或pyproject.toml安装项目依赖和测试依赖(如pytest, pytest-cov)。 - 运行代码风格检查 :使用
black,isort,flake8等工具。 - 运行静态类型检查 (如果用了Type Hints):使用
mypy。 - 运行测试套件 :执行
pytest命令,并收集覆盖率报告。 - 上传覆盖率报告 :将生成的覆盖率报告(如HTML或XML格式)上传到像Codecov、Coveralls这样的服务,以便在Pull Request中可视化展示。
- 质量门禁 :如果测试失败,或者覆盖率低于预设阈值,或者代码风格检查不通过,则CI流程失败,阻止代码合并。
GitHub Actions 示例片段 (.github/workflows/test.yml) :
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .[dev] # 假设dev依赖包含测试工具
- name: Lint with flake8
run: |
flake8 my_project tests
- name: Test with pytest
run: |
pytest --cov=my_project --cov-report=xml --cov-report=html tests/
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: ./coverage.xml
构建这样一套自动化的测试流水线,意味着每次代码提交都会自动得到质量反馈,让“测试为盾”的理念贯穿于整个开发生命周期,真正成为团队交付可靠软件的坚实保障。
更多推荐
所有评论(0)