去年年底开始,我们组陆陆续续把 AI 塞进了测试流程。到现在半年多,中间推翻过一次方案,也确实有几块是真的省下了时间。

网上讲 AI 测试的文章大多是"五大场景、三大趋势"那种,看完还是不知道怎么下手。所以这篇我按我们实际踩过的顺序写,重点讲哪些能用、哪些别碰,顺带把能直接用的代码贴出来。

一开始我们想错了

最开始的想法很朴素:让 AI 写用例,我们省事。

跑了大概两周发现不对劲。AI 生成的用例,我们审核花的时间比自己写还长。

原因挺反直觉的。不是因为它写得差,恰恰是因为写得像那么回事。标题、前置条件、步骤、预期结果一应俱全,你扫一眼觉得没问题就放过了。真正出问题的全是细节。

举个例子。有个折扣计算的函数,实际返回 0.125,AI 生成的用例写的是"预期折扣 12%"。这条用例跑起来是绿的,而且一直绿下去,直到哪天有人改了那段代码才会暴露。我们后来集中清理过一次,翻出四五条这类断言,最老的一条是半年前生成的,中间跑了不知道多少次,没人发现。

反倒是那种明显的胡说八道——调了一个压根不存在的接口、引用了一个没定义的方法——跑一次就红,两分钟改完,根本不算问题。

所以现在我们有一条看着很笨的规矩:AI 生成的用例,凡是带具体数值断言的,必须人工对着代码核一遍真实返回值。这一步省下来的返工时间,远超审核花的时间。

还有一点,别裸着问。最早我们是直接把需求丢过去说"写一下测试用例",出来的基本全是正常流程,等于没用。后来改成把测试设计方法写进 prompt:等价类怎么划、边界值取哪几个点、有没有状态机、要不要覆盖并发和越权。输出格式也要求结构化,不让它自由发挥。

改完之后效果差很多。有一次一个"密码找回"的需求,AI 出了 14 条,包括邮箱格式非法、链接过期、链接已使用、密码复杂度不足这些。工程师过了一遍,留下 11 条,改了 2 条,扔了 1 条,前后 8 分钟。同样的量自己写大概要 40 多分钟。

不过说实在的,节省时间倒在其次,更有价值的是它把边界和异常场景补齐了。人写用例天然往正常流程上靠,那些"最小值减一"的用例没人愿意写,AI 不在乎。

真正省时间的是这两块

接口测试的用例生成

这块是我们目前唯一敢直接接进 CI 做门禁的。

原因也简单:接口契约是结构化的,AI 不需要猜业务。我们把 OpenAPI 里的 schema 读出来,按字段类型、枚举、最小值最大值、必填这些自动派生异常用例,AI 只负责补两件事——schema 里没有的业务组合规则(比如"优惠券不能叠加"),以及生成像那么回事的业务数据。

一个关键点是单参数缺陷注入:一次只脏化一个字段,其他字段保持合法。这样一条用例失败了,你能立刻归因到具体字段,不会出现三条错误搅在一起看不出是哪个导致的。

```python
# api_fuzz.py —— 从 OpenAPI schema 派生参数级异常用例
def _happy_value(schema: dict):
    """按 schema 生成一个合法值,作为缺陷注入的基线。"""
    t = schema.get("type")
    if "enum" in schema:
        return schema["enum"][0]
    if t == "integer":
        return schema.get("minimum", 0)
    if t == "number":
        return float(schema.get("minimum", 0))
    if t == "boolean":
        return True
    if t == "array":
        return [_happy_value(schema.get("items", {"type": "string"}))]
    return "test"


def derive_values(schema: dict) -> list[tuple[str, object]]:
    """为单个参数派生:合法值 / 越界 / 类型错 / 格式错 / 注入面。"""
    t, out = schema.get("type"), []
    fmt = schema.get("format")

    if t in ("integer", "number"):
        lo, hi = schema.get("minimum", 0), schema.get("maximum", 100)
        step = 1 if t == "integer" else 0.5
        out += [("正常值", (lo + hi) // 2),
                ("下边界-1", lo - step), ("下边界", lo), ("下边界+1", lo + step),
                ("上边界-1", hi - step), ("上边界", hi), ("上边界+1", hi + step),
                ("类型错误", "abc"), ("数值溢出", 10 ** 12)]
    elif t == "string":
        mn, mx = schema.get("minLength", 1), schema.get("maxLength", 32)
        out += [("正常值", "x" * mn), ("空字符串", ""),
                ("超长", "x" * (mx + 1)), ("类型错误", 12345),
                ("XSS 面", "<script>alert(1)</script>"),
                ("SQL 注入面", "' OR 1=1 --"),
                ("Unicode 超长", "\U0001F600" * (mx + 1))]
        if fmt == "email":
            out += [("格式错误", "not-an-email")]
        if fmt == "date-time":
            out += [("格式错误", "2026-13-45T99:99:99Z")]
        if "enum" in schema:
            out += [("枚举外值", "___not_in_enum___")]
    elif t == "boolean":
        out += [("正常值", True), ("类型错误", "true")]
    elif t == "array":
        out += [("空数组", []),
                ("超长数组", list(range(schema.get("maxItems", 10) + 1))),
                ("元素类型错误", ["not-a-number"]),
                ("类型错误", {"a": 1})]
    return out


def build_cases(spec: dict, path: str, method: str) -> list[dict]:
    op = spec["paths"][path][method]
    params = op.get("parameters", [])
    schemas = {p["name"]: p.get("schema", {}) for p in params}
    required = {p["name"] for p in params if p.get("required")}
    base = {n: _happy_value(s) for n, s in schemas.items()}

    cases = []
    for name, sch in schemas.items():
        for label, val in derive_values(sch):
            body = dict(base, **{name: val})
            bad = any(k in label for k in ("错误", "越界", "超长", "注入", "溢出", "枚举外", "空"))
            cases.append({
                "path": path, "method": method.upper(),
                "mutated": name, "label": label, "body": body,
                "expect_status": 400 if bad else 200,
            })
    for name in required:
        cases.append({
            "path": path, "method": method.upper(),
            "mutated": name, "label": "缺失必填项",
            "body": {k: v for k, v in base.items() if k != name},
            "expect_status": 400,
        })
    return cases
```

我们有个四十来个接口的微服务,这套跑下来两千多条用例。手写得两个月,脚本几分钟。更关键的是它不会漏——你让他手写,他一定跳过"上边界加一"这种。

UI 脚本的维护

UI 自动化最贵的从来不是写,是修。改个按钮文案、调一下 class,能级联挂掉几十条脚本,一周工时就没了。

我们后来给 Playwright 套了一层自愈:每个元素存一份多属性指纹(selector、标签、文本、role、aria-label、DOM 相对路径),首次定位失败就拿这些属性去当前页面做加权匹配,分数够高就自动换过去继续跑。

```python
# heal.py —— Playwright 多属性指纹自愈定位
from __future__ import annotations
import json, time
from dataclasses import dataclass
from difflib import SequenceMatcher

from playwright.sync_api import Page, TimeoutError as PWTimeout

HEAL_LOG = "heal_events.jsonl"
THRESHOLD = 0.90   # 低于此分禁止自动改写,必须报失败


@dataclass
class FP:
    """元素指纹:单一属性失效时,用其余属性做加权匹配。"""
    selector: str = ""
    tag: str = ""
    text: str = ""
    role: str = ""
    aria_label: str = ""
    dom_path: str = ""

    def score(self, other: "FP") -> float:
        rules = [
            ("selector",   0.20, self._same),
            ("tag",        0.10, self._same),
            ("text",       0.30, self._fuzzy),
            ("aria_label", 0.20, self._fuzzy),
            ("role",       0.10, self._same),
            ("dom_path",   0.10, self._fuzzy),
        ]
        w_sum = hit = 0.0
        for name, w, fn in rules:
            a, b = getattr(self, name), getattr(other, name)
            if not a and not b:        # 双方都缺该属性 → 不计入分母
                continue
            w_sum += w
            hit += w * fn(str(a), str(b))
        return hit / w_sum if w_sum else 0.0

    @staticmethod
    def _same(a, b):
        return 1.0 if a == b else 0.0

    @staticmethod
    def _fuzzy(a, b):
        return SequenceMatcher(None, a, b).ratio()


_COLLECT_JS = """
() => Array.from(
  document.querySelectorAll('button,a,input,select,textarea,[role],[aria-label]')
).map(el => {
  const path = [];
  let cur = el;
  while (cur && cur.parentElement && path.length < 5) {
    const i = Array.from(cur.parentElement.children).indexOf(cur) + 1;
    path.unshift(`${cur.tagName.toLowerCase()}:nth-child(${i})`);
    cur = cur.parentElement;
  }
  return {
    selector: el.id ? '#' + el.id : path[path.length - 1],
    tag: el.tagName.toLowerCase(),
    text: (el.innerText || el.value || el.placeholder || '').trim().slice(0, 60),
    role: el.getAttribute('role') || '',
    aria_label: el.getAttribute('aria-label') || '',
    dom_path: path.join(' > ')
  };
})
"""


def heal_click(page: Page, baseline: FP, timeout: int = 3000):
    """
    baseline: 上一次成功运行时落库的指纹。
    1) 快路径:原 selector 直接命中,零额外开销
    2) 慢路径:指纹加权匹配,≥ 阈值才自动改写,并落审计日志
    3) 低于阈值:直接抛异常,绝不静默通过
    """
    try:
        page.locator(baseline.selector).click(timeout=timeout)
        return baseline.selector, "direct"
    except PWTimeout:
        pass

    best, best_score = None, 0.0
    for raw in page.evaluate(_COLLECT_JS):
        cand = FP(**raw)
        if not (cand.text or cand.aria_label):
            continue
        s = baseline.score(cand)
        if s > best_score:
            best, best_score = cand, s

    if best is None or best_score < THRESHOLD:
        raise AssertionError(
            f"[自愈失败] 未找到可信替代元素:期望 {baseline.text!r},"
            f"最高分 {best_score:.2f} < 阈值 {THRESHOLD}"
        )

    _audit(baseline, best, best_score)
    page.locator(best.selector).first.click(timeout=timeout)
    return best.selector, f"healed({best_score:.2f})"


def _audit(old: FP, new: FP, score: float):
    """每次自愈都必须留痕:谁改的、改成什么、为什么改。"""
    with open(HEAL_LOG, "a", encoding="utf-8") as f:
        f.write(json.dumps({
            "ts": time.time(),
            "score": round(score, 3),
            "from": old.__dict__,
            "to": new.__dict__,
        }, ensure_ascii=False) + "\n")
```

这里必须说一下我们被坑的一次。

一开始阈值设的 0.82,跑得挺顺,成功率看着也漂亮。过了三个月发现有个流程的测试一直是绿的,但那个功能其实早就坏了。查下来是自愈把"删除"按钮匹配成了"编辑"按钮——两个按钮文案相似度 0.83,刚过线,于是脚本每次都点错按钮,然后后面的断言恰好也都过了。

那次之后我们把阈值提到 0.9,并且加了黑名单:删除、支付、提交这类危险操作直接禁用自愈,失败就是失败,不许找替代。同时每次自愈都写日志,每周过一遍,哪个元素老被自愈就反过来找研发加 data-testid。

自愈这东西没有阈值和审计,会比不自愈更糟。元素其实已经错了但测试还是绿的,团队在假的安全感里一路跑下去,比每天看红色可怕多了。

几块我们没做成或者做得一般的

AI 判 AI。 就是用另一个模型给输出打分。我们试过,快是快,用来给探索性测试的结果排优先级还行,当质量门禁不行。它对边缘情况的错误明显偏乐观,人能看出来的问题它经常给高分。如果你的链路是 AI 生成用例、AI 判定结果,那这个系统正在犯的错,恰好就是它检测不到的错。

非确定性。 我们有个客服机器人,最早的断言写的是 assert "退款已受理" in response,结果 CI 时红时绿,一天重跑三四次。后来改成属性断言:输出里必须带订单号、不能出现承诺性的法律措辞、不能泄露其他用户信息,不看具体字面。同一场景跑 20 次,按通过率判。这个麻烦,但没别的办法,AI 参与的系统就是不能用单次断言。

视觉回归和性能压测。 视觉这块我们只是换了带语义模型的工具(原来像素级 diff 误报太多,字体渲染差一点就刷屏),确实清净了不少,但没往深了做。性能那边试着用生产流量日志生成负载画像,比"一千并发匀速爬坡"真实很多,能压出匀速模型压不出来的问题,不过我们也就试了一次大促,样本不够,不好下结论。

如果你也要做

别一上来就重构整个测试栈,那是给自己找事。我们的顺序是这样的:

先挑一个高频又不出事的流程,比如登录回归或者接口冒烟,让 AI 和现有测试并行跑两周,不动现有东西,只观察。这两周你会遇到大部分坑,成本低。

然后接自愈层,同时把度量看板搭起来。这里提醒一句,基线一定要在接入之前就量好,不然事后你说不清到底省了多少。我们当时就吃了这个亏,前两周没记录,后面只能靠回忆估数。

再往后才是失败聚类、变更影响分析这些。我们做了个把失败栈归一化再聚类、然后让 AI 结合当次 commit 猜根因的东西,效果还行,一次 nightly 三百条红的能归成三四个簇,比人肉看快得多。

指标上我们看这几个:用例编写耗时、每周维护工时、不稳定用例占比、无效失败率。但有一条是底线——逃逸到生产的缺陷率不能变差。前面几项难看都行,这一项不行。如果 AI 接进来之后发版快了但线上事故也多了,那就是负收益,赶紧停。

最后一条,prompt 记得进版本库,连着模型版本号、temperature、生成日期一起存。我们早期没存,后来想复现一批三个月前生成的用例,怎么都跑不出同样的结果,只能重来。

就这些

现在的状态是,写脚本和维护脚本这两块确实省了不少人力,工程师的时间更多花在判断该测什么、这条断言的意图是什么上面了。

至于网上说的"AI 替代测试工程师",半年下来我没看到这个迹象,看到的是它把重复劳动接走了,剩下的活反而更费脑子。

代码都在上面了,自愈那段和接口派生那段可以直接拿去改。

更多推荐