第 13 章 · 数据驱动与参数化 E2E
本章目标:用 parametrize、fixture params 和外部数据源把"同一套操作 × 多份测试数据"变成结构化的 E2E 用例,并学会控制参数规模。
13.1 为什么 E2E 也需要数据驱动
一个登录表单的校验逻辑有十几种输入组合(空密码、错误格式邮箱、正确凭据……),手写十几个几乎相同的测试函数是维护灾难。数据驱动的核心思想是:把"操作流程"与"测试数据"分离——流程写一遍,数据列成表,由框架批量生成用例。
E2E 场景下有三条常用的参数化路径:
@pytest.mark.parametrize:同一场流程跑多份数据;- fixture params:整个环境/角色维度不同(如普通用户 vs 管理员);
- 外部数据文件(YAML/JSON):数据量大或需要非程序员维护时。
13.2 parametrize 驱动表单校验
以注册表单校验为例:
# tests/test_signup_validation.py
import re
import pytest
from playwright.sync_api import Page, expect
# (邮箱, 密码, 预期提示文案) 三元组覆盖各等价类
INVALID_CASES = [
("", "Passw0rd!", "请输入邮箱"),
("not-an-email", "Passw0rd!", "邮箱格式不正确"),
("user@test.com", "", "请输入密码"),
("user@test.com", "123", "密码至少 8 位"),
]
@pytest.mark.parametrize("email,password,message", INVALID_CASES)
def test_signup_shows_validation(page: Page, email: str, password: str, message: str):
page.goto("/signup")
page.get_by_label("邮箱").fill(email)
page.get_by_label("密码").fill(password)
page.get_by_role("button", name="注册").click()
# Web-First 断言自动等待提示出现
expect(page.get_by_role("alert")).to_contain_text(message)一次运行生成 4 个独立用例,任何一个失败都能在报告中精确定位到是哪组数据——这是把所有情况塞进一个函数里做不到的。
用 ids 让报告可读
默认用例名会把参数值拼进去,长字符串会很难看。用 ids 自定义命名:
@pytest.mark.parametrize(
"email,password,message",
INVALID_CASES,
ids=["空邮箱", "坏邮箱格式", "空密码", "密码过短"], # 用例名直接可读
)
def test_signup_validation(page: Page, email, password, message):
...也可以传函数动态生成:ids=lambda val: f"case-{val}"(对每个参数依次调用)。配合 -k "空邮箱" 还能按中文名筛选运行。
13.3 fixture params 驱动多角色场景
parametrize 参数化的是"一份数据";当差异在于整个环境身份(不同角色的账号、不同的浏览器上下文配置)时,fixture params 更合适:
# conftest.py
import pytest
from playwright.sync_api import Page
ROLES = {
"admin": {"user": "admin@shop.io", "passwd": "Adm1n!Secret"},
"buyer": {"user": "buyer@shop.io", "passwd": "Buyer!Secret"},
}
@pytest.fixture(params=list(ROLES), ids=["管理员", "买家"])
def logged_in_page(request, page: Page) -> Page:
"""每个角色都会得到一个已登录的独立页面"""
cred = ROLES[request.param]
page.goto("/login")
page.get_by_label("邮箱").fill(cred["user"])
page.get_by_label("密码").fill(cred["passwd"])
page.get_by_role("button", name="登录").click()
expect(page.get_by_role("navigation")).to_contain_text(cred["user"])
yield page
page.context.close() # 确保会话隔离# tests/test_dashboard.py
def test_dashboard_visible(logged_in_page):
"""这条测试会对 admin 和 buyer 各跑一次"""
expect(logged_in_page.locator("#dashboard")).to_be_visible()params 中每个值都会实例化一次该 fixture,依赖它的测试随之翻倍——角色维度的扩展不需要改任何测试代码。
13.4 外部数据源:YAML 组织复杂数据
数据超过十几行后放 Python 文件里会干扰阅读,挪进 YAML 由 conftest.py 统一加载:
# data/products.yaml
- name: 机械键盘
price: "399.00"
stock: 有货
- name: 显示器支架
price: "129.00"
stock: 缺货# tests/test_search.py
import pathlib
import yaml
import pytest
from playwright.sync_api import Page, expect
DATA = yaml.safe_load(
(pathlib.Path(__file__).parent.parent / "data" / "products.yaml").read_text("utf-8")
)
@pytest.mark.parametrize("item", DATA, ids=lambda i: i["name"]) # 用商品名做 id
def test_product_card(page: Page, item: dict):
page.goto(f"/search?q={item['name']}")
card = page.locator(".product-card").first
expect(card.get_by_class_name("price")).to_have_text(item["price"])
# 库存徽标文案取决于数据而非代码
expect(card.get_by_test_id("stock-badge")).to_contain_text(item["stock"])JSON 同理(json.load)。要点是:数据文件只描述事实,断言逻辑保持单一;新增商品只需改 YAML,不动任何测试代码。
13.5 控制参数爆炸:等价类思维
参数化最大的陷阱是组合爆炸:4 个字段 × 每字段 5 个取值 = 625 个 E2E 用例,每个都要开浏览器。生产级做法:
- 等价类划分:每张表单挑出边界值 + 一个典型值即可(空 / 超长 / 非法格式 / 合法),不必穷举;
- 分层拆分:纯校验逻辑下沉到单元/API 层测(毫秒级),E2E 只保留"端到端能走通"的代表路径(第 17 章的 API 测试正是承接点);
- 正交采样:多字段组合用 pairwise 思路人工挑选几十个代表组合,而不是全量笛卡尔积;
- 慢路径复用状态:参数化用例共享同一个
storage_state(第 9 章)跳过重复登录,把每条用例的时间从分钟级压到秒级。
判断标准
新增一条参数前先问:它会改变被验证的行为分支吗?只是换个等价输入的,砍掉。
13.6 本章小结
- parametrize 管"数据"、fixture params 管"环境/角色",两者互补;
ids让报告里的用例名可读可筛选;- YAML/JSON 数据文件让非开发人员也能维护测试数据;
- 用等价类和分层测试控制 E2E 参数总量,避免浏览器级的组合爆炸。
🧪 随堂测验
点击你认为正确的选项。答错时会展示正确答案与原因解析。
1. 同一套注册流程要用 4 组非法输入分别验证,最合适的做法是?
2. fixture 的 params=[...] 会产生什么效果?
3. E2E 表单校验的参数组合达到几百个时,正确的处理思路是?
4. 关于 parametrize 的 ids 参数,下列说法正确的是?
🛠️ 动手实践
- 为一个搜索框写出 5 组参数化用例(含中文、特殊字符、超长字符串、空串、正常词),用
ids命名。 - 把 13.3 的 ROLES 扩展出一个"访客"角色(不登录直接访问),观察哪些测试通过哪些失败。
- 将本章 YAML 示例改成 JSON 实现,体会两种格式的取舍,并把数据文件移到
tests/data/下。