跳转至

自动化测试框架选型与分层策略

一、一个换了三套框架的团队

某项目半年内走了三步: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,按文件分发,避免同一用例的共享数据互相踩:

pytest -n 4 --dist loadscope --alluredir=allure-results

数据管理的三条硬规则:每个用例自己造数据、用唯一前缀(如 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 软断言,一次收集多个失败 失败后仍继续执行,可能污染状态 表单类多字段校验

七、重试的边界

@pytest.mark.flaky(reruns=2, reruns_delay=1)
def test_search_timeout(session):
    ...

允许重试的只有:网络抖动、环境启动未就绪、第三方依赖短暂 5xx。禁止重试的两类:

  • 断言失败(重试后通过说明用例本身不确定,应修用例)
  • 数据状态冲突(重试只会制造更多脏数据)

--reruns 5 全量打开是最差实践,它把 35% 的失败率“修”成 3%,同时掩盖了真实缺陷。正确做法是记录 flaky_rate,超过 2% 的用例进隔离区,两周内必须修或删。

八、覆盖率与 ROI

月收益 = 月回归次数 × 手工单次耗时(h) × 人数 × 人力成本(¥/h)
月成本 = 维护人天/22 × 人力成本 × 8
回本周期(月) = 一次性开发成本 / (月收益 - 月成本)

代入一组真实量级:每月回归 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 自动化。

十、三种“为了框架而框架”

  1. 为分层而分层:OrderService.create() 里只有 return self.api.create(),四层全是透传。分层的前提是每层有真实职责,透传层只会增加跳转成本。
  2. 为数据驱动而数据驱动:把断言写成 YAML 字符串再 eval,或给 3 条用例套一个 200 行的数据加载器。只有“同结构、多组数据、断言一致”时才值得数据驱动。
  3. 为报告好看而堆步骤:每个赋值都包 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 失败无法定位 报告作为制品上传
数据库断言连生产 数据污染、权限越界 只连测试库只读账号

相关阅读