应届生第一个 Playwright 小项目:跑 100 次量出 flaky 率,产出物直接进作品集
聊到 UI 自动化,对面问了句很轻的话:你写的那些用例,怎么知道它稳不稳?你张嘴就是"跑了一遍,全绿"。追问跟着就到:"跑了几次?有没有重试?重试才过的占多少?"三个问句,一个都接不上。因为绿是一个瞬间的状态,稳是一段分布上的概率——手里没有跑过 N 次的记录,这题就没法答,只能重复那句"我这边跑是绿的"。
这不是抬杠,是这轮工具演进下新人最该补的一块。据第三方发布追踪(gitwatchman、stuffinsider、libraries.io,核实于 2026-10-02),Playwright 在 2026 年已经推进到 v1.6x 一线——这是第三方口径、不是官方核实,也不牵扯任何特性效果数字。它只说明一件事:版本迭代到这个频率,"会用某个工具"的半衰期极短,而"能回答这段自动化可信几分"这件事,换 runner、换版本都还得有人做。会用工具不值钱,能量化用例稳不稳才值钱。
四步就能走完:写一小批有抖动风险的用例 → 同一批连跑 N 次 → 算首过率、flaky 率、失败分布 → 产出一页带口径的稳定性报告。
第一步:写一小批用例,别挑那种永远绿的
五到八条就够,但每条都要有真实抖动风险:一个搜索联想框(要等结果列表异步回来)、一个加入购物车(要等状态回写)、一个头像上传(要等后台任务完成再刷新)、一个结算页(元素多、选择器最容易跟着改版抖)。别拿"打开首页断言标题对不对"凑数——它一百次能绿一百次,量出来的数只会骗你自己。这一步练的是选靶子:知道哪里会抖,比会写多少条 spec 重要。
第二步:同一批用例连跑 N 次,每轮只记三态
Playwright 这类 runner 基本都提供把同一批用例重复执行 N 遍的参数(具体名字去官方 CLI 文档确认一次,版本会变),CI 里挂上就能跑;一时接不上 CI,就在本地连跑一轮把结果抄下来。关键是记录口径:每条用例每轮只有三种落点——首跑就过(first_pass)、首跑挂了重试才过(flaky)、重试仍然挂(hard_fail)。把这三态落成一张表或一串 JSON,你后面所有指标都从这三态里算,不用重跑。
三个口径先钉死,不然数字会打架
同一个"通过",三个人能算出三个数——这就是聊稳不稳之前必须先把口径写在纸上的原因。首过率 = 首跑就过的轮次 ÷ 总轮次,它才代表"你写的用例本身有多可信";flaky 率 = 重试才过的轮次 ÷ 总轮次,它代表"有多少不稳定被重试掩盖了";两者相加,就是你开头那句"全绿"里的水分。再补一个不稳定分 = flaky 率 + 硬失败率,也就是"这一轮首跑不值得信"的比例,它是你排治理优先级的排序键,不是拿来汇报好看的成绩。三个口径里我最建议你盯首过率:它是唯一一个不会因为多按一次重试就自己变好的数。口径先说死,后面一半争论都能省掉。
接回旧知识:这就是把"跑一遍"换成"跑统计"
别被 flaky 这词唬住,它和你写过的接口断言是同一套骨架,只是判定对象变了。做接口测试时你对的是"这一次响应对不对"——状态码、字段结构、错误码;现在你对的是"一百次里对了几次"。你早就会的那些动作全用得上:一个 for 循环、一个 dict 装结果、collections 里的 Counter 分类计数、把统计丢进表格看一眼。要补的只有三件小事:定三态口径、把每条用例的分布算出来、把失败按性质分开数。这也是这个小项目适合新人第一周就做的原因——它不逼你啃一套陌生范式,只把你已经会的"写断言"往前推一格,推到"给断言打分"。
第三步:一条命令算出首过率、flaky 率和失败分布
纯标准库、样例内置、不联网、不读外部文件,复制即跑——这一段就是你面试能当场打开摊在桌上的东西:
# flaky_report.py —— 纯标准库(random/collections),样例内置,`python flaky_report.py` 直接跑通
import random
from collections import Counter, defaultdict
random.seed(20261002) # 固定随机种子:同一份样例每次跑出同一张报告,可复现
ROUNDS = 100 # 同一批 UI 用例连跑 100 次
RETRY = 1 # 失败后允许重试一次(重试才过 = flaky)
# 档①:一小批 UI 用例(对应你真实项目里的 spec)
# fail_p = 这条用例首跑挂掉的概率;ftype_w = 挂掉时归因权重(locator 定位类 / assert 断言类 / wait 等待类)
CASES = {
"login_form.spec": {"fail_p": 0.05, "ftype_w": {"locator": 0.2, "assert": 0.2, "wait": 0.6}},
"search_suggest.spec": {"fail_p": 0.20, "ftype_w": {"locator": 0.3, "assert": 0.1, "wait": 0.6}},
"cart_add.spec": {"fail_p": 0.08, "ftype_w": {"locator": 0.5, "assert": 0.3, "wait": 0.2}},
"checkout_flow.spec": {"fail_p": 0.25, "ftype_w": {"locator": 0.2, "assert": 0.5, "wait": 0.3}},
"profile_avatar.spec": {"fail_p": 0.45, "ftype_w": {"locator": 0.6, "assert": 0.2, "wait": 0.2}},
}
FTYPES = ["locator", "assert", "wait"]
def pick_ftype(w):
r, acc = random.random(), 0.0
for k, p in w.items():
acc += p
if r <= acc:
return k
return "wait"
# 档②:连跑 ROUNDS 轮,每轮记录首过 / 重试才过(flaky) / 重试仍挂(硬失败)
hit = defaultdict(Counter) # hit[case]["first_pass"|"flaky"|"hard_fail"]
ftype = defaultdict(Counter) # ftype[case] 只统计硬失败的归因
for name, c in CASES.items():
for _ in range(ROUNDS):
if random.random() >= c["fail_p"]:
hit[name]["first_pass"] += 1
continue
if random.random() < 0.8: # 等待类抖动重试大概率能过(演示口径)
hit[name]["flaky"] += 1
else:
hit[name]["hard_fail"] += 1
ftype[name][pick_ftype(c["ftype_w"])] += 1
print(f"=== 用例稳定性报告(样例轨迹 / 连跑 {ROUNDS} 次 / 允许重试 {RETRY} 次)===\n")
print(f"{'用例':<22}{'首过率':>8}{'flaky率':>9}{'硬失败率':>10} 不稳定分 失败分布(定位/断言/等待)")
rows = []
for name in CASES:
h = hit[name]
fp = h["first_pass"] / ROUNDS
fl = h["flaky"] / ROUNDS
hf = h["hard_fail"] / ROUNDS
unstable = fl + hf # 不稳定分:首跑不可信的比例
dist = "/".join(str(ftype[name][t]) for t in FTYPES)
rows.append((name, fp, fl, hf, unstable, dist))
print(f"{name:<22}{fp:>7.0%}{fl:>8.0%}{hf:>9.0%}{unstable:>10.0%} {dist}")
first_pass_all = sum(hit[n]["first_pass"] for n in CASES) / (ROUNDS * len(CASES))
flaky_all = sum(hit[n]["flaky"] for n in CASES) / (ROUNDS * len(CASES))
print(f"\n套件级:首过率={first_pass_all:.0%} flaky率={flaky_all:.0%}"
f" (表面绿灯率={first_pass_all + flaky_all:.0%},含重试才过的水分)")
worst = max(rows, key=lambda r: r[4])
print(f"最不稳定:{worst[0]} 不稳定分 {worst[4]:.0%} → 优先治理")
print("\n=== 放行判定 ===")
gates = []
if first_pass_all < 0.90:
gates.append(f"套件首过率 {first_pass_all:.0%} < 90%,先补等待策略再谈全绿")
if flaky_all > 0.05:
gates.append(f"套件 flaky 率 {flaky_all:.0%} > 5%,重试次数不是越多越好")
if worst[4] > 0.30:
gates.append(f"{worst[0]} 不稳定分 {worst[4]:.0%} > 30%,隔离到单独 job 观察")
if ftype[worst[0]]["wait"] / max(sum(ftype[worst[0]].values()), 1) >= 0.5:
gates.append(f"{worst[0]} 失败集中在等待类,先改断言写法再考虑加超时")
print("HOLD(先修不稳定,不放行『全绿』结论)" if gates else "PASS(可报全绿)")
for g in gates:
print(" - " + g)
为什么这么写、踩了什么坑: 第一版我把指标定成"最终通不通过",跑完报出来一个很好看的数——接近九成的用例最后是绿的。差点就把这版贴进作品集,后来才反应过来:那里面一大半是靠重试捞回来的,我把 flaky 洗成了稳定。改成三态分开记之后,画面完全反过来,首跑就过的只有不到八成,首跑挂掉的上百次里有七成多被重试悄悄吃掉。第二坑是重试次数必须写死:改成允许重试两次,flaky 率立刻变漂亮——你优化的是指标,不是用例。第三坑最值钱:失败不分类,你永远只会加重试。脚本把最不稳定的那条单独点了名,它的硬失败几乎全堆在定位类,这种抖加多少超时都没用,得回去改选择器;只有集中在等待类的,才有资格谈重试和超时。所以判定环节我没只放阈值,而是按失败性质分流——这也是报告里那句 HOLD 的真正含义:不是"这次没跑绿",是"这批用例还不足以支撑一次放行判断"。顺手加固定随机种子,是因为作品集里的东西最怕"我这儿能跑"。
第四步:交一页带口径的《用例稳定性报告》
脚本跑通不是终点,那页纸才是能带走的东西。一页里写清:样本量与重试策略、三态定义、每条用例的首过率/flaky 率/硬失败率/不稳定分、失败分布按定位类-断言类-等待类各占多少、点名最不稳定那条、最后一行放行判定。再配两句容易被追的坦白——"这一百轮是我自己造的样例轨迹,不是任何真实项目的 CI 记录","90%、5%、30% 三条线是演示拍的,接到真实项目要按 CI 时长和历史抖动重标"。有口径的数字才叫证据。能主动说清这两句,比报一个大数更像一个做过的人。
接上 CI,让这份报告每天自己长一遍
小项目要变成长习惯,得让它自动化:把三态记录落成每条用例每次执行的一个 JSON 行(runner 自带的 JSON 报告器基本都能出这种格式),让统计脚本读目录而不是读你手抄的表;再把"连跑 N 轮"挂成夜间任务,早上第一件事是看首过率有没有掉。这一步真正的价值不在省事,而在把"我觉得这条用例挺稳"变成"系统每天替我记一次账"。一个月的记录攒下来,你第一次有了属于自己的抖动基线,也第一次能在别人说"这条偶尔会红"时接上一句:它过去三十天红了四天,全在定位类,上周改完选择器已经首跑就过了——这句话的分量,比任何"熟悉 Playwright"都重。
会说"我会 Playwright"和能交一份 flaky 报告,差在哪
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
这一步往就业上怎么看,以及它的边界
抬一层看:据公开的招聘信息定性来看,AI 与自动化方向的测试岗一直在招,别只盯"点测"两个字——量化稳定性这类活儿,恰好长在这条方向上。这只是方向性判断,本文不引任何规模或报酬类的数。项目本身也有边界,说清楚反而加分:一百轮执行记录、各条用例的不稳定分、失败分布和那句 HOLD 判定,都是本文构造的演示数据,跑的是随机模拟而不是任何真实被测系统,也不指涉任何真实企业的面试原题。想把这条线从"会用工具"练成能当场摊开的产出物,学社 AI 测试作品集实战做的就是这类演练。
全绿是一瞬间的状态,稳不跑得一百次才算得出来。
今晚就把你最不放心那条 UI 用例连跑 20 次,记下三态算个首过率——数字比争论好使。
本文部分内容参考了霍格沃兹测试开发学社整理的相关技术资料,主要涉及软件测试、自动化测试、测试开发及 AI 测试等内容,侧重测试实践、工具应用与工程经验整理。
- 点赞
- 收藏
- 关注作者
评论(0)