深入浅出 Pytest:自动化测试的最佳实践 pytest教程 程序测试 单元化测试
一、用法
.1 断言
在测试函数中用assert(断言)来判断测试是否符合预期。
# 一个成功的测试函数
def test_passing():
assert (1, 2, 3) == (1, 2, 3)
# 一个失败的测试函数
def test_failing():
assert (1, 2, 3) == (3, 2, 1)
assert
语句本质上是用于检查一个表达式的布尔值是否为 True
。如果表达式的值为 False
,则会抛出一个 AssertionError
异常。assert
通常用于调试和测试过程中验证程序的某些假设或条件是否成立。
# Syntax
assert 条件表达式 [, 错误信息]
- 条件表达式:任何返回布尔值的表达式。
- 错误信息(可选):如果断言失败,会作为异常的附加信息显示。
.2 测试预期异常
pytest 捕获异常的主要目的是为了验证代码在预期抛出异常的情况下是否能够正确处理这些异常。简单来说,就是测试你的代码在遇到错误情况时是否“按计划出错”,而不是直接崩溃或产生不可预料的行为。
例子1:(用pytest.raises上下管理器可以让测试代码更清晰、易读,并提高异常相关测试的可靠性)
import pytest
def test_multiple_exceptions():
# 用 Pytest 提供的一个上下文管理器,用于测试代码是否会抛出特定的异常。
with pytest.raises((TypeError, ValueError)):
int("abc") #ValueError
len(1) # TypeError
# 以上代码等效于:
def test_multiple_exceptions():
try:
int("abc")
assert False, "Expected ValueError but no exception was raised"
except ValueError:
pass # Test passes because ValueError was raised
except Exception as e:
assert False, f"Unexpected exception raised: {type(e).__name__}"
try:
len(1)
assert False, "Expected ValueError but no exception was raised"
except TypeError:
pass # Test passes because TypeError was raised
except Exception as e:
assert False, f"Unexpected exception raised: {type(e).__name__}"
例子2:
import pytest
def f():
raise ExceptionGroup(
"Group message",
[
RuntimeError(),
],
)
def test_exception_in_group():
with pytest.raises(ExceptionGroup) as excinfo:
f()
assert excinfo.group_contains(RuntimeError)
assert not excinfo.group_contains(TypeError)
这段代码使用 pytest 测试了一个函数是否正确地创建并抛出了包含特定类型异常的异常组,并验证了该异常组中是否不包含指定(TypeError)类型的异常。
二、Pytest 测试发现的约定
在使用 pytest
进行自动化测试时,pytest
会根据一定规则自动寻找项目中的测试用例。以下是详细的解释:
1.1 测试发现的起始位置
- 如果没有指定测试路径(
testpaths
),pytest
会默认从当前目录开始查找测试文件。 - 如果在配置中设置了
testpaths
(例如在pytest.ini
配置文件中),pytest
会从指定的路径开始查找。
1 .2 查找测试文件
pytest
会寻找以test_
为前缀,或者以_test
为后缀的 Python 文件。这意味着像test_example.py
或example_test.py
这样的文件名会被识别为测试文件。
1.3 查找测试函数和方法
在这些测试文件中,pytest
会查找符合特定规则的测试函数和测试方法:
-
类外的测试函数或方法: 以
test
开头的函数名(如test_addition()
)会被认为是测试函数。这些函数不需要属于任何类,区别于unitest,这使pytest的语法更加灵活。 -
类内的测试方法: 如果函数位于类中,并且该类的名称以
Test
开头(例如TestMathOperations
),且该类没有__init__
方法(即没有初始化方法),则该类中的所有以test
开头的方法也会被视为测试方法。 -
静态方法和类方法: 即使是静态方法(
@staticmethod
)和类方法(@classmethod
),如果它们以test
开头,也会被认为是测试方法。
可以在程序中执行pytest.main()函数来运行该程序内所有的测试函数和测试方法,或者在终端执行pytest <文件名>。
三、Fixture(固件)
3.1 Fixture 的定义
使用 @pytest.fixture
装饰器来定义 fixture。Fixture 可以返回任何类型的数据,包括对象、列表、字典等。
import pytest
@pytest.fixture
def database_connection():
# 建立数据库连接
conn = ... # 模拟数据库连接
print("setup")
yield conn # 将连接提供给测试函数
print("teardown")
# 关闭数据库连接
conn.close()
def test_using_database(database_connection):
# 使用 fixture 提供的数据库连接
print("test run")
assert ... # 使用conn进行断言
@pytest.fixture
装饰器定义了一个名为database_connection
的 fixture。yield conn
语句将模拟的数据库连接conn
提供给测试函数。yield
之前的代码会在测试函数运行前执行(setup预处理),yield
之后的代码会在测试函数运行后执行(teardown后处理)。test_using_database
函数通过参数database_connection
使用了这个 fixture。pytest 还会自动将 fixture 的返回值传递给该参数(此例未体现)。- 这个例子展示了 fixture 如何进行 setup 和 teardown 操作,模拟建立和关闭数据库连接,或者创建和清理临时文件等。
3.2 Fixture 的作用域 (Scope)
Fixture 的作用域决定了 fixture 的生命周期。pytest 提供了以下几种作用域:
function
(默认): 每个测试函数都会调用一次 fixture。class
: 每个测试类中的所有测试方法共享一个 fixture 实例。module
: 每个模块中的所有测试函数共享一个 fixture 实例。session
: 整个测试会话只创建一个 fixture 实例。
可以通过 scope
参数来指定 fixture 的作用域:
@pytest.fixture(scope="module")
def module_resource():
# ...
yield
3.3 Fixture 的参数化 (params)
可以使用 params
参数为 fixture 提供多组参数,从而实现参数化测试。这会在测试执行时,为每组参数运行一次测试函数。
import pytest
@pytest.fixture(params=[1, 2, 3])
def test_data(request):
return request.param
def test_with_parameterized_fixture(test_data):
print(f"Testing with data: {test_data}")
assert test_data > 0
@pytest.fixture(params=[1, 2, 3])
使用params
参数定义了一个参数化的 fixture。request.param
获取当前参数的值。test_with_parameterized_fixture
函数使用test_data
fixture。pytest 会依次使用1
、2
和3
作为test_data
的值,分别运行测试函数三次。- 这个例子展示了如何使用 fixture 进行参数化测试,简化了重复的测试代码。
四、参数化 (Parametrization)
参数化允许你使用不同的输入数据多次运行同一个测试函数。这可以有效地减少测试代码的重复,并提高测试的覆盖率。
4.1 使用 @pytest.mark.parametrize
装饰器
使用 @pytest.mark.parametrize
装饰器来定义参数化测试。该装饰器接受两个参数:
argnames
: 一个字符串或字符串列表,表示参数的名称。argvalues
: 一个列表,包含参数的值。
import pytest
@pytest.mark.parametrize("input, expected", [
(2, 4),
(3, 9),
(4, 16),
])
def test_square(input, expected):
assert input * input == expected
@pytest.mark.parametrize("input, expected", ...)
装饰器定义了参数化测试。"input, expected"
指定了两个参数的名称。[(2, 4), (3, 9), (4, 16)]
提供了三组参数值。- pytest 会依次使用这三组参数值运行
test_square
函数三次。
4.2 参数的组合
如果使用多个 @pytest.mark.parametrize
装饰器,pytest 会对参数进行组合,生成更多的测试用例。
import pytest
@pytest.mark.parametrize("x", [1, 2])
@pytest.mark.parametrize("y", [3, 4])
def test_addition(x, y):
assert x + y > 0
这个例子会生成 4 个测试用例:(1, 3)
, (1, 4)
, (2, 3)
, (2, 4)
。
4.3 参数 ID
可以使用 ids
参数为每个测试用例指定一个 ID,以便更清晰地识别测试结果。
import pytest
@pytest.mark.parametrize("input, expected", [
(2, 4),
(3, 9),
(4, 16),
], ids=["two", "three", "four"])
def test_square(input, expected):
assert input * input == expected
ids=["two", "three", "four"]
为每个测试用例指定了 ID。- 在测试结果中,会显示这些 ID,而不是默认的参数值,使测试结果更易读。
4.4 与 Fixture 结合使用
参数化可以与 fixture 结合使用,为每个参数化的测试用例提供不同的 fixture 实例。
import pytest
@pytest.fixture(params=["file1.txt", "file2.txt"])
def test_file(request):
filename = request.param
with open(filename, "w") as f:
f.write("test data")
yield filename
import os
os.remove(filename)
@pytest.mark.parametrize("content", ["hello", "world"])
def test_file_content(test_file, content):
with open(test_file, "r") as f:
f.write(content) #根据参数写入内容
with open(test_file, "r") as f:
file_content = f.read()
assert file_content == content
这个例子结合了两者。test_file
fixture 创建了两个不同的文件,而 @pytest.mark.parametrize
提供了不同的内容来写入这些文件。test_file_content
函数会运行四次,分别测试不同的文件和内容组合
五、标记函数
pytest 的标记 (marks) 功能允许你为测试函数添加元数据,从而实现更灵活的测试组织、选择和执行。你可以使用标记来:
- 对测试进行分类: 例如,将测试分为单元测试、集成测试、UI 测试等。
- 跳过或预期失败的测试: 例如,跳过某些已知会失败的测试,或者标记某些测试为预期失败。
- 控制测试执行: 例如,只运行特定标记的测试。
5.1 使用 @pytest.mark
装饰器
使用 @pytest.mark.markname
装饰器来为测试函数添加标记,其中 markname
是标记的名称。
import pytest
@pytest.mark.slow # 标记为慢速测试
def test_long_running_task():
# ...
@pytest.mark.ui # 标记为 UI 测试
def test_user_interface():
# ...
@pytest.mark.parametrize("input, expected", [(1, 1), (2, 4)])
@pytest.mark.regression # 标记为回归测试
def test_square(input, expected):
assert input * input == expected
一个测试函数可以拥有多个标记,就像 test_square
函数同时拥有 parametrize
和 regression
两个标记。
5.2 内置标记
pytest 提供了一些内置标记,用于常见的测试场景:
-
@pytest.mark.skip(reason="reason")
:无条件跳过测试。reason
参数用于提供跳过原因。@pytest.mark.skip(reason="This test is currently under development") def test_feature_not_implemented_yet(): # ...
-
@pytest.mark.skipif(condition, reason="reason")
:根据条件跳过测试。condition
是一个 Python 表达式,如果值为True
,则跳过测试。import sys @pytest.mark.skipif(sys.platform == "win32", reason="This test only runs on Linux") def test_linux_specific_feature(): # ...
-
@pytest.mark.xfail(reason="reason", raises=ExceptionType)
:标记测试为预期失败。即使测试失败,也不会将其计为失败,而是计为 xfail。raises
参数用于指定预期的异常类型。@pytest.mark.xfail(reason="This feature has a known bug") def test_buggy_feature(): # ... @pytest.mark.xfail(raises=ZeroDivisionError) def test_division_by_zero(): 1 / 0
-
@pytest.mark.parametrize
:正如之前提到的,用于参数化测试。
5.3 自定义标记
你可以创建自定义标记,用于更灵活地组织和选择测试。你需要在 pytest.ini
文件中注册自定义标记(推荐这样子做),以避免警告。
pytest.ini
文件示例:
[pytest]
markers =
slow: mark test as slow to run
ui: mark test as ui test
regression: mark test as regression test
5.4 使用标记运行测试
使用 -m
选项可以运行带有特定标记的测试。
-
运行所有标记为
slow
的测试:pytest -m slow
-
运行所有标记为
slow
或ui
的测试:pytest -m "slow or ui"
-
运行所有未标记为
slow
的测试pytest -m "not slow"
5.5 结合 Fixture 和参数化
标记可以与 Fixture 和参数化结合使用,以实现更复杂的测试场景。例如,你可以使用标记来选择运行哪些参数化测试:
import pytest
@pytest.fixture(params=[1, 2, 3], ids=["one", "two", "three"])
def input_value(request):
return request.param
@pytest.mark.fast
def test_fast_calculation(input_value):
assert input_value * 2 < 10
@pytest.mark.slow
def test_slow_calculation(input_value):
assert input_value ** 2 < 20
# 只运行标记为 fast 的测试
# pytest -m fast
# 只运行标记为 slow 的测试
# pytest -m slow
# 同时运行 fast 和 slow 的测试
# pytest -m "fast or slow"