第2章 · 夹具设计原则:DRY、单一职责、可组合性、可维护性
UI 测试夹具(Fixture)的质量直接决定自动化套件的长期健康度。粗放的夹具往往出现重复加载、职责混杂、难以拼接、改一处而碎全盘等问题。本章以 DRY、单一职责、可组合性、可维护性 四条原则为骨架,阐述它们在夹具设计与代码实现中的具体落地方式。
| 原则 | 核心定义 | 夹具中体现 | 违反后的常见问题 |
|---|---|---|---|
| DRY | 不要重复自己,一次定义,多处复用 | 共享资源加载、全局测试数据、公共页面操作 | 每个用例重复编写 setup,维护成本剧增 |
| 单一职责 | 每个夹具只负责一个明确的逻辑维度 | 登录夹具只处理认证,数据库夹具只处理数据回滚 | 夹具依赖混乱,修改一个影响其他不相关用例 |
| 可组合性 | 夹具之间可以按需拼装,不产生副作用 | 通过依赖注入或参数化组合多个轻量夹具 | 大型夹具耦合度高,组合时冲突、爆炸 |
| 可维护性 | 结构清晰、变化集中、低认知负担 | 统一生命周期管理、文档化、配置与代码分离 | 夹具变成黑箱,修复一个 bug 需要阅读大量代码 |
2.1 DRY —— 消除重复,提升复用
原理:在 pytest 等框架中,conftest.py 是 DRY 的天然载体。将多次出现的 setup/teardown 提升为全局级或模块级 fixture,避免每个测试文件都写同一套逻辑。
实现流程
- 识别重复代码:扫描整个项目,提取出现 ≥3 次的前置操作(如浏览器打开、用户登录、数据插入)。
- 迁入统一 fixture:在
conftest.py中定义并标记作用域(scope="module"/"session")。 - 参数化共享数据:使用
@pytest.fixture(params=[...])提供多组数据,避免每个测试拷贝数据列表。 - 移除冗余代码:逐个清理原先的重复 setup 调用,确保只保留注入式使用。
# conftest.py — DRY 示例
import pytest
from selenium import webdriver
@pytest.fixture(scope="session")
def shared_driver():
driver = webdriver.Chrome()
yield driver
driver.quit()
@pytest.fixture(scope="module")
def logged_in_user(shared_driver):
shared_driver.get("https://example.com/login")
# 执行统一登录流程
yield "user_"
# 无需登出,由 session fixture 处理资源释放
调试检测:用
pytest --fixtures 查看当前作用域内的 fixture 定义,若发现相同的逻辑出现在多个 fixture 中,即 DRY 未落实。
2.2 单一职责 —— 每个 fixture 只做一件事
原理:一个 fixture 不应该既负责数据库连接又负责页面截图。职责边界模糊会导致 fixture 之间隐式依赖,破坏确定性。
实现流程
- 列出 fixture 的所有行为,将其拆分为原子操作。
- 每个 fixture 只处理一个外部资源或一组紧密相关状态(如仅处理配置读取)。
- 通过 fixture 依赖树组合小 fixture,而不是在一个 fixture 中写复杂流程。
# 单一职责拆分示例 — 不符合
@pytest.fixture
def bad_fixture():
db = connect_db()
config = load_config()
driver = webdriver.Chrome()
yield db, config, driver
# 清理三个资源,职责混杂
# 符合单一职责
@pytest.fixture
def db_conn():
conn = connect_db()
yield conn
conn.close()
@pytest.fixture
def test_config():
return load_config()
@pytest.fixture
def browser(test_config):
driver = webdriver.Chrome()
yield driver
driver.quit()
常见错误:两个单一职责 fixture 之间出现隐式顺序依赖。例如
browser 需要 test_config 中的 URL,但未显申明参数注入,运行时会因 fixture 顺序而报错。解决方案:在 fixture 函数参数中明确写入所依赖的其他 fixture 名称。
2.3 可组合性 —— 拼装而非继承
原理:通过参数化、Fixture 工厂、或 pytest 的 @pytest.fixture 的自动依赖组合,将多个小 fixture 按需组合成复杂场景,而不需要为每种组合创建一个新 fixture。
实现流程
- 创建低层级“原子 fixture”(如
clean_db、session_manager)。 - 在上层场景中直接把这些原子 fixture 作为参数引用,框架自动按依赖图 resolve。
- 复杂组合使用
@pytest.fixture的params或lazy fixture避免重复定义。
# 可组合夹具示例
@pytest.fixture
def clean_db():
"""每个测试前清空数据库"""
clear_all_tables()
yield
@pytest.fixture
def seed_data():
"""注入测试基础数据"""
insert_sample_data()
yield
# 组合使用:直接在测试或高层 fixture 中引用
def test_user_registration(clean_db, seed_data, logged_in_user):
# clean_db 先执行 → seed_data → logged_in_user
assert check_registration_works()
性能优化:组合多个
scope="function" 的 fixture 会增加每个用例的 setup 开销。对高频调用且无状态污染的 fixture 应提升作用域至 module 或 session,并通过 request.addfinalizer 管理清理,保持可组合性的同时减少重复初始化。
2.4 可维护性 —— 让 fixture 代码自成文档
原理:可维护性来自一致的生命周期管理、显式命名、以及约定大于配置。fixture 的定义位置、作用域、清理逻辑都应当无需猜测。
实现流程
- 将 fixture 按照功能模块放入各自的
conftest.py,保持层级清晰。 - 每个 fixture 添加 docstring 说明资源来源、清理行为和副作用。
- 清理逻辑统一使用
yield形式,避免使用teardown_module等杂散函数。 - 定义公共常量或配置 YAML 文件,并由一个
settingsfixture 加载,所有其他 fixture 通过它获取路径、账号等参数。
# 可维护性示例 — conftest.py 结构
tests/
├── conftest.py # session/function 级共享 fixture
├── ui/
│ ├── conftest.py # 仅 UI 测试相关 fixture
│ └── test_login.py
├── api/
│ ├── conftest.py # 仅 API 测试相关 fixture
│ └── test_endpoints.py
└── config.yaml # 全局环境配置
# config.yaml
base_url: "https://staging.example.com"
timeout: 30
admin_user: "admin"
稳定性建议:所有 fixture 的清理逻辑必须幂等。使用
try/finally 或 request.addfinalizer 包裹资源释放,确保即使测试断言失败也不残留浏览器进程、数据库连接或文件句柄。
2.5 原则关系与夹具架构图
下图展示了四条原则如何在夹具设计与运行时形成协作网络。DRY 驱动共享资源层,单一职责细分原子服务,可组合性将这些原子拼接为业务场景,可维护性则贯穿整个结构保证正交。
2.6 常见违反原则模式与纠正
| 反模式 | 违反原则 | 纠正方案 |
|---|---|---|
| 超级 fixture(一个 fixture 返回 5 个以上对象) | 单一职责 | 拆分为多个原子 fixture,通过参数组合 |
| 测试文件中重复写相同的 setup 代码 | DRY | 提升至 conftest.py,使用 autouse 或者显式注入 |
| fixture 依赖硬编码路径或环境变量 | 可维护性 | 统一通过 settings fixture 加载配置 |
| 为每个不同组合创建新 fixture 文件 | 可组合性 | 使用参数化 fixture 或 pytest 的 parametrize 结合小 fixture |
代码审查要点:审查新 fixture 时问三个问题:(1) 这段逻辑是否已存在于其他 fixture?(2) 这个 fixture 是否只完成一个领域的任务?(3) 如果需要与其他 fixture 拼接,是否必须修改内部代码?满足此三者,即符合原则。