05.pytest中数据驱动测试

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. 减少代码重复:相同的测试逻辑只需要写一次
  2. 易于维护:修改测试逻辑只需要改一个地方
  3. 易于扩展:添加新的测试数据只需要在数据列表中添加即可
  4. 清晰的测试报告:每个数据组合都会生成独立的测试用例报告
  5. 提高测试覆盖率:可以轻松测试大量不同的数据组合

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 的作用

  1. 提高可读性:在测试报告中更容易理解每个测试用例的含义
  2. 便于调试:当测试失败时,可以通过 ID 快速定位问题
  3. 便于筛选:可以使用 -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 数据文件的最佳实践

  1. 文件组织:将测试数据文件放在专门的 datatest_data 目录中
  2. 文件命名:使用清晰的命名,如 login_test_data.json
  3. 数据验证:在读取数据后进行验证,确保数据格式正确
  4. 错误处理:添加异常处理,处理文件不存在或格式错误的情况

示例:完整的数据加载工具

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 动态参数化的优势

  1. 灵活性:可以根据不同条件生成不同的测试数据
  2. 可配置性:测试数据可以通过配置文件、环境变量等方式配置
  3. 可扩展性:易于添加新的测试数据源
  4. 维护性:测试数据与测试逻辑分离

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]"

发表评论