自动化测试框架选型与分层策略¶
一、一个换了三套框架的团队¶
某项目半年内走了三步:Selenium + unittest → Cypress → Playwright。用例从 0 涨到 600 条,回归仍要 4 小时,失败率 35%,其中约 80% 打开日志发现是等待超时、测试数据被别人改了、或者本地能过 CI 不过。
换框架没有解决任何问题。真正的问题是用例直接操作 driver、配置写死在代码里、失败没有请求响应上下文。选型和分层是两件事:选型决定未来两年的维护成本,分层决定今天写的用例明年还能不能改。
二、选型维度与权重¶
| 维度 | 权重 | 要回答的问题 | 量化方式 |
|---|---|---|---|
| 语言栈匹配 | 20% | 团队能否直接读源码改框架 | 团队主力语言占比 |
| 团队能力 | 15% | 是否会写 fixture、是否会调试异步 | 能独立排错的人数 |
| 维护成本 | 25% | 定位方式是否稳定、升级是否破坏 API | 每版本修改用例数 |
| 报告能力 | 10% | 失败能否直接看到请求/响应/截图 | 是否原生支持 Allure |
| 并行能力 | 15% | 能否按文件/用例隔离并行 | 10 分钟能跑多少条 |
| CI 集成 | 10% | 退出码、JUnit XML、容器化 | 接入所需人天 |
| 生态与招聘 | 5% | 招人难度、插件是否维护 | GitHub 活跃度 |
打分用 1~5 分乘权重,别拍脑袋。一个常见误区是把“社区流行”给 30% 权重,结果选了一个团队没人会调试的栈。
一个 Java 团队的真实打分(1~5 分):
| 维度 | 权重 | pytest+requests | JUnit+RestAssured | Playwright | Robot Framework |
|---|---|---|---|---|---|
| 语言栈匹配 | 20% | 2 | 5 | 3 | 3 |
| 团队能力 | 15% | 3 | 5 | 3 | 2 |
| 维护成本 | 25% | 4 | 4 | 4 | 2 |
| 报告能力 | 10% | 4 | 4 | 4 | 3 |
| 并行能力 | 15% | 5 | 4 | 4 | 3 |
| CI 集成 | 10% | 5 | 4 | 4 | 3 |
| 生态与招聘 | 5% | 5 | 4 | 4 | 2 |
| 加权总分 | 100% | 3.75 | 4.35 | 3.65 | 2.55 |
结论是接口用 JUnit + RestAssured,UI 用 Playwright,而不是“哪个最流行选哪个”。打分表要进设计文档,半年后回看才有依据。
三、五套主流方案对比¶
| 方案 | 技术栈与版本 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| pytest + requests | Python 3.11、pytest 8.3、requests 2.32 | fixture 依赖注入强、断言失败自动展示表达式、插件生态全(xdist/allure/rerun) | 需要自己搭分层;同步模型对高并发场景一般 | 接口为主、团队以 Python 为主 |
| JUnit 5 + RestAssured | Java 17、JUnit 5.11、RestAssured 5.5.0 | 与 Spring 项目同栈、类型安全、IDE 重构友好 | 样板代码多、启动慢、学习曲线陡 | Java 服务端团队、需要复用领域模型 |
| Playwright | 1.47,支持 Python/TS/Java | 自动等待、trace viewer、多浏览器、网络拦截强 | 只覆盖浏览器;组件测试不如 Cypress 直观 | Web UI 主链路、需要稳定与调试能力 |
| Cypress | 13.15 | 开发者体验好、时间旅行调试、前端团队易上手 | 同源限制需 cy.origin、多标签页弱、仅 JS |
前端主导、单页应用、组件测试 |
| Robot Framework | 7.1 + SeleniumLibrary 6.3 | 关键字驱动、非程序员可读、适合验收测试 | 复杂逻辑用关键字拼会很别扭、调试弱 | 业务方参与验收、以表格用例为主 |
结论通常是组合而不是二选一:接口层用 pytest/requests 或 JUnit/RestAssured,UI 层用 Playwright 只覆盖 5~10 条主链路,契约用 Pact/OpenAPI。
四、四层架构¶
tests/ # 用例层:只写业务断言,不出现 URL
api/test_order.py
services/ # 业务层:编排多个接口、构造前置状态
order_service.py
api/ # 接口层:URL、header、序列化、错误映射
client.py
order_api.py
core/ # 驱动层:配置、鉴权、会话、日志、重试
config.py
auth.py
data/ # 数据工厂:唯一手机号、随机 SKU
config/
dev.yaml
staging.yaml
判断分层是否有效,看一个指标:接口地址变更时改几个文件。只改 api/ 是合格;要改 30 个用例就是没分层。
驱动层配置外置,环境变量优先于文件:
# core/config.py
from pathlib import Path
import os
import yaml
class Config:
def __init__(self, env: str | None = None):
self.env = env or os.getenv("TEST_ENV", "dev")
path = Path(__file__).parents[1] / "config" / f"{self.env}.yaml"
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
self.base_url = os.getenv("BASE_URL", raw["base_url"])
self.timeout = float(os.getenv("HTTP_TIMEOUT", raw.get("timeout", 10)))
# config/staging.yaml
base_url: https://staging.example.com
timeout: 15
db:
host: staging-db.internal
user: qa_reader # 密码只从环境变量读,不写文件
接口层只做一件事,把 HTTP 细节关在门内:
# api/order_api.py
class OrderApi:
def __init__(self, client):
self._c = client
def create(self, payload: dict, idempotency_key: str | None = None):
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else {}
return self._c.request("POST", "/api/v1/orders", json=payload, headers=headers)
HTTP 细节(重试、日志、错误映射)集中在 client,不让每个接口方法重复:
# api/client.py
import logging
logger = logging.getLogger("api")
class ApiClient:
def __init__(self, cfg, session):
self._cfg, self._s = cfg, session
def request(self, method: str, path: str, **kwargs):
kwargs.setdefault("timeout", self._cfg.timeout)
resp = self._s.request(method, self._cfg.base_url + path, **kwargs)
if resp.status_code == 429:
logger.warning("限流 %s %s,Retry-After=%s",
method, path, resp.headers.get("Retry-After"))
if resp.status_code >= 500:
logger.error("5xx %s %s -> %s", method, path, resp.text[:500])
return resp
业务层负责编排与清理:
# services/order_service.py
class OrderService:
def __init__(self, api, db):
self.api, self.db = api, db
def create_and_wait(self, sku_id: int, qty: int = 1) -> int:
resp = self.api.create({"skuId": sku_id, "qty": qty},
idempotency_key=f"auto-{uuid.uuid4().hex}")
assert resp.status_code == 201, resp.text
order_id = resp.json()["data"]["orderId"]
self.db.wait_until("select status from orders where id=%s", order_id,
lambda r: r["status"] == "PENDING", timeout=5)
return order_id
用例层保持薄:
@allure.epic("交易")
@allure.feature("订单")
class TestOrder:
@allure.story("库存不足时下单失败")
@allure.severity(allure.severity_level.CRITICAL)
def test_out_of_stock(self, order_service):
with allure.step("提交超过库存的订单"):
resp = order_service.api.create({"skuId": 1001, "qty": 99999})
with allure.step("校验业务码"):
assert resp.status_code == 200
assert resp.json()["code"] == 40021, resp.text
同一套分层在 Java 栈下的等价写法(JUnit 5.11 + RestAssured 5.5.0):
// api/OrderApi.java
import io.restassured.specification.RequestSpecification;
import io.restassured.response.Response;
import static io.restassured.RestAssured.given;
public class OrderApi {
private final RequestSpecification spec; // 每线程一份
public OrderApi(RequestSpecification spec) {
this.spec = spec;
}
public Response create(Object payload, String idempotencyKey) {
return given(spec)
.header("Idempotency-Key", idempotencyKey)
.contentType("application/json")
.body(payload)
.when().post("/api/v1/orders")
.then().extract().response();
}
}
// tests/api/OrderTest.java
import static org.assertj.core.api.Assertions.assertThat;
class OrderTest {
@Test
void outOfStock(OrderApi api) {
Response resp = api.create(
Map.of("skuId", 1001, "qty", 99999),
UUID.randomUUID().toString());
assertThat(resp.statusCode()).isEqualTo(200);
assertThat(resp.jsonPath().getInt("code")).isEqualTo(40021);
}
}
RestAssured.baseURI 是静态全局状态,JUnit 并行执行(或 pytest 侧的 -n 4)时会串环境,必须通过 @BeforeEach 构造独立的 RequestSpecification。报告接入 allure-rest-assured 后,还要在 spec 上挂 filter(new AllureRestAssured()),否则 Allure 里只有断言没有请求响应。
五、会话、数据与并行¶
conftest.py 里按 session 建登录态,按 worker 隔离:
@pytest.fixture(scope="session")
def session(cfg):
s = requests.Session()
s.headers.update({"X-Request-Id": str(uuid.uuid4())})
token = login(cfg.base_url, cfg.user, os.environ["TEST_PASSWORD"])
s.headers["Authorization"] = f"Bearer {token}"
yield s
s.close()
并行用 pytest-xdist,按文件分发,避免同一用例的共享数据互相踩:
数据管理的三条硬规则:每个用例自己造数据、用唯一前缀(如 auto-{uuid4().hex[:8]}-)、结束按前缀清理而不是 truncate table。共享账号在并行下必然互相踢下线。
六、Allure 与断言¶
pip install allure-pytest==2.13.5 pytest-xdist==3.6.1 pytest-rerunfailures==14.0
pytest --alluredir=allure-results
allure generate allure-results -o allure-report --clean
# 合并多次执行(分片/并行 job)
allure combine allure-results-* -o allure-results
失败时把请求响应塞进报告,排查成本立刻下降:
def attach(resp):
allure.attach(resp.request.url, "url", allure.attachment_type.TEXT)
allure.attach(resp.request.body or "", "request", allure.attachment_type.TEXT)
allure.attach(resp.text, "response", allure.attachment_type.JSON)
断言优先级:内置 assert(失败自动展开左右值)> assertpy(链式可读)> hamcrest(匹配器丰富)。三者只选一个,混用会让失败信息风格不一。金额断言见接口断言一文,必须用 Decimal。
| 库 | 版本 | 优点 | 代价 | 适用 |
|---|---|---|---|---|
| 内置 assert | - | 零依赖、失败展示表达式 | 复杂断言要自己写 | 90% 场景 |
| assertpy | 1.1 | assert_that(x).is_between(...) 可读性好 |
多一层封装,IDE 跳转弱 | 断言复杂的校验 |
| hamcrest | 2.1 | 匹配器可组合、错误信息结构化 | 语法冗长、新人上手慢 | 需要复用匹配器 |
| pytest-check | 2.4 | 软断言,一次收集多个失败 | 失败后仍继续执行,可能污染状态 | 表单类多字段校验 |
七、重试的边界¶
允许重试的只有:网络抖动、环境启动未就绪、第三方依赖短暂 5xx。禁止重试的两类:
- 断言失败(重试后通过说明用例本身不确定,应修用例)
- 数据状态冲突(重试只会制造更多脏数据)
--reruns 5 全量打开是最差实践,它把 35% 的失败率“修”成 3%,同时掩盖了真实缺陷。正确做法是记录 flaky_rate,超过 2% 的用例进隔离区,两周内必须修或删。
八、覆盖率与 ROI¶
代入一组真实量级:每月回归 2 次、每次 8 小时、2 人、¥80/h,月收益 2560 元;开发 15 人天一次性投入 9600 元;每月维护 2 人天约 1280 元。月净收益 1280 元,回本约 7.5 个月。如果一个月只跑一次,回本超过一年,这笔投入就该重新评估。
覆盖率不要报“用例总数”。报三个:核心接口覆盖率(已自动化/核心接口清单)、核心链路覆盖率(P0 流程覆盖条数)、缺陷发现占比(自动化发现/总缺陷)。
九、从 0 到 1 的节奏¶
第一周:只做三件事——选 3 条核心链路、跑通 pytest + requests + Allure、接进 CI 并在 PR 上出报告。不搭分层,先证明能跑。
第一月:落四层目录、配置外置、数据工厂、20~30 条稳定用例,每天定时跑一次,失败自动通知到人。
第一季度:并行执行、契约校验、隔离区机制、度量看板(通过率/时长/flaky 率),核心接口覆盖到 60% 以上再谈 UI 自动化。
十、三种“为了框架而框架”¶
- 为分层而分层:
OrderService.create()里只有return self.api.create(),四层全是透传。分层的前提是每层有真实职责,透传层只会增加跳转成本。 - 为数据驱动而数据驱动:把断言写成 YAML 字符串再
eval,或给 3 条用例套一个 200 行的数据加载器。只有“同结构、多组数据、断言一致”时才值得数据驱动。 - 为报告好看而堆步骤:每个赋值都包
allure.step,报告 300 步,失败仍要靠 grep 日志。步骤只包“业务动作”,粒度到“提交订单”“校验库存”即可。
十一、验收这套框架是否合格¶
| 检查项 | 合格线 | 验证方式 |
|---|---|---|
| 环境切换 | 只改 TEST_ENV 即可跑另一套环境 |
本地 + CI 各跑一次 |
| 用例独立性 | 单条、全量、倒序三种跑法结果一致 | 分别执行并对比结果 |
| 并行隔离 | -n 4 与单线程通过率差 <2% |
连续 3 轮对比 |
| 失败定位 | 报告里能看到 URL、请求体、响应体 | 故意改一个断言观察 |
| 数据清理 | 跑完无残留(按前缀查询为 0) | 执行后查库 |
| 执行时长 | 核心回归 10 分钟内 | CI 日志 |
| 门禁可用 | 失败返回非 0 且能阻断合并 | 故意失败一次 |
坑位表¶
| 坑 | 现象 | 处理 |
|---|---|---|
用例里写 sleep(3) |
环境慢时仍失败,快时白等 | 轮询等待 + 超时上限 |
| 用例依赖执行顺序 | 单跑通过、全量失败 | 每条用例自造数据 |
| session 级 token 并行共享 | 随机 401 | 每 worker 独立登录 |
| 硬编码 base_url | 换环境改几十处 | 配置外置 + 环境变量 |
| 只断言 200 | 业务失败也绿 | 分层断言 |
| 无限重试 | 缺陷被掩盖 | 只重试环境类异常 |
| 报告只在本地 | CI 失败无法定位 | 报告作为制品上传 |
| 数据库断言连生产 | 数据污染、权限越界 | 只连测试库只读账号 |