Pytest 的参数化测试详解
1. 什么是参数化测试
1.1 基本概念
参数化测试(Parametrized Testing) 是 pytest 提供的一个强大功能,它允许你使用不同的输入数据运行同一个测试函数多次,从而用最少的代码测试多种情况。
1.2 形象比喻
想象一下,你要测试一个计算器:
传统方式(非参数化):
def test_add_1_and_2():
assert 1 + 2 == 3
def test_add_3_and_4():
assert 3 + 4 == 7
def test_add_5_and_6():
assert 5 + 6 == 11
你需要为每一组数据写一个测试函数,代码重复且繁琐。如果有100组数据,就要写100个函数!
参数化方式:
@pytest.mark.parametrize("a, b, expected", [
(1, 2, 3),
(3, 4, 7),
(5, 6, 11)
])
def test_add(a, b, expected):
assert a + b == expected
只需要一个测试函数,通过不同的数据来执行测试,代码简洁且易于维护。添加新的测试数据只需要在列表中添加一行即可!
1.3 参数化测试的优势
- 减少代码重复:相同的测试逻辑只需要写一次
- 易于维护:修改测试逻辑只需要改一个地方
- 易于扩展:添加新的测试数据只需要在数据列表中添加即可
- 清晰的测试报告:每个数据组合都会生成独立的测试用例报告
- 提高测试覆盖率:可以轻松测试大量不同的数据组合
- 数据与逻辑分离:测试数据和测试逻辑分离,更清晰
1.4 参数化测试的应用场景
- 接口测试:测试不同参数组合的接口响应
- 表单验证:测试各种输入数据的验证规则
- 边界值测试:测试边界值和异常值
- 多环境测试:在不同环境下使用相同测试逻辑
- 批量数据处理:测试大量数据的处理逻辑
- 数学运算测试:测试不同数值的运算结果
- 字符串处理测试:测试不同字符串的处理结果
2. @pytest.mark.parametrize 基础
2.1 什么是 parametrize
@pytest.mark.parametrize 是 pytest 提供的装饰器,用于实现参数化测试。它允许你为测试函数提供多组参数,pytest 会为每组参数创建一个独立的测试用例。
2.2 基本语法格式
@pytest.mark.parametrize("参数名1, 参数名2, ...", [
(值1, 值2, ...),
(值3, 值4, ...),
...
])
def test_function(参数名1, 参数名2, ...):
# 测试代码
pass
语法说明:
- 第一个参数:字符串,包含用逗号分隔的参数名(注意:逗号后面有空格也可以,但建议保持一致)
- 第二个参数:列表或元组,包含多组测试数据
- 每组数据是一个元组,对应参数名中的参数
- 测试函数的参数名必须与装饰器中的参数名完全一致
2.3 最简单的示例
2.3.1 示例 1:单个参数
import pytest
@pytest.mark.parametrize("number", [1, 2, 3, 4, 5])
def test_is_positive(number):
"""测试数字是否为正数"""
assert number > 0
运行结果:
test_example.py::test_is_positive[1] PASSED
test_example.py::test_is_positive[2] PASSED
test_example.py::test_is_positive[3] PASSED
test_example.py::test_is_positive[4] PASSED
test_example.py::test_is_positive[5] PASSED
说明:
- 这个测试会运行 5 次,每次使用不同的数字
[1]、[2]等是 pytest 自动生成的测试用例 ID
2.3.2 示例 2:两个参数
import pytest
@pytest.mark.parametrize("a, b", [
(1, 2),
(3, 4),
(5, 6)
])
def test_add(a, b):
"""测试加法"""
result = a + b
assert result == a + b
print(f"{a} + {b} = {result}")
运行结果:
test_example.py::test_add[1-2] PASSED
test_example.py::test_add[3-4] PASSED
test_example.py::test_add[5-6] PASSED
说明:
- 这个测试会运行 3 次,每次使用不同的参数组合
[1-2]表示参数 a=1, b=2 的测试用例
2.3.3 示例 3:三个参数
import pytest
@pytest.mark.parametrize("username, password, expected", [
("admin", "admin123", True),
("user", "user123", True),
("invalid", "wrong", False)
])
def test_login(username, password, expected):
"""测试登录功能"""
# 模拟登录逻辑
actual = (username == "admin" and password == "admin123") or
(username == "user" and password == "user123")
assert actual == expected
运行结果:
test_example.py::test_login[admin-admin123-True] PASSED
test_example.py::test_login[user-user123-True] PASSED
test_example.py::test_login[invalid-wrong-False] PASSED
2.4 参数名的命名规则
参数名必须符合 Python 变量命名规则:
- 只能包含字母、数字和下划线
- 不能以数字开头
- 不能是 Python 关键字(如
if、for、class等) - 建议使用有意义的名称
正确示例:
@pytest.mark.parametrize("user_name, password", [
("admin", "123456"),
("user", "password")
])
def test_login(user_name, password):
pass
错误示例:
# 错误:参数名不能包含空格
@pytest.mark.parametrize("user name, password", [
("admin", "123456")
])
def test_login(user name, password): # 语法错误
pass
# 错误:参数名不能是关键字
@pytest.mark.parametrize("if, else", [
(1, 2)
])
def test_example(if, else): # 语法错误
pass
3. 不同数据类型的参数化
3.1 使用整数作为测试数据
整数是最常用的数据类型之一,适合测试数学运算、计数等功能。
示例:测试平方运算
import pytest
@pytest.mark.parametrize("number, expected", [
(1, 1),
(2, 4),
(3, 9),
(4, 16),
(5, 25),
(0, 0),
(-1, 1), # 负数的平方也是正数
(-2, 4)
])
def test_square(number, expected):
"""测试数字的平方"""
result = number ** 2
assert result == expected, f"{number} 的平方应该是 {expected},但得到 {result}"
运行结果:
test_example.py::test_square[1-1] PASSED
test_example.py::test_square[2-4] PASSED
test_example.py::test_square[3-9] PASSED
test_example.py::test_square[4-16] PASSED
test_example.py::test_square[5-25] PASSED
test_example.py::test_square[0-0] PASSED
test_example.py::test_square[-1-1] PASSED
test_example.py::test_square[-2-4] PASSED
3.2 使用字符串作为测试数据
字符串适合测试文本处理、验证、格式化等功能。
示例 1:测试字符串长度
import pytest
@pytest.mark.parametrize("text, expected_length", [
("hello", 5),
("world", 5),
("pytest", 6),
("", 0), # 空字符串
("a", 1), # 单个字符
("测试", 2), # 中文字符
("hello world", 11) # 包含空格
])
def test_string_length(text, expected_length):
"""测试字符串长度"""
assert len(text) == expected_length
示例 2:测试邮箱格式验证
import pytest
import re
@pytest.mark.parametrize("email, is_valid", [
("user@example.com", True),
("test.email@domain.co.uk", True),
("name+tag@example.org", True),
("invalid.email", False), # 无效邮箱:缺少@
("@example.com", False), # 无效邮箱:@前为空
("user@", False), # 无效邮箱:@后为空
("user@.com", False), # 无效邮箱:域名不完整
("", False) # 空字符串
])
def test_email_format(email, is_valid):
"""测试邮箱格式"""
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}$'
actual = bool(re.match(pattern, email))
assert actual == is_valid, f"邮箱 {email} 的验证结果不符合预期"
3.3 使用列表(List)作为测试数据
列表适合存储简单的数据组合,是最常用的数据格式。
示例:测试列表操作
import pytest
@pytest.mark.parametrize("input_list, expected_sum", [
([1, 2, 3], 6),
([4, 5, 6], 15),
([10, 20, 30], 60),
([], 0), # 空列表
([5], 5), # 单个元素
([-1, -2, -3], -6) # 负数列表
])
def test_list_sum(input_list, expected_sum):
"""测试列表求和"""
result = sum(input_list)
assert result == expected_sum
示例:测试列表排序
import pytest
@pytest.mark.parametrize("input_list, expected", [
([3, 1, 4, 1, 5], [1, 1, 3, 4, 5]),
([5, 4, 3, 2, 1], [1, 2, 3, 4, 5]),
([1], [1]), # 单个元素
([], []), # 空列表
([2, 2, 2], [2, 2, 2]) # 相同元素
])
def test_list_sort(input_list, expected):
"""测试列表排序"""
result = sorted(input_list)
assert result == expected
3.4 使用元组(Tuple)作为测试数据
元组和列表在参数化中用法相同,但元组是不可变的,更适合表示固定的测试数据。
示例:测试数学运算
import pytest
@pytest.mark.parametrize("a, b, operation, expected", [
(10, 5, "add", 15),
(10, 5, "subtract", 5),
(10, 5, "multiply", 50),
(10, 5, "divide", 2),
(0, 5, "add", 5),
(10, 0, "multiply", 0)
])
def test_math_operations(a, b, operation, expected):
"""测试基本数学运算"""
if operation == "add":
result = a + b
elif operation == "subtract":
result = a - b
elif operation == "multiply":
result = a * b
elif operation == "divide":
result = a / b
else:
raise ValueError(f"未知操作:{operation}")
assert result == expected
3.5 使用字典(Dict)作为测试数据
字典适合存储复杂的测试数据,每个字典代表一组测试数据。
示例 1:测试用户信息
import pytest
@pytest.mark.parametrize("user_data", [
{"name": "张三", "age": 25, "email": "zhangsan@example.com"},
{"name": "李四", "age": 30, "email": "lisi@example.com"},
{"name": "王五", "age": 28, "email": "wangwu@example.com"}
])
def test_user_info(user_data):
"""测试用户信息"""
assert "name" in user_data
assert "age" in user_data
assert "email" in user_data
assert user_data["age"] > 0
assert "@" in user_data["email"]
print(f"用户:{user_data['name']},年龄:{user_data['age']},邮箱:{user_data['email']}")
示例 2:解包字典数据
如果你想让测试函数直接接收字典的各个字段,可以这样写:
import pytest
@pytest.mark.parametrize("name, age, email", [
("张三", 25, "zhangsan@example.com"),
("李四", 30, "lisi@example.com"),
("王五", 28, "wangwu@example.com")
])
def test_user_info(name, age, email):
"""测试用户信息(解包方式)"""
assert isinstance(name, str)
assert age > 0
assert "@" in email
print(f"用户:{name},年龄:{age},邮箱:{email}")
示例 3:混合使用字典和元组
import pytest
@pytest.mark.parametrize("user_data, expected_role", [
({"name": "admin", "permissions": ["read", "write", "delete"]}, "admin"),
({"name": "user", "permissions": ["read"]}, "user"),
({"name": "guest", "permissions": []}, "guest")
])
def test_user_role(user_data, expected_role):
"""测试用户角色"""
if "delete" in user_data["permissions"]:
actual_role = "admin"
elif "read" in user_data["permissions"]:
actual_role = "user"
else:
actual_role = "guest"
assert actual_role == expected_role
3.6 使用布尔值作为测试数据
布尔值适合测试开关、标志位、条件判断等功能。
示例:测试功能开关
import pytest
@pytest.mark.parametrize("feature_enabled, should_work", [
(True, True),
(False, False)
])
def test_feature_flag(feature_enabled, should_work):
"""测试功能开关"""
if feature_enabled:
assert should_work is True
else:
assert should_work is False
示例:测试条件判断
import pytest
@pytest.mark.parametrize("is_admin, can_delete, can_edit", [
(True, True, True), # 管理员可以删除和编辑
(False, False, True), # 普通用户可以编辑但不能删除
(False, False, False) # 访客不能删除也不能编辑
])
def test_permissions(is_admin, can_delete, can_edit):
"""测试权限"""
if is_admin:
assert can_delete is True
assert can_edit is True
else:
assert can_delete is False
3.7 使用 None 作为测试数据
None 适合测试空值、默认值、可选参数等场景。
示例:测试默认值处理
import pytest
@pytest.mark.parametrize("value, default, expected", [
(None, "default", "default"),
("", "default", "default"),
("actual_value", "default", "actual_value"),
(0, "default", 0), # 注意:0 不是 None
(False, "default", False) # 注意:False 不是 None
])
def test_default_value(value, default, expected):
"""测试默认值处理"""
result = value if value is not None and value != "" else default
assert result == expected
示例:测试可选参数
import pytest
def process_data(data, prefix=None):
"""处理数据的函数"""
if prefix is None:
return data
return f"{prefix}_{data}"
@pytest.mark.parametrize("data, prefix, expected", [
("test", None, "test"),
("test", "pre", "pre_test"),
("data", None, "data"),
("data", "prefix", "prefix_data")
])
def test_process_data(data, prefix, expected):
"""测试数据处理"""
result = process_data(data, prefix)
assert result == expected
3.8 使用浮点数作为测试数据
浮点数适合测试需要精确度的计算。
示例:测试浮点数运算
import pytest
@pytest.mark.parametrize("a, b, expected", [
(1.5, 2.5, 4.0),
(0.1, 0.2, 0.3),
(3.14, 2.86, 6.0),
(10.0, 5.0, 15.0)
])
def test_float_add(a, b, expected):
"""测试浮点数加法"""
result = a + b
# 注意:浮点数比较时使用近似相等
assert abs(result - expected) < 0.0001
注意:浮点数比较时,由于精度问题,应该使用近似相等而不是直接相等。
4. 多参数组合测试
4.1 两个参数的组合
示例:测试登录功能
import pytest
@pytest.mark.parametrize("username, password", [
("admin", "admin123"),
("user", "user123"),
("guest", "guest123"),
("", ""), # 空用户名和密码
("admin", ""), # 空密码
("", "admin123") # 空用户名
])
def test_login(username, password):
"""测试登录功能"""
# 模拟登录逻辑
if username and password:
assert len(username) > 0
assert len(password) > 0
print(f"尝试登录:用户名={username}, 密码={password}")
else:
print("用户名或密码为空,登录失败")
assert not username or not password
4.2 三个参数的组合
示例:测试计算器功能
import pytest
@pytest.mark.parametrize("a, b, operation", [
(10, 5, "add"),
(10, 5, "subtract"),
(10, 5, "multiply"),
(10, 5, "divide"),
(0, 5, "add"),
(10, 0, "divide") # 除零测试
])
def test_calculator(a, b, operation):
"""测试计算器功能"""
if operation == "add":
result = a + b
assert result == 15 if (a, b) == (10, 5) else result == 5
elif operation == "subtract":
result = a - b
assert result == 5
elif operation == "multiply":
result = a * b
assert result == 50
elif operation == "divide":
if b == 0:
# 应该抛出除零错误
with pytest.raises(ZeroDivisionError):
result = a / b
else:
result = a / b
assert result == 2
4.3 四个及以上参数的组合
示例:测试用户注册功能
import pytest
@pytest.mark.parametrize("username, password, email, age", [
("user1", "pass123", "user1@example.com", 25),
("user2", "pass456", "user2@example.com", 30),
("user3", "pass789", "user3@example.com", 18),
("", "pass123", "user4@example.com", 25), # 空用户名
("user5", "", "user5@example.com", 25), # 空密码
("user6", "pass123", "invalid_email", 25), # 无效邮箱
("user7", "pass123", "user7@example.com", 15) # 年龄不足
])
def test_register(username, password, email, age):
"""测试用户注册功能"""
errors = []
# 验证用户名
if not username:
errors.append("用户名不能为空")
# 验证密码
if not password:
errors.append("密码不能为空")
# 验证邮箱
if "@" not in email:
errors.append("邮箱格式不正确")
# 验证年龄
if age < 18:
errors.append("年龄必须大于等于18岁")
# 如果有错误,注册应该失败
if errors:
print(f"注册失败:{', '.join(errors)}")
assert len(errors) > 0
else:
print(f"注册成功:用户名={username}, 邮箱={email}, 年龄={age}")
assert len(username) > 0
assert len(password) > 0
assert "@" in email
assert age >= 18
4.4 参数组合的数量计算
当你使用 @pytest.mark.parametrize 时,测试用例的数量等于所有参数组合的数量。
示例:计算测试用例数量
import pytest
# 3 组数据 = 3 个测试用例
@pytest.mark.parametrize("a, b", [
(1, 2),
(3, 4),
(5, 6)
])
def test_example(a, b):
pass
# 结果:3 个测试用例
示例:更多参数组合
import pytest
# 5 组数据 = 5 个测试用例
@pytest.mark.parametrize("x, y, z", [
(1, 2, 3),
(4, 5, 6),
(7, 8, 9),
(10, 11, 12),
(13, 14, 15)
])
def test_three_params(x, y, z):
assert x + y + z == x + y + z
# 结果:5 个测试用例
5. 嵌套参数化
5.1 什么是嵌套参数化
嵌套参数化是指在一个测试函数上使用多个 @pytest.mark.parametrize 装饰器,pytest 会生成所有可能的参数组合(笛卡尔积)。
5.2 基本用法
示例:测试不同浏览器和操作系统的组合
import pytest
@pytest.mark.parametrize("browser", ["Chrome", "Firefox", "Safari"])
@pytest.mark.parametrize("os", ["Windows", "Mac", "Linux"])
def test_cross_browser_os(browser, os):
"""测试不同浏览器和操作系统的组合"""
print(f"测试环境:{browser} on {os}")
# 这里会生成 3 * 3 = 9 个测试用例
assert browser in ["Chrome", "Firefox", "Safari"]
assert os in ["Windows", "Mac", "Linux"]
运行结果:
test_example.py::test_cross_browser_os[Chrome-Windows] PASSED
test_example.py::test_cross_browser_os[Chrome-Mac] PASSED
test_example.py::test_cross_browser_os[Chrome-Linux] PASSED
test_example.py::test_cross_browser_os[Firefox-Windows] PASSED
test_example.py::test_cross_browser_os[Firefox-Mac] PASSED
test_example.py::test_cross_browser_os[Firefox-Linux] PASSED
test_example.py::test_cross_browser_os[Safari-Windows] PASSED
test_example.py::test_cross_browser_os[Safari-Mac] PASSED
test_example.py::test_cross_browser_os[Safari-Linux] PASSED
说明:嵌套参数化会生成所有可能的组合,总共 3 × 3 = 9 个测试用例。
5.3 嵌套参数化的执行顺序
嵌套参数化的执行顺序是:外层参数变化最慢,内层参数变化最快。
示例:理解执行顺序
import pytest
@pytest.mark.parametrize("outer", [1, 2])
@pytest.mark.parametrize("inner", ["a", "b", "c"])
def test_order(outer, inner):
"""理解嵌套参数化的执行顺序"""
print(f"outer={outer}, inner={inner}")
执行顺序:
outer=1, inner=a
outer=1, inner=b
outer=1, inner=c
outer=2, inner=a
outer=2, inner=b
outer=2, inner=c
说明:
- 外层参数
outer先保持为 1,内层参数inner依次变化:a, b, c - 然后外层参数
outer变为 2,内层参数inner再次依次变化:a, b, c
5.4 三层嵌套参数化
示例:测试 API 的不同方法、端点和状态码
import pytest
@pytest.mark.parametrize("method", ["GET", "POST", "PUT"])
@pytest.mark.parametrize("endpoint", ["/api/users", "/api/products"])
@pytest.mark.parametrize("status_code", [200, 201, 400])
def test_api_combinations(method, endpoint, status_code):
"""测试 API 的不同组合"""
print(f"测试 {method} {endpoint},期望状态码 {status_code}")
# 这会生成 3 * 2 * 3 = 18 个测试用例
assert method in ["GET", "POST", "PUT"]
assert endpoint.startswith("/api/")
assert status_code in [200, 201, 400]
说明:这个测试会生成 3 × 2 × 3 = 18 个测试用例。
5.5 嵌套参数化的实际应用场景
示例:测试不同用户角色和权限的组合
import pytest
@pytest.mark.parametrize("user_role", ["admin", "user", "guest"])
@pytest.mark.parametrize("action", ["read", "write", "delete"])
def test_permissions(user_role, action):
"""测试不同角色的权限"""
# 定义权限矩阵
permissions = {
"admin": ["read", "write", "delete"],
"user": ["read", "write"],
"guest": ["read"]
}
has_permission = action in permissions[user_role]
if user_role == "admin":
assert has_permission is True
elif user_role == "user":
assert has_permission is (action != "delete")
else: # guest
assert has_permission is (action == "read")
print(f"角色 {user_role} 执行 {action} 操作:{'允许' if has_permission else '拒绝'}")
注意:嵌套参数化会生成大量的测试用例(参数数量的乘积),使用时要注意控制数量,避免测试时间过长。
6. 参数化与 ID 标识
6.1 什么是 ID 标识
当你使用参数化时,pytest 会自动为每个测试用例生成一个 ID(标识符),用于在测试报告中区分不同的测试用例。默认情况下,ID 是基于参数值生成的。
6.2 查看默认 ID
示例:查看默认生成的 ID
import pytest
@pytest.mark.parametrize("a, b", [
(1, 2),
(3, 4),
(5, 6)
])
def test_add(a, b):
assert a + b == a + b
运行命令:
pytest -v test_example.py
输出:
test_example.py::test_add[1-2] PASSED
test_example.py::test_add[3-4] PASSED
test_example.py::test_add[5-6] PASSED
这里的 [1-2]、[3-4]、[5-6] 就是自动生成的 ID。
6.3 自定义 ID
你可以通过 ids 参数为每组测试数据指定自定义的 ID,使测试报告更加清晰易读。
基本语法:
@pytest.mark.parametrize("参数名", 数据列表, ids=["ID1", "ID2", ...])
示例:自定义测试用例 ID
import pytest
@pytest.mark.parametrize("username, password", [
("admin", "admin123"),
("user", "user123"),
("guest", "guest123"),
], ids=["管理员登录", "普通用户登录", "访客登录"])
def test_login(username, password):
"""测试登录功能"""
assert len(username) > 0
assert len(password) > 0
运行结果:
test_example.py::test_login[管理员登录] PASSED
test_example.py::test_login[普通用户登录] PASSED
test_example.py::test_login[访客登录] PASSED
说明:现在测试报告中使用的是中文 ID,更容易理解每个测试用例的含义。
6.4 使用函数生成 ID
如果测试数据很多,手动写 ID 会很麻烦,可以使用函数来动态生成 ID。
示例:使用函数生成 ID
import pytest
def id_func(val):
"""根据参数值生成 ID"""
if isinstance(val, tuple):
return f"a={val[0]}_b={val[1]}"
return str(val)
@pytest.mark.parametrize("a, b", [
(1, 2),
(3, 4),
(5, 6)
], ids=id_func)
def test_add(a, b):
assert a + b == a + b
更实用的示例:
import pytest
def make_id(val):
"""生成更友好的测试 ID"""
if isinstance(val, dict):
return f"用户_{val['name']}"
return str(val)
@pytest.mark.parametrize("user", [
{"name": "张三", "age": 25},
{"name": "李四", "age": 30},
{"name": "王五", "age": 28}
], ids=make_id)
def test_user(user):
"""测试用户信息"""
assert "name" in user
assert "age" in user
运行结果:
test_example.py::test_user[用户_张三] PASSED
test_example.py::test_user[用户_李四] PASSED
test_example.py::test_user[用户_王五] PASSED
6.5 ID 的作用
- 提高可读性:在测试报告中更容易理解每个测试用例的含义
- 便于调试:当测试失败时,可以通过 ID 快速定位问题
- 便于筛选:可以使用
-k参数根据 ID 筛选测试用例
示例:使用 ID 筛选测试用例
# 只运行包含"管理员"的测试用例
pytest -k "管理员"
# 只运行包含"用户_张三"的测试用例
pytest -k "用户_张三"
7. 从文件读取测试数据
7.1 为什么需要从文件读取数据
在实际项目中,测试数据可能非常多,如果都写在代码中会:
- 代码冗长,难以维护
- 测试数据和测试逻辑混合,不符合单一职责原则
- 非技术人员无法修改测试数据
- 数据修改需要修改代码,容易出错
因此,将测试数据存储在外部文件中是一个更好的选择。
7.2 从 JSON 文件读取数据
JSON 是一种常用的数据格式,适合存储结构化的测试数据。
步骤 1:创建 JSON 数据文件
创建 test_data.json:
{
"login_data": [
{
"username": "admin",
"password": "admin123",
"expected": true
},
{
"username": "user",
"password": "user123",
"expected": true
},
{
"username": "invalid",
"password": "wrong",
"expected": false
}
],
"user_data": [
{
"name": "张三",
"age": 25,
"email": "zhangsan@example.com"
},
{
"name": "李四",
"age": 30,
"email": "lisi@example.com"
}
]
}
步骤 2:在测试中读取 JSON 数据
import pytest
import json
import os
# 获取 JSON 文件路径
def load_test_data(file_name):
"""加载测试数据文件"""
file_path = os.path.join(os.path.dirname(__file__), file_name)
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
# 加载数据
test_data = load_test_data('test_data.json')
@pytest.mark.parametrize("login_info", test_data["login_data"])
def test_login_from_json(login_info):
"""从 JSON 文件读取登录测试数据"""
username = login_info["username"]
password = login_info["password"]
expected = login_info["expected"]
# 模拟登录逻辑
actual = (username == "admin" and password == "admin123") or
(username == "user" and password == "user123")
assert actual == expected, f"登录测试失败:用户名={username}, 密码={password}"
更简洁的方式:直接解包
import pytest
import json
import os
def load_json_data(file_name, key):
"""加载 JSON 文件中指定键的数据"""
file_path = os.path.join(os.path.dirname(__file__), file_name)
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
return data[key]
# 加载登录数据
login_data = load_json_data('test_data.json', 'login_data')
@pytest.mark.parametrize("username, password, expected", [
(item["username"], item["password"], item["expected"])
for item in login_data
])
def test_login(username, password, expected):
"""从 JSON 文件读取登录测试数据"""
actual = (username == "admin" and password == "admin123") or
(username == "user" and password == "user123")
assert actual == expected
7.3 从 YAML 文件读取数据
YAML 格式更加简洁易读,特别适合编写测试数据。
步骤 1:安装 PyYAML
pip install PyYAML
步骤 2:创建 YAML 数据文件
创建 test_data.yaml:
login_data:
- username: admin
password: admin123
expected: true
- username: user
password: user123
expected: true
- username: invalid
password: wrong
expected: false
user_data:
- name: 张三
age: 25
email: zhangsan@example.com
- name: 李四
age: 30
email: lisi@example.com
步骤 3:在测试中读取 YAML 数据
import pytest
import yaml
import os
def load_yaml_data(file_name, key):
"""加载 YAML 文件中指定键的数据"""
file_path = os.path.join(os.path.dirname(__file__), file_name)
with open(file_path, 'r', encoding='utf-8') as f:
data = yaml.safe_load(f)
return data[key]
# 加载登录数据
login_data = load_yaml_data('test_data.yaml', 'login_data')
@pytest.mark.parametrize("username, password, expected", [
(item["username"], item["password"], item["expected"])
for item in login_data
])
def test_login_from_yaml(username, password, expected):
"""从 YAML 文件读取登录测试数据"""
actual = (username == "admin" and password == "admin123") or
(username == "user" and password == "user123")
assert actual == expected
7.4 从 CSV 文件读取数据
CSV 格式适合存储表格数据,特别适合大量测试数据。
步骤 1:创建 CSV 数据文件
创建 test_data.csv:
username,password,expected
admin,admin123,true
user,user123,true
invalid,wrong,false
guest,guest123,true
步骤 2:在测试中读取 CSV 数据
import pytest
import csv
import os
def load_csv_data(file_name):
"""加载 CSV 文件数据"""
file_path = os.path.join(os.path.dirname(__file__), file_name)
data = []
with open(file_path, 'r', encoding='utf-8') as f:
reader = csv.DictReader(f)
for row in reader:
# 转换 expected 为布尔值
row['expected'] = row['expected'].lower() == 'true'
data.append(row)
return data
# 加载 CSV 数据
csv_data = load_csv_data('test_data.csv')
@pytest.mark.parametrize("username, password, expected", [
(row["username"], row["password"], row["expected"])
for row in csv_data
])
def test_login_from_csv(username, password, expected):
"""从 CSV 文件读取登录测试数据"""
actual = (username == "admin" and password == "admin123") or
(username == "user" and password == "user123") or
(username == "guest" and password == "guest123")
assert actual == expected
7.5 从 Excel 文件读取数据
Excel 文件适合非技术人员编辑测试数据。
步骤 1:安装 openpyxl
pip install openpyxl
步骤 2:创建 Excel 数据文件
创建 test_data.xlsx,包含以下数据: |
username | password | expected |
|---|---|---|---|
| admin | admin123 | true | |
| user | user123 | true | |
| invalid | wrong | false |
步骤 3:在测试中读取 Excel 数据
import pytest
from openpyxl import load_workbook
import os
def load_excel_data(file_name, sheet_name="Sheet1"):
"""加载 Excel 文件数据"""
file_path = os.path.join(os.path.dirname(__file__), file_name)
wb = load_workbook(file_path)
ws = wb[sheet_name]
data = []
# 读取表头
headers = [cell.value for cell in ws[1]]
# 读取数据行
for row in ws.iter_rows(min_row=2, values_only=True):
row_dict = dict(zip(headers, row))
# 转换 expected 为布尔值
if 'expected' in row_dict:
row_dict['expected'] = str(row_dict['expected']).lower() == 'true'
data.append(row_dict)
return data
# 加载 Excel 数据
excel_data = load_excel_data('test_data.xlsx')
@pytest.mark.parametrize("username, password, expected", [
(row["username"], row["password"], row["expected"])
for row in excel_data
])
def test_login_from_excel(username, password, expected):
"""从 Excel 文件读取登录测试数据"""
actual = (username == "admin" and password == "admin123") or
(username == "user" and password == "user123")
assert actual == expected
7.6 数据文件的最佳实践
- 文件组织:将测试数据文件放在专门的
data或test_data目录中 - 文件命名:使用清晰的命名,如
login_test_data.json - 数据验证:在读取数据后进行验证,确保数据格式正确
- 错误处理:添加异常处理,处理文件不存在或格式错误的情况
示例:完整的数据加载工具
import pytest
import json
import os
from pathlib import Path
class TestDataLoader:
"""测试数据加载器"""
def __init__(self, data_dir="test_data"):
"""初始化数据加载器"""
self.data_dir = Path(__file__).parent / data_dir
def load_json(self, file_name, key=None):
"""加载 JSON 文件"""
file_path = self.data_dir / file_name
if not file_path.exists():
raise FileNotFoundError(f"数据文件不存在:{file_path}")
try:
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
return data[key] if key else data
except json.JSONDecodeError as e:
raise ValueError(f"JSON 格式错误:{e}")
def load_yaml(self, file_name, key=None):
"""加载 YAML 文件"""
import yaml
file_path = self.data_dir / file_name
if not file_path.exists():
raise FileNotFoundError(f"数据文件不存在:{file_path}")
try:
with open(file_path, 'r', encoding='utf-8') as f:
data = yaml.safe_load(f)
return data[key] if key else data
except yaml.YAMLError as e:
raise ValueError(f"YAML 格式错误:{e}")
# 使用示例
loader = TestDataLoader()
login_data = loader.load_json('login_data.json', 'login_data')
@pytest.mark.parametrize("username, password, expected", [
(item["username"], item["password"], item["expected"])
for item in login_data
])
def test_login(username, password, expected):
"""测试登录"""
# 测试逻辑
pass
8. 参数化与 Fixture 结合
8.1 什么是 Fixture
Fixture 是 pytest 提供的用于准备测试环境和清理测试环境的机制。它可以与参数化结合使用,提供更灵活的测试数据管理。
8.2 在 Fixture 中使用参数化
你可以创建一个参数化的 Fixture,为测试提供不同的数据。
示例:参数化的 Fixture
import pytest
@pytest.fixture(params=["Chrome", "Firefox", "Safari"])
def browser(request):
"""参数化的浏览器 Fixture"""
browser_name = request.param
print(f"初始化浏览器:{browser_name}")
yield browser_name
print(f"关闭浏览器:{browser_name}")
def test_with_browser(browser):
"""使用参数化的浏览器 Fixture"""
print(f"使用浏览器:{browser}")
assert browser in ["Chrome", "Firefox", "Safari"]
运行结果:
初始化浏览器:Chrome
使用浏览器:Chrome
关闭浏览器:Chrome
初始化浏览器:Firefox
使用浏览器:Firefox
关闭浏览器:Firefox
初始化浏览器:Safari
使用浏览器:Safari
关闭浏览器:Safari
8.3 Fixture 与参数化装饰器结合
你可以同时使用 @pytest.fixture 和 @pytest.mark.parametrize,实现更复杂的数据组合。
示例:Fixtures 和参数化结合
import pytest
@pytest.fixture
def user_data():
"""提供用户数据"""
return {"name": "测试用户", "role": "admin"}
@pytest.mark.parametrize("action", ["create", "update", "delete"])
def test_user_actions(user_data, action):
"""测试用户操作"""
print(f"执行操作:{action},用户:{user_data['name']}")
assert user_data["name"] == "测试用户"
assert action in ["create", "update", "delete"]
说明:这个测试会运行 3 次,每次使用不同的 action,但都使用相同的 user_data。
8.4 多个参数化 Fixture 的组合
示例:多个参数化 Fixture
import pytest
@pytest.fixture(params=["Windows", "Linux", "Mac"])
def operating_system(request):
"""参数化的操作系统 Fixture"""
return request.param
@pytest.fixture(params=["Chrome", "Firefox"])
def browser(request):
"""参数化的浏览器 Fixture"""
return request.param
def test_cross_platform(operating_system, browser):
"""测试跨平台组合"""
print(f"测试环境:{operating_system} + {browser}")
# 这会生成 3 * 2 = 6 个测试用例
assert operating_system in ["Windows", "Linux", "Mac"]
assert browser in ["Chrome", "Firefox"]
说明:这个测试会生成 3 × 2 = 6 个测试用例,测试所有操作系统和浏览器的组合。
8.5 从 Fixture 读取测试数据
示例:使用 Fixture 加载测试数据
import pytest
import json
import os
@pytest.fixture(scope="module")
def login_test_data():
"""加载登录测试数据"""
file_path = os.path.join(os.path.dirname(__file__), "test_data.json")
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
return data["login_data"]
@pytest.mark.parametrize("login_info", [
pytest.param({"username": "admin", "password": "admin123", "expected": True}, id="管理员登录"),
pytest.param({"username": "user", "password": "user123", "expected": True}, id="普通用户登录"),
])
def test_login(login_info):
"""测试登录"""
username = login_info["username"]
password = login_info["password"]
expected = login_info["expected"]
actual = (username == "admin" and password == "admin123") or
(username == "user" and password == "user123")
assert actual == expected
9. 动态参数化
9.1 什么是动态参数化
动态参数化是指在运行时动态生成测试数据,而不是在代码中硬编码。这对于需要根据环境、配置或其他条件生成测试数据的场景非常有用。
9.2 使用 pytest_generate_tests 钩子
pytest_generate_tests 是 pytest 提供的钩子函数,允许你在运行时动态生成测试参数。
基本语法:
def pytest_generate_tests(metafunc):
"""动态生成测试参数"""
if "param_name" in metafunc.fixturenames:
# 动态生成参数
metafunc.parametrize("param_name", [值1, 值2, ...])
示例:根据环境变量动态生成测试数据
import pytest
import os
def pytest_generate_tests(metafunc):
"""根据环境变量动态生成测试数据"""
if "environment" in metafunc.fixturenames:
# 从环境变量获取环境列表
envs = os.getenv("TEST_ENVIRONMENTS", "dev,test,prod").split(",")
metafunc.parametrize("environment", envs)
def test_api(environment):
"""测试不同环境的 API"""
print(f"测试环境:{environment}")
assert environment in ["dev", "test", "prod"]
运行方式:
# 使用默认环境
pytest test_example.py
# 指定环境
TEST_ENVIRONMENTS=dev,test pytest test_example.py
9.3 从数据库读取测试数据
示例:从数据库动态加载测试数据
import pytest
import sqlite3
def pytest_generate_tests(metafunc):
"""从数据库动态加载测试数据"""
if "user_id" in metafunc.fixturenames:
# 连接数据库
conn = sqlite3.connect('test.db')
cursor = conn.cursor()
# 查询用户 ID
cursor.execute("SELECT id FROM users WHERE status = 'active'")
user_ids = [row[0] for row in cursor.fetchall()]
conn.close()
# 参数化
metafunc.parametrize("user_id", user_ids)
def test_user(user_id):
"""测试用户功能"""
print(f"测试用户 ID:{user_id}")
assert user_id > 0
9.4 根据配置文件动态生成参数
示例:从配置文件读取参数
import pytest
import json
import os
def load_config():
"""加载配置文件"""
config_path = os.path.join(os.path.dirname(__file__), "config.json")
with open(config_path, 'r', encoding='utf-8') as f:
return json.load(f)
def pytest_generate_tests(metafunc):
"""根据配置文件动态生成测试参数"""
config = load_config()
if "api_endpoint" in metafunc.fixturenames:
endpoints = config.get("api_endpoints", [])
metafunc.parametrize("api_endpoint", endpoints)
if "timeout" in metafunc.fixturenames:
timeouts = config.get("timeouts", [5, 10, 30])
metafunc.parametrize("timeout", timeouts)
def test_api_call(api_endpoint, timeout):
"""测试 API 调用"""
print(f"调用 API:{api_endpoint},超时时间:{timeout}秒")
assert api_endpoint.startswith("http")
assert timeout > 0
9.5 动态参数化的优势
- 灵活性:可以根据不同条件生成不同的测试数据
- 可配置性:测试数据可以通过配置文件、环境变量等方式配置
- 可扩展性:易于添加新的测试数据源
- 维护性:测试数据与测试逻辑分离
10. 参数化的高级用法
10.1 使用 pytest.param 进行精细控制
pytest.param 允许你为每个参数组合添加标记、ID 等元数据。
示例:使用 pytest.param
import pytest
@pytest.mark.parametrize("a, b, expected", [
pytest.param(1, 2, 3, id="正常情况"),
pytest.param(0, 0, 0, id="零值测试"),
pytest.param(-1, -2, -3, id="负数测试"),
pytest.param(100, 200, 300, id="大数测试")
])
def test_add(a, b, expected):
"""测试加法"""
assert a + b == expected
运行结果:
test_example.py::test_add[正常情况] PASSED
test_example.py::test_add[零值测试] PASSED
test_example.py::test_add[负数测试] PASSED
test_example.py::test_add[大数测试] PASSED
10.2 参数化与跳过测试
你可以使用 pytest.param 来标记某些参数组合需要跳过。
示例:跳过特定的测试数据
import pytest
@pytest.mark.parametrize("a, b, expected", [
(1, 2, 3),
pytest.param(3, 4, 7, marks=pytest.mark.skip(reason="暂时跳过")),
(5, 6, 11),
pytest.param(7, 8, 15, marks=pytest.mark.skipif(True, reason="条件跳过")),
])
def test_add(a, b, expected):
"""测试加法,部分数据跳过"""
assert a + b == expected
10.3 参数化与预期失败
你可以使用 pytest.param 标记某些测试用例为预期失败。
示例:标记预期失败的测试
import pytest
@pytest.mark.parametrize("a, b, expected", [
(1, 2, 3),
pytest.param(3, 4, 8, marks=pytest.mark.xfail(reason="已知 bug")),
(5, 6, 11),
])
def test_add(a, b, expected):
"""测试加法,部分用例预期失败"""
assert a + b == expected
10.4 参数化与标记组合
示例:为不同的参数组合添加不同的标记
import pytest
@pytest.mark.parametrize("a, b, expected", [
pytest.param(1, 2, 3, marks=[pytest.mark.smoke, pytest.mark.positive]),
pytest.param(-1, -2, -3, marks=[pytest.mark.negative]),
pytest.param(0, 0, 0, marks=[pytest.mark.edge]),
])
def test_add(a, b, expected):
"""测试加法,不同参数组合有不同的标记"""
assert a + b == expected
10.5 参数化测试类的所有方法
你可以对整个测试类进行参数化,这样类中的所有测试方法都会使用相同的参数。
示例:参数化测试类
import pytest
@pytest.mark.parametrize("browser", ["Chrome", "Firefox", "Safari"])
class TestBrowser:
"""测试浏览器功能"""
def test_open_page(self, browser):
"""测试打开页面"""
print(f"使用 {browser} 打开页面")
assert browser in ["Chrome", "Firefox", "Safari"]
def test_click_button(self, browser):
"""测试点击按钮"""
print(f"使用 {browser} 点击按钮")
assert browser in ["Chrome", "Firefox", "Safari"]
def test_close_browser(self, browser):
"""测试关闭浏览器"""
print(f"使用 {browser} 关闭浏览器")
assert browser in ["Chrome", "Firefox", "Safari"]
运行结果:
test_example.py::TestBrowser::test_open_page[Chrome] PASSED
test_example.py::TestBrowser::test_open_page[Firefox] PASSED
test_example.py::TestBrowser::test_open_page[Safari] PASSED
test_example.py::TestBrowser::test_click_button[Chrome] PASSED
test_example.py::TestBrowser::test_click_button[Firefox] PASSED
test_example.py::TestBrowser::test_click_button[Safari] PASSED
test_example.py::TestBrowser::test_close_browser[Chrome] PASSED
test_example.py::TestBrowser::test_close_browser[Firefox] PASSED
test_example.py::TestBrowser::test_close_browser[Safari] PASSED
说明:类中的每个测试方法都会在三种浏览器上各运行一次,总共 9 个测试用例(3个方法 × 3个浏览器)。
11. 实际应用场景示例
接口测试
需求:测试用户登录接口,需要测试多种用户名和密码组合。
import pytest
import requests
@pytest.mark.parametrize("username, password, expected_status", [
("admin", "admin123", 200),
("user", "user123", 200),
("invalid", "wrong", 401),
("", "", 400),
("admin", "", 400),
("", "admin123", 400),
])
def test_login_api(username, password, expected_status):
"""测试登录接口"""
url = "https://api.example.com/login"
data = {
"username": username,
"password": password
}
response = requests.post(url, json=data)
assert response.status_code == expected_status,
f"登录失败:用户名={username}, 密码={password}, 期望状态码={expected_status}, 实际状态码={response.status_code}"