Pytest 中的数据驱动测试详解
1. 什么是数据驱动测试
1.1 基本概念
数据驱动测试(Data-Driven Testing) 是一种测试方法,它将测试数据和测试逻辑分离,通过使用不同的测试数据来执行相同的测试逻辑,从而减少代码重复,提高测试效率和可维护性。
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
你需要为每一组数据写一个测试函数,代码重复且繁琐。
数据驱动方式:
@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 最简单的示例
示例 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
示例 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
2.4 参数名的命名规则
参数名必须符合 Python 变量命名规则:
- 只能包含字母、数字和下划线
- 不能以数字开头
- 不能是 Python 关键字
正确示例:
@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
3. 不同数据类型的参数化
3.1 使用列表(List)作为测试数据
列表是最常用的数据格式,适合存储简单的数据组合。
示例:测试字符串长度
import pytest
@pytest.mark.parametrize("text, expected_length", [
("hello", 5),
("world", 5),
("pytest", 6),
("", 0),
("a", 1)
])
def test_string_length(text, expected_length):
"""测试字符串长度"""
assert len(text) == expected_length
运行结果:
test_example.py::test_string_length[hello-5] PASSED
test_example.py::test_string_length[world-5] PASSED
test_example.py::test_string_length[pytest-6] PASSED
test_example.py::test_string_length[-0] PASSED
test_example.py::test_string_length[a-1] PASSED
3.2 使用元组(Tuple)作为测试数据
元组和列表在参数化中用法相同,但元组是不可变的,更适合表示固定的测试数据。
示例:测试数学运算
import pytest
@pytest.mark.parametrize("a, b, expected", [
(1, 2, 3), # 加法
(5, 3, 2), # 减法
(4, 3, 12), # 乘法
(10, 2, 5) # 除法
])
def test_math_operations(a, b, expected):
"""测试基本数学运算"""
if expected == 3:
assert a + b == expected
elif expected == 2:
assert a - b == expected
elif expected == 12:
assert a * b == expected
elif expected == 5:
assert a / b == expected
3.3 使用字典(Dict)作为测试数据
字典适合存储复杂的测试数据,每个字典代表一组测试数据。
示例:测试用户信息
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"]
更灵活的字典用法:解包字典
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.4 使用字符串作为测试数据
字符串适合测试文本处理、验证等功能。
示例:测试邮箱格式验证
import pytest
import re
@pytest.mark.parametrize("email", [
"user@example.com",
"test.email@domain.co.uk",
"name+tag@example.org",
"invalid.email", # 无效邮箱
"@example.com", # 无效邮箱
"user@", # 无效邮箱
])
def test_email_format(email):
"""测试邮箱格式"""
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}$'
is_valid = bool(re.match(pattern, email))
# 前三个应该是有效的
if email in ["user@example.com", "test.email@domain.co.uk", "name+tag@example.org"]:
assert is_valid, f"{email} 应该是有效的邮箱"
else:
assert not is_valid, f"{email} 应该是无效的邮箱"
3.5 使用布尔值作为测试数据
布尔值适合测试开关、标志位等功能。
示例:测试功能开关
import pytest
@pytest.mark.parametrize("enabled, should_work", [
(True, True),
(False, False),
(True, True),
])
def test_feature_flag(enabled, should_work):
"""测试功能开关"""
if enabled:
assert should_work is True
else:
assert should_work is False
3.6 使用 None 作为测试数据
None 适合测试空值、默认值等场景。
示例:测试默认值处理
import pytest
@pytest.mark.parametrize("value, default", [
(None, "default"),
("", "default"),
("actual_value", "actual_value"),
])
def test_default_value(value, default):
"""测试默认值处理"""
result = value if value is not None and value != "" else default
assert result == default or result == "actual_value"
4. 多参数组合测试
4.1 两个参数的组合
示例:测试登录功能
import pytest
@pytest.mark.parametrize("username, password", [
("admin", "admin123"),
("user", "user123"),
("guest", "guest123"),
("", ""), # 空用户名和密码
])
def test_login(username, password):
"""测试登录功能"""
# 模拟登录逻辑
if username and password:
assert len(username) > 0
assert len(password) > 0
print(f"尝试登录:用户名={username}, 密码={password}")
else:
print("用户名或密码为空,登录失败")
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
# 嵌套参数化:2 * 3 = 6 个测试用例
@pytest.mark.parametrize("x", [1, 2])
@pytest.mark.parametrize("y", [3, 4, 5])
def test_nested(x, y):
pass
# 结果:6 个测试用例(1-3, 1-4, 1-5, 2-3, 2-4, 2-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
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
5.4 实际应用场景
示例:测试 API 的不同方法和状态码
import pytest
@pytest.mark.parametrize("method", ["GET", "POST", "PUT", "DELETE"])
@pytest.mark.parametrize("status_code", [200, 201, 400, 404, 500])
def test_api_methods(method, status_code):
"""测试不同 HTTP 方法和状态码的组合"""
print(f"测试 {method} 请求,期望状态码 {status_code}")
# 模拟 API 调用
if method == "GET" and status_code == 200:
assert True # GET 请求成功
elif method == "POST" and status_code == 201:
assert True # POST 请求创建成功
elif status_code in [400, 404, 500]:
assert True # 错误状态码
else:
# 其他组合
assert True
注意:嵌套参数化会生成大量的测试用例(参数数量的乘积),使用时要注意控制数量。
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
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
6.5 ID 的作用
- 提高可读性:在测试报告中更容易理解每个测试用例的含义
- 便于调试:当测试失败时,可以通过 ID 快速定位问题
- 便于筛选:可以使用
-k参数根据 ID 筛选测试用例
示例:使用 ID 筛选测试用例
# 只运行包含"管理员"的测试用例
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"]
运行结果:
test_example.py::test_with_browser[Chrome] PASSED
test_example.py::test_with_browser[Firefox] PASSED
test_example.py::test_with_browser[Safari] PASSED
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"]
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"]
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 条件参数化
有时候,你可能只想在某些条件下进行参数化。
示例:根据标记条件参数化
import pytest
# 定义测试数据
test_data = [
(1, 2, 3),
(3, 4, 7),
(5, 6, 11)
]
# 只在特定标记下参数化
@pytest.mark.parametrize("a, b, expected", test_data)
@pytest.mark.slow
def test_add_slow(a, b, expected):
"""慢速测试"""
import time
time.sleep(1) # 模拟慢速操作
assert a + b == expected
# 快速测试不使用参数化
def test_add_fast():
"""快速测试"""
assert 1 + 2 == 3
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
11. 实际应用场景示例
11.1 场景一:接口测试
需求:测试用户登录接口,需要测试多种用户名和密码组合。
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}"
11.2 场景二:表单验证测试
需求:测试用户注册表单的各种输入验证。
import pytest
@pytest.mark.parametrize("username, password, email, age, expected_error", [
("user1", "pass123", "user1@example.com", 25, None), # 正常情况
("", "pass123", "user1@example.com", 25, "用户名不能为空"), # 空用户名
("user2", "", "user2@example.com", 25, "密码不能为空"), # 空密码
("user3", "pass123", "invalid_email", 25, "邮箱格式不正确"), # 无效邮箱
("user4", "pass123", "user4@example.com", 15, "年龄必须大于等于18岁"), # 年龄不足
("user5", "123", "user5@example.com", 25, "密码长度必须大于等于6"), # 密码太短
])
def test_register_form(username, password, email, age, expected_error):
"""测试注册表单验证"""
errors = []
# 验证用户名
if not username:
errors.append("用户名不能为空")
# 验证密码
if not password:
errors.append("密码不能为空")
elif len(password) < 6:
errors.append("密码长度必须大于等于6")
# 验证邮箱
if "@" not in email:
errors.append("邮箱格式不正确")
# 验证年龄
if age < 18:
errors.append("年龄必须大于等于18岁")
# 验证错误信息
if expected_error:
assert expected_error in errors, f"期望错误:{expected_error},实际错误:{errors}"
else:
assert len(errors) == 0, f"不应该有错误,但发现了:{errors}"
11.3 场景三:边界值测试
需求:测试计算器的边界值情况。
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, "add", 10),
(0, 0, "add", 0),
# 边界值:负数
(-10, 5, "add", -5),
(10, -5, "add", 5),
(-10, -5, "add", -15),
# 边界值:大数
(999999, 1, "add", 1000000),
(1, 999999, "add", 1000000),
# 异常情况:除零
(10, 0, "divide", None), # 应该抛出异常
])
def test_calculator_boundary(a, b, operation, expected):
"""测试计算器边界值"""
if operation == "add":
result = a + b
assert result == expected
elif operation == "subtract":
result = a - b
assert result == expected
elif operation == "multiply":
result = a * b
assert result == expected
elif operation == "divide":
if b == 0:
with pytest.raises(ZeroDivisionError):
result = a / b
else:
result = a / b
assert result == expected
11.4 场景四:多环境测试
需求:在不同环境下测试相同的功能。
import pytest
import os
# 定义环境配置
ENVIRONMENTS = {
"dev": {
"base_url": "https://dev-api.example.com",
"timeout": 10
},
"test": {
"base_url": "https://test-api.example.com",
"timeout": 15
},
"prod": {
"base_url": "https://api.example.com",
"timeout": 30
}
}
@pytest.mark.parametrize("env_name", ["dev", "test", "prod"])
def test_api_in_different_envs(env_name):
"""在不同环境下测试 API"""
env_config = ENVIRONMENTS[env_name]
base_url = env_config["base_url"]
timeout = env_config["timeout"]
print(f"测试环境:{env_name}")
print(f"API 地址:{base_url}")
print(f"超时时间:{timeout}秒")
# 这里可以实际调用 API
# response = requests.get(f"{base_url}/health", timeout=timeout)
# assert response.status_code == 200
assert base_url.startswith("https://")
assert timeout > 0
11.5 场景五:数据驱动 UI 测试
需求:测试不同用户角色的页面访问权限。
import pytest
@pytest.mark.parametrize("user_role, page, should_have_access", [
("admin", "/admin/dashboard", True),
("admin", "/user/profile", True),
("admin", "/settings", True),
("user", "/admin/dashboard", False), # 普通用户不能访问管理员页面
("user", "/user/profile", True),
("user", "/settings", True),
("guest", "/admin/dashboard", False),
("guest", "/user/profile", False),
("guest", "/settings", False),
])
def test_page_access(user_role, page, should_have_access):
"""测试不同角色的页面访问权限"""
# 模拟权限检查逻辑
admin_pages = ["/admin/dashboard"]
user_pages = ["/user/profile", "/settings"]
if user_role == "admin":
has_access = True # 管理员可以访问所有页面
elif user_role == "user":
has_access = page not in admin_pages # 普通用户不能访问管理员页面
else: # guest
has_access = False # 访客不能访问任何页面
assert has_access == should_have_access,
f"角色 {user_role} 访问 {page} 的权限不符合预期"
12. 常见问题和解决方案
12.1 问题一:参数名拼写错误
错误示例:
@pytest.mark.parametrize("username, password", [
("admin", "admin123")
])
def test_login(user_name, password): # 参数名不匹配
pass
错误信息:
TypeError: test_login() missing 1 required positional argument: 'user_name'
解决方案:确保参数化装饰器中的参数名与测试函数的参数名完全一致。
@pytest.mark.parametrize("username, password", [
("admin", "admin123")
])
def test_login(username, password): # 参数名匹配
assert username == "admin"
12.2 问题二:参数数量不匹配
错误示例:
@pytest.mark.parametrize("a, b, c", [
(1, 2), # 只有两个值,但定义了三个参数
(3, 4)
])
def test_example(a, b, c):
pass
错误信息:
ValueError: too many values to unpack (expected 3)
解决方案:确保每组测试数据的数量与参数数量一致。
@pytest.mark.parametrize("a, b, c", [
(1, 2, 3), # 三个值对应三个参数
(4, 5, 6)
])
def test_example(a, b, c):
assert a + b == c
12.3 问题三:参数化数据过多导致测试时间过长
问题:当测试数据非常多时,所有测试用例都会执行,导致测试时间过长。
解决方案 1:使用标记筛选部分测试数据
import pytest
# 完整测试数据
all_test_data = [
(1, 2, 3),
(3, 4, 7),
(5, 6, 11),
# ... 更多数据
]
# 快速测试数据(用于日常开发)
quick_test_data = [
(1, 2, 3),
(5, 6, 11),
]
# 根据标记选择数据
test_data = pytest.mark.parametrize("a, b, expected",
all_test_data if pytest.config.getoption("--full") else quick_test_data
)
解决方案 2:使用 -k 参数筛选测试用例
# 只运行部分测试用例
pytest -k "test_add[1-2] or test_add[3-4]"