摘要:同一个测试账号被两条用例同时读写时,先失败的往往是改到一半的中间态,而 workers 这个数字从来不是资源约束。一手来源为 Playwright 官方 Release Notes 的 Version 1.63 条目 Test locks 一节,属版本条目原文口径、未标发布日期。可拿走四口径调度对照、能算冲突对数与墙钟的仿真脚本及真实输出、锁名两种写法的利弊表、按资源命名的锁清单与三条 CI 检查。它只覆盖调度层的互斥,不替代测试数据隔离与外部状态复位。
两条各自跑绿的用例并发跑在同一个测试账号上:一条把它从个人版切成企业版,另一条同时去读会员价——读的那条拿到的是没写完的中间态,于是红的是共享状态的瞬时值,不是被测功能。Playwright 官方 Release Notes 的 Version 1.63 条目把这件事收进了测试框架:Test locks(测试锁:写在用例声明上的具名互斥位),声明了同一个锁名的用例不并发,其余用例照旧并行;口径性质是版本条目的功能说明而非性能承诺,条目本身也没有标发布日期。这篇要处理的那个具体问题很窄:锁的粒度只到"锁名"这一层,所以名字怎么写、打错一个字符会不会静默失效、两条各持两把锁又反序去取会不会把流水线挂住。
官方给的那位新成员:具名测试锁
据 Playwright 官方 Release Notes 的 Version 1.63 条目,Test locks 这次正式进来了。访问共享资源的用例——官方点名的例子是外部服务和全局账号设置——可以声明一个具名 lock。原话值得整句抄下来:共享同一个锁名的用例绝不并发,跨文件、跨 worker、跨 project 都成立,而其余一切继续并行。
// 官方 API 形态示例(v1.63 Test locks 条目)
test('把测试账号切成企业版', { lock: 'account:TEST_001' }, async ({ page }) => {
// 改全局账号设置,再断言生效
});
test('会员价展示校验', { lock: 'account:TEST_001' }, async ({ page }) => {
// 与上面那条同名,因此永不与它同时跑
});
// 一个用例可以持有多把锁
test('企业版下单并核销优惠券',
{ lock: ['account:TEST_001', 'coupon:GLOBAL'] }, async ({ page }) => {});
// 整组加锁:这一组内部串行,和别的组之间照样并行
test.describe('短信通道用例', { lock: 'notify:SMS' }, () => {
test('发送验证码', async ({ page }) => {});
test('验证码限流', async ({ page }) => {});
});
这对测试团队意味着的第一件事,是它把"哪些用例不能同时跑"这件事从口头约定变成声明。过去我们靠三种土办法:把 workers 设成 1、把会打架的用例塞进同一个文件里跑串行、或者在 README 里写一句"这几条别一起跑"。三种都有各自的失效方式,而失效方式决定了它们会不会在三个月后还活着。
图注:具名锁的语义只有半句是官方承诺——同名不并发;剩下半句(外部状态怎么收拾)是我们的事。
四种调度口径,谁真的挡住了串扰
我把这条语义写成一个只用标准库的调度仿真(02_锁调度仿真.py):12 条用例分布在 3 个文件、4 个 project 上,4 个 worker,每条用例声明它碰哪个共享资源、是读还是写,外加可选的锁名。"冲突"的定义很硬:两条用例的执行窗口重叠,碰同一个资源,且至少一方是写。
def conflicts(timeline): # 冲突=同一共享资源被并发读写
bad, info, ids = [], {c["id"]: c for c in CASES}, sorted(timeline)
for i, a in enumerate(ids):
for b in ids[i + 1:]:
ca, cb = info[a], info[b]
if ca["res"] != cb["res"] or"w"notin (ca["op"], cb["op"]):
continue
sa, ea = timeline[a][0], timeline[a][1]
sb, eb = timeline[b][0], timeline[b][1]
if ea isNoneor eb isNone: # 停滞场景:没跑完,没有结束点
continue
if sa < eb and sb < ea:
bad.append("%s(%s) × %s(%s) 同时读写 %s:%d-%d 与 %d-%d" % (
a, ca["op"], b, cb["op"], ca["res"], sa, ea, sb, eb))
return bad
终端原文(完整输出 41 行,这里删去 [0] 用例清单与 [2] 泳道两段的逐行内容):
[1] 四种调度口径对照(冲突=同一共享资源被并发读写)
口径 冲突对 墙钟 tick 并行度 说明
workers=4 且不声明 lock 2 10 3.20 用例随便并
↳ A1(w) × B1(r) 同时读写 account:TEST_001:0-3 与 0-4
↳ D1(w) × D2(r) 同时读写 coupon:GLOBAL:4-6 与 5-7
全场一把锁(等价 workers=1) 0 32 1.00 所有用例排队
文件名当锁(等价文件内串行) 2 12 2.67 只压住同一文件
↳ A1(w) × B1(r) 同时读写 account:TEST_001:0-3 与 0-4
↳ B2(w) × C2(r) 同时读写 order:POOL:4-6 与 3-5
workers=4 + 具名 lock 0 13 2.46 同名锁不并发,其余照并行
四个数放在一起,结论就不是口号了。不声明锁时冲突确实发生,而且发生在两条你都不会怀疑的用例上:一条写账号、一条只读账号——读的一方拿到的是写方还没收尾的中间态,这就是那条断言读出半个状态的成因。全场串行把冲突清零,代价是墙钟从 10 个 tick 涨到 32 个,这条路的尽头是没人愿意跑全量。"文件内串行"这一格最值得看:它把同一文件的用例排好了,可 account:TEST_001 那对冲突偏偏跨文件,于是冲突对数纹丝不动还是 2,而且新引入一对 order:POOL——不同文件里的读写撞上了。具名锁这一栏冲突为 0,墙钟 13,只比放任并行多 3 个 tick:这就是官方那句"其余一切继续并行"的实际形状。
必须补一句口径:这套 tick、并行度和冲突对数在仿真内部自洽,它只回答一个问题——在哪种调度约束下,同一资源的重叠读写会归零。
资金与一致性视角:锁名应该写成资源名
同样一段声明,写法有两种,效果差很多。
锁名写法
例子
它约束的是谁
副作用
按用例/功能起名
bill-page
、refund-case-3
只把"长得像"的用例串起来
新加的用例不知道要复用哪把锁,冲突照旧
按共享资源起名
account:TEST_001
、coupon:GLOBAL、notify:SMS
碰同一资源的所有用例,跨文件跨 project
锁名清单本身变成一份资源台账
我们的做法是把锁名当成一份可读的资源清单来管:每个名字对应一个具体的外部对象,负责人是谁、怎么复位、能不能并行读,都写在那份清单里。因为官方承诺的只有"同名锁不并发"这半句,另外半句——测试数据隔离和外部状态清理——它没管,也不能替我们管。账号被改坏了不会因为你上了锁就自动复原;锁只是保证没人跟你同时动手。这一层限定是本文的工程判断,不是文档里的承诺。
两个坑:静默失效与调度停滞
第一个坑是名字。锁的互斥单位是"锁名",不是"资源"。
[3] 锁名打错一个字符:B1 / C1 写成 user_settings(下划线)
冲突对:2(同名才算同一把锁,名字不同就是两把)
↳ A1(w) × B1(r) 同时读写 account:TEST_001:0-3 与 0-4
↳ A2(w) × C1(w) 同时读写 notify:SMS:3-6 与 4-7
user-settings 写成 user_settings,用例照跑、报告照绿,只有把冲突检测打开才看得见:两条用例又同时读写同一个账号了。这类失效最难被发现,因为它不影响任何一条用例自己的结果。所以我们把锁名当字符串资产:集中一份常量表,用例从表里取,PR 里新增资源名要过一遍。
第二个坑是多把锁的顺序。一个用例可以持有多把锁——这是官方能力;而当两条用例各自要两把锁、取的顺序相反时,调度器就会卡住:
[4] 一条用例持两把锁、两条用例加锁顺序相反(2 worker 收口验证)
停在第 7 tick:B1 持 pay-channel 在等 user-settings;B2 持 user-settings 在等 pay-channel
此刻锁归属:pay-channel←B1,user-settings←B2
两条用例都没跑完(结束时间为 None),流水线会一直挂到步骤超时
为什么这么写、踩了什么坑,四条。第一,仿真里我把"第二条锁"设成用例执行到中段才申请,就是为了复现真实世界里最常见的那种卡法:前一半跑得好好的,需要外部资源时才要第二把锁。第二,加锁顺序这件事没有任何机制替你兜住,它是声明式互斥的自然代价;我们的做法是给多锁用例定一条全库统一的排序规则(按资源名字典序取锁),把顺序写进代码生成而不是靠人记——这属于本文推导的补救方案。第三,停滞的表现形式是超时而不是失败,日志里两句"某个用例没跑完",很容易被当成网络抖动重跑一次了事;重跑之后如果调度顺序变了,它又绿了,问题就被彻底埋进随机性里。第四,别把"重跑一次就绿"当回归通过的标准,这条恰好是并发污染最会伪装的形状。
怎么证明锁真的生效
只看红绿证明不了互斥,得看时间线。官方在同一个条目里给了两件合手的工具:内置的 perfetto reporter 会写 Trace Event Format,用 Perfetto UI 或 chrome://tracing 打开,能把一次运行渲染成每个 worker 一条泳道的时间线;HTML 报告现在会在步骤旁边渲染 duration waterfall。再加一句命令层面的坑:--reporter 是替换现有报告,--add-reporter 才是追加——排查并行问题时要留着原来那份能看单测结果的报告。
泳道图读法就一条:同一个资源名下的用例,它们的块在纵向不同泳道上不该有重叠的横向区间。仿真里那段泳道输出就是这个读法(每格一个 tick,.. 是空闲):worker 0 排着 A1、A2、B1、C1 四条同名锁用例,一条接一条;而 worker 1 到 worker 3 上那些没有锁或用别的锁的用例照常并行推进。你不需要读源码就能回答"这两条到底有没有同时跑"。
挂进 CI 的三件事
其一,PR 级校验声明而不只是校验结果:把锁名与共享资源清单做一次比对,凡是断言里出现测试账号写入、优惠券核销、短信发送这类动作却没有声明 lock 的用例,直接在 PR 上过红——这一步比任何事后复盘都便宜。其二,每日全量带上 perfetto 与 HTML 两份报告(用 --add-reporter),泳道文件随构建产物存三天,红图出现时先问"这条用例的时间窗口和谁重叠了"。其三,给停滞留一条硬约束:CI 步骤超时设成明确值,并在超时后自动导出泳道,别让它挂在"看起来还在跑"的状态里过夜。至于环境侧的提醒,同一个条目里还写着三个组件包不再更新、一个 Linux 发行版不再支持——这属于另一件事,但它同样说明:并行能力的边界,往往就写在版本号旁边。
边界:这段互斥管不到什么
第一,官方承诺的是"同名锁不并发,其余继续并行",范围止于此;数据隔离、外部状态复位、账号可用性都不是它管的活。第二,多把锁用例的加锁顺序、锁名与资源清单一一对应、超时后导泳道,这三条都是本文的工程推导与示例做法,不是文档里的规范。第三,发布日期这一栏官方条目里是空的,本文不编一个上去;要写复核时间戳,就按当日官方页的实际排序去认最前面那条版本,别拿记忆里的版本号当依据。第四,条目里的内核三元组(Chromium 153.0.8010.12、Firefox 155.0、WebKit 26.6,另写明也针对 Google Chrome 153 与 Microsoft Edge 153 做过测试)是版本事实,不等于所有新特性在每个内核上都可用——锁是调度层能力,但用例本身依赖的浏览器行为还得按内核分别验。
下一步
今晚就把最容易打架的那一类挑出来:所有会写同一个测试账号的用例。给它们一个统一的锁名,名字按资源写、不按用例写,然后在 CI 里加一条"写共享资源却没声明锁就红"的检查。第二天再出现这类红,就不用靠人去猜是谁跟谁撞上了——冲突对数和泳道会直接报出那两条用例的名字。
并发的问题从来不是"谁跑得快",而是"谁允许你们同时动手"——把这句话写成声明,红灯才第一次有了责任人。
本文部分内容参考了霍格沃兹测试开发学社整理的相关技术资料,主要涉及软件测试、自动化测试、测试开发及 AI 测试等内容,侧重测试实践、工具应用与工程经验整理。