Python(十九):第十八章 单元测试

第十八章 单元测试

软件开发的核心目标之一是交付高质量、运行稳定的代码。单元测试 (Unit Testing) 是保障这一目标的重要手段,它专注于验证软件中最小可测试单元(通常是函数、方法或类)的行为是否符合预期。

在本章中,我们将深入学习 Python 中广受欢迎的测试框架 —— pytest

18.1 单元测试简介:

单元测试通过在开发早期发现并修复问题,从而提升代码质量,增强代码重构的信心,并作为一种“活文档”辅助理解代码功能。

基本流程:

  1. 隔离单元:确定要测试的函数、方法或类。
  2. 定义预期:明确该单元在特定输入下应有的输出或行为。
  3. 编写测试:使用测试框架编写代码来验证这些预期。
  4. 执行测试:运行测试并检查结果。
  5. 迭代优化:根据测试结果修改代码或测试本身。

18.2 Pytest 简介与核心优势

pytest 是一个成熟且功能齐全的 Python 测试框架,它使得编写小型、易读的测试变得简单,并且可以扩展以支持复杂的函数式、接口或系统级测试。

为什么选择 pytest

  • 极简样板代码:相比 unittestpytest 需要的模板代码更少。测试函数就是普通的 Python 函数,不需要继承任何类。
  • 强大的 assert 语句:直接使用标准的 assert 语句进行断言,pytest 会提供详细的断言失败信息。
  • 灵活的 Fixturespytest 的 Fixture 系统非常强大,用于管理测试依赖和测试上下文的准备与清理,比传统的 setUp/tearDown 更灵活。
  • 丰富的插件生态:拥有大量高质量的第三方插件(如 pytest-django, pytest-cov (覆盖率), pytest-xdist (并行测试) 等)。
  • 良好的兼容性:可以运行基于 unittestnose 编写的测试用例。
  • 清晰的测试报告:默认提供易读的测试报告。

安装 pytest

您可以使用 pip 来安装 pytest

1
pip install pytest

18.3 Pytest 核心特性概览

pytest 的强大功能主要体现在以下几个核心特性上,本笔记将逐一介绍:

特性/概念简介涉及的主要 pytest 元素/用法
测试发现 (Test Discovery)pytest 自动查找符合特定命名约定的测试文件和函数。文件名 test_*.py*_test.py;函数/方法名 test_*
基本测试函数 (Basic Test Functions)普通 Python 函数即可作为测试用例,无需继承特定类。def test_example(): ...
断言 (Assertions)使用 Python 内置的 assert 语句进行结果验证,pytest 提供详细的错误报告。assert expression
异常测试 (Exception Testing)优雅地测试代码是否按预期抛出异常。pytest.raises() 上下文管理器。
Fixtures (测试固件)管理测试函数的依赖、状态和资源,实现代码复用和模块化。@pytest.fixture 装饰器, yield 用于 teardown。
参数化测试 (Parametrization)使用不同的参数多次运行同一个测试函数,避免代码重复。@pytest.mark.parametrize 装饰器。
标记 (Markers)为测试函数添加元数据,用于分类、跳过、标记预期失败等。@pytest.mark.<marker_name> (如 skip, xfail)。
运行测试 (Running Tests)通过命令行工具 pytest 运行测试,并提供多种选项。pytest 命令及其参数 (如 -v, -k, -m)。

18.4 Pytest 基础实践

18.4.1 编写第一个 Pytest 测试:测试加法函数

pytest 会自动发现当前目录及其子目录下所有命名为 test_*.py*_test.py 的文件中的 test_* 开头的函数。

被测代码 (my_math.py):

1
2
3
4
# my_math.py
def add(x, y):
"""计算两个数的和"""
return x + y

测试代码 (test_my_math_pytest.py):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# test_my_math_pytest.py
from my_math import add # 导入需要测试的函数

def test_simple_addition():
"""测试基本的加法功能。"""
# 直接使用 assert进行断言
assert add(1, 2) == 3, "1 + 2 应该等于 3" # 可选的错误信息
assert add(-1, 1) == 0
# assert add(0, 3) == 这里一定会报错 因为 0 # 3 != 0

def test_negative_addition():
"""测试负数相加。"""
assert add(-5, -10) == -15


代码注释与讲解:

  • 测试文件命名为 test_my_math_pytest.py,遵循 pytest 的发现约定。
  • 测试函数 test_simple_additiontest_negative_additiontest_ 开头。
  • 直接使用 assert 语句。如果 assert 后的表达式为 Falsepytest 会将该测试标记为失败,并提供详细的上下文信息。
  • 最后的 , "message" 部分是可选的,如果断言失败,这个消息不会像 unittestmsg 参数那样直接显示,pytest 会通过其内省机制提供更丰富的失败信息。

运行测试:

image-20250510175654130

pytest 会自动找到并执行 test_simple_additiontest_negative_addition

18.4.2 测试异常:pytest.raises

当需要验证代码是否按预期抛出特定异常时,可以使用 pytest.raises

被测代码 (more_math.py,包含 square 函数):

1
2
3
4
5
6
# more_math.py
def square(n):
"""计算一个数的平方。"""
if not isinstance(n, (int, float)):
raise TypeError("输入必须是数字")
return n * n

测试代码 (test_more_math_pytest.py):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# test_more_math_pytest.py
import pytest # 需要导入 pytest 来使用 pytest.raises 等特性
from more_math import square


def test_square_positive_numbers():
"""测试正数的平方。"""
assert square(2) == 4
assert square(3.0) == 9.0


def test_square_negative_numbers():
"""测试负数的平方。"""
assert square(-2) == 4
assert square(-1.5) == 2.25


def test_square_zero():
"""测试零的平方。"""
assert square(0) == 0

def test_square_invalid_input_raises_typeerror():
"""测试当输入无效时,square 函数是否抛出 TypeError。"""
# pytest.raises 是一个上下文管理器,用于断言在 with 块内执行的代码会抛出指定类型的异常
# 如果代码块中没有抛出 TypeError,测试将失败
# 如果代码块中抛出了其他类型的异常,测试也将失败
with pytest.raises(TypeError):
square('呵呵呵呵呵') # 这行代码应该抛出 TypeError
# 这个测试能通过是因为 square 函数在接收到字符串参数时会抛出 TypeError
# 当使用 pytest.raises(TypeError) 上下文管理器时,如果代码块内抛出了 TypeError
# pytest 会捕获这个异常并使测试通过,这正是我们期望的行为


def test_square_invalid_input_message():
"""测试 TypeError 异常的错误信息是否符合预期 (可选)。"""
# pytest.raises 的 match 参数允许提供一个正则表达式,用于验证抛出的异常实例的错误消息
# 这对于确保错误信息对用户友好且准确非常有用
# 如果抛出的异常消息与提供的正则表达式不匹配,测试将失败
with pytest.raises(TypeError, match="输入必须是数字"): # match 参数使用正则表达式匹配错误信息
square('text')

18.5 Pytest Fixtures:强大的依赖注入与测试准备

Fixtures(测试固件)是 pytest 中一个非常核心且强大的特性。它们用于为测试函数、类、模块或整个会话设置必要的预置条件(如数据、对象实例、服务连接等),并在测试结束后进行清理。

18.5.1 Fixture 基本概念与应用:@pytest.fixture

通过 @pytest.fixture 装饰器可以将一个函数标记为 fixture。测试函数可以通过将其名称作为参数来请求使用这个 fixture。

示例 (test_fixtures_basic.py):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# test_fixtures_basic.py
import pytest

@pytest.fixture
def sample_list():
"""一个提供简单列表数据的 fixture。"""
print("\n [Fixture setup] Creating sample_list...")
data = [1, 2, 3, 4, 5]
return data # fixture 返回的值会注入到测试函数中


@pytest.fixture
def sample_dict():
"""一个提供简单字典数据的 fixture。"""
print("\n [Fixture setup] Creating sample_dict...")
return {"name": "pytest", "type": "framework"}

def test_list_length(sample_list): # 将 fixture 名称作为参数
"""测试 sample_list 的长度。"""
print("\n [Test running] test_list_length using sample_list")
assert len(sample_list) == 5


def test_list_content(sample_list):
"""测试 sample_list 的内容。"""
print("\n [Test running] test_list_content using sample_list")
assert 3 in sample_list
assert sample_list[0] == 1



def test_dict_values(sample_dict, sample_list): # 一个测试可以使用多个 fixtures
"""测试 sample_dict 的值,并同时使用 sample_list。"""
print("\n [Test running] test_dict_values using sample_dict and sample_list")
assert sample_dict["name"] == "pytest"
assert len(sample_list) > 0 # 确认 sample_list 也被正确注入

代码注释与讲解:

  • @pytest.fixture: 装饰器,将 sample_listsample_dict 函数转换为 fixture。
  • 当测试函数(如 test_list_length(sample_list))在其参数列表中包含 fixture 名称时,pytest 会在执行该测试函数之前先执行对应的 fixture 函数,并将其返回值注入到测试函数的同名参数中。
  • 复用性:同一个 fixture 可以被多个测试函数使用,避免了重复的设置代码。
  • 声明式依赖:测试函数清晰地声明了它所依赖的上下文或数据。

18.5.2 Fixture 的作用域 (Scope)

Fixture 可以定义不同的作用域,以控制其执行(setup/teardown)的频率和生命周期。作用域通过 @pytest.fixturescope 参数指定

作为单元测试来说,没有必要区分的这么死板,平常来说使用默认值即可,若有严格需求再详细区分作用域

  • function (默认): 每个测试函数执行一次。是开销最小、隔离性最好的作用域。
  • class: 每个测试类执行一次。用于类中所有测试方法共享的、创建开销较大的资源。
  • module: 每个模块执行一次。
  • package: 每个包执行一次 (Python 3.7+, 且 pytest 4.3+)。
  • session: 整个测试会话(即一次 pytest 命令的完整执行过程)执行一次。适用于全局的、创建非常昂贵的资源。

示例 (test_fixture_scopes.py):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# test_fixture_scopes.py
import pytest


# 函数级别 - 每个测试函数都会重新创建一次
@pytest.fixture
def function_scoped_fixture():
print("\n [设置] 函数级别的fixture - 每个测试函数都会重新创建")
yield "函数值"
print("\n [清理] 函数级别的fixture")

# 类级别 - 每个测试类只创建一次,类中所有测试方法共享
@pytest.fixture(scope="class")
def class_scoped_fixture():
print("\n [设置] 类级别的fixture - 每个测试类只创建一次")
yield "类值"
print("\n [清理] 类级别的fixture")

# 模块级别 - 整个测试模块文件只创建一次,所有测试共享
@pytest.fixture(scope="module")
def module_scoped_fixture():
print("\n [设置] 模块级别的fixture - 整个测试文件只创建一次")
yield "模块值"
print("\n [清理] 模块级别的fixture")

# 会话级别 - 整个测试会话只创建一次,所有测试模块共享
@pytest.fixture(scope="session")
def session_scoped_fixture():
print("\n [设置] 会话级别的fixture - 整个测试会话只创建一次")
yield "会话值"
print("\n [清理] 会话级别的fixture")



代码注释与讲解:

  • 观察运行 pytest -s -v test_fixture_scopes.py (-s 用于显示 print 输出) 时的输出,可以清晰地看到不同作用域 fixture 的 setup 时机。

18.5.3 使用 yield 实现 Fixture 的 Teardown (清理操作)

如果 fixture 需要在测试使用完毕后执行清理操作(类似于 unittest 中的 tearDown),可以使用 yield 语句。yield 之前的代码是 setup 部分,yield 之后的代码是 teardown 部分。

示例 (使用 pytest fixture):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# test_file_fixture_pytest.py
import pytest
import os
import tempfile # 用于创建更安全的临时文件/目录


@pytest.fixture(scope="function") # 每个测试函数都会得到一个新的临时文件
def temp_file_with_content():
"""
创建一个包含内容的临时文件。
测试结束后,该临时文件会被自动清理。
"""
# Setup: 创建临时文件并写入内容
# tempfile.NamedTemporaryFile 创建一个有名字的临时文件,delete=False 确保在with块结束后文件不会立即删除,
# 这样测试函数才能访问它。我们需要手动删除。
# 或者使用 tmp_path fixture (见 18.5.4) 会更简单。

# 使用 tempfile.mkstemp() 可以获得文件名和文件描述符,更可控
fd, file_path = tempfile.mkstemp(text=True, prefix="pytest_temp_")
print(f"\n [Fixture 设置] 创建临时文件: {file_path}")

file_content = "Hello, pytest fixtures!"
with open(fd, "w") as f: # 使用文件描述符打开
f.write(file_content)

# yield 将文件路径和预期的内容传递给测试函数
yield file_path, file_content

# Teardown: 删除临时文件
print(f"\n [Fixture 清理] 删除临时文件: {file_path}")
os.remove(file_path)


def test_read_from_temp_file(temp_file_with_content):
"""测试从 fixture 创建的临时文件中读取内容。"""
file_path, expected_content = temp_file_with_content # 解包 fixture 返回的值
print(f"\n [测试运行中] test_read_from_temp_file 访问 {file_path}")

with open(file_path, "r") as f:
content = f.read()
assert content == expected_content


def test_temp_file_exists_during_test(temp_file_with_content):
"""测试临时文件在测试执行期间确实存在。"""
file_path, _ = temp_file_with_content
print(f"\n [测试运行中] test_temp_file_exists_during_test 检查 {file_path}")
assert os.path.exists(file_path)

代码注释与讲解:

  • yield file_path, file_content: yield 语句是 setup 和 teardown 的分界点。它将 file_pathfile_content 提供给测试函数。
  • yield 之后的代码 (os.remove(file_path)) 在使用该 fixture 的测试函数执行完毕后(无论成功或失败)执行。