《AI 重塑测试 · 90 天实测日记》第 42 篇 · 结论以本机实跑记录为准
先摆代价,再说做法。
会话隔离这件事,框架已经替你做完一大半了。 Playwright 的 browser.new_context() 创建的是完全独立的浏览器上下文,Cookie、缓存、localStorage 天然互不干扰。
我实测过(本机 Windows / Python 3.11.8 / Playwright 1.62.0 / Chromium 151.0.7922.34,测试页面 /login-slow 和 /profile):
| 场景 | /profile 显示 |
|---|---|
| context A 登录后,带 cookie 访问 | 当前用户 tester ,专属订单数 3 |
| context A 登录后,不带 cookie 访问 | 请先登录 |
| context B(另一个上下文),不带 cookie 访问 | 请先登录 |
A 登录之后,B 完全看不到 A 的登录状态。
所以「写一堆清 cookie 的代码」这种方案,代价是你在维护一份根本不必要的东西。 隔离是架构给的。
真正贵的地方在别处,全在这篇里:登录态复用的取舍、账号池要不要做、重试策略什么时候会掩盖真问题。
上下文语义见 Playwright BrowserContext 文档,Cookie 的作用见 MDN Cookie 文档。
一、账号池:大部分团队不需要
先问一句:你的系统有「同一个账号只能有一个会话」这条规则吗?
没有的话,账号池这个复杂度一分钱都不用花。 我见过不少团队一上来就搭池子、搞租约、加超时回收,跑了一个月发现服务端根本没有互踢逻辑。
有的话(很多系统有「单点登录」或者「登录后踢掉旧会话」),代价对比是这样的:
| 方案 | 好处 | 代价 |
|---|---|---|
| 每条用例新建 context | 隔离最彻底,排查最简单 | 每次都要登录,慢 |
| 按任务固定分配账号 | 简单,一行映射 | 任务数不能超过账号数 |
| 账号池 | 任务数不受限,并行度高 | 要管取号 / 还号 / 超时回收 / 脏号 |
判断线很简单:用例不超过十条,就用「按任务固定分配」。 账号池是为「几十个任务并发跑同一批账号」准备的,那是另一个量级的问题。
真要用池子,最小实现也得有这两样:
python
class 账号池:
def __init__(self, 账号清单):
self._空闲 = list(账号清单)
self._锁 = threading.Lock()
def 取号(self):
with self._锁:
if not self._空闲:
raise RuntimeError("没有可用账号")
return self._空闲.pop()
def 还号(self, 账号):
with self._锁:
self._空闲.append(账号)
注意 取号 抛异常而不是阻塞等待。阻塞等待会把「账号不够」这个配置问题变成一个跑到一半才出现的超时,难查得多。
二、登录态复用:省下的时间要算进账
复用登录态的收益很实在:省掉重复登录。 登录要走密码校验、加解密、可能还有短信验证码,这几秒乘以几十条用例就是好几分钟。
但代价必须写下来:
text
可以复用:只读断言、不改数据、不依赖登录态内容
不能复用:会下单、会改金额、会写数据、跑失败后要重试
判断标准:这条用例跑完之后,状态变了吗? 变了就不能复用。
还有一个隐蔽的代价:复用之后,「登录态有效」这个前提从用例的前提变成了一个隐式假设。 哪天认证服务改版或者 token 过期策略变了,你的业务用例会莫名其妙挂一批,而且挂的原因看起来跟业务无关。
一条经验:认证功能测试和已认证业务测试分开组织。 前者每次真登录,后者可以复用。混在一起的时候,你会分不清「登录挂了」和「业务挂了」。
另外别用 storage_state 文件在任务之间共享登录态。那个文件被一个任务覆盖,另一个任务读到的就是另一个身份,而且不会报错。
三、重试策略:最贵的那个「贴心」
先给实测:密码错误时接口返回 401,页面提示「登录失败」;登录成功时接口返回:
json
{
"username": "tester",
"order_count": 3,
"token": "TOKEN-tester"
}
会话失效的信号有三种:跳回登录页(地址栏变成 /login-slow)、接口 401、页面出现「请先登录」。
你当然可以写成「检测到未登录就重登一次」:
python
def 断言已登录(页面, 用户名):
页面.goto("/profile")
if 页面.locator("text=请先登录").count() > 0:
登录(页面, 用户名, 账号表[用户名]["密码"])
页面.goto("/profile")
assert 页面.locator("[data-testid='username']").inner_text() == 用户名
这段代码很省事,也很容易被人加码成「重试 3 次」。
代价是:你把「会话管理有 bug」这个事实藏起来了。
一个每次都能重登成功的断言,看起来很稳。但如果它第一次就失败了------说明会话生命周期管理本身有问题------这个信息被重试吃掉了,红灯刷成绿灯。
所以重试上限写死成 1 次。 重登之后还不行,让它失败。
这条的判断标准很清楚:重试次数 > 1 的时候,你已经不是在测业务了,是在测自己的重试逻辑。 除非你测的就是重试本身。
顺带说一句:别在重试时静默换一个权限更高的账号。有些脚本会「拿不到 tester 就用 admin」------那等于把权限断言整个废掉了。
四、权限差异:期望值必须和账号绑死
不同账号看到的菜单和数据不一样。实测 tester 的专属订单数是 3。
所以断言得写清楚是「在 tester 这个账号下」:
python
账号表 = {
"tester": {"密码": "pass123", "订单数": "3"},
"admin": {"密码": "pass456", "订单数": "27"},
}
python
页面.goto("/profile")
assert 页面.locator("[data-testid='username']").inner_text() == "tester"
assert 页面.locator("[data-testid='order-count']").inner_text() == 账号表["tester"]["订单数"]
代价很低,收益很高:期望值集中一处,硬编码的 == "3" 至少能让人一眼看出是哪个账号的数。
这一条在多人协作时特别值钱。「我这儿跑得通你那儿跑不通」,十次有八次是登的不是同一个账号。
五、清理:分三层,第三层会波及别人
这是我觉得最容易出事的���方。
很多脚本在 finally 里统一调登出。结果是------A 任务跑完调登出,把 B 任务正在用的同一个账号的会话也作废了。 B 那边莫名其妙被踢回登录页,查半天查不到原因。
清理要分三层:
| 清理对象 | 做法 | 影响谁 |
|---|---|---|
| 浏览器状态 | context.close() 或 context.clear_cookies() |
只影响本轮 |
| 业务数据 | 按本轮记录的资源 ID 逐项删 | 只影响本轮 |
| 账号会话 | 调登出接口 / 等服务端过期 | 可能踢掉别人的会话 |
第三层的代价最高,因为它超出了你自己的边界。 在并发场景下,「登出」这个动作的受害者不止你一个。
默认别做第三层。 让服务端自然过期,或者只在确认没有任何并行任务用这个账号时才登出。
另外一个容易忽略的:cookie 隔离不等于业务数据隔离。 两个 context 之间浏览器状态是分开的,但后端数据是共用的------你往 /profile 写一条记录,另一个 context 里也看得到。
隔离的是浏览器状态,不是数据库。 要真隔离业务数据,得后端按账号分,或者测试环境用独立库。这不是 Playwright 能解决的,也别指望它解决。
六、并发场景的代价
并发跑的时候,最容易出的事是两个任务抢同一个账号。
防护很简单:每个任务一个账号,断言只认自己那个账号的数据,断言放在写操作之前。
python
def test_并发两个账号互不干扰(浏览器):
def 跑(用户名):
上下文 = 浏览器.new_context()
页面 = 上下文.new_page()
登录(页面, 用户名, 账号表[用户名]["密码"])
页面.goto("/profile")
结果 = (页面.locator("[data-testid='username']").inner_text(),
页面.locator("[data-testid='order-count']").inner_text())
上下文.close()
return 结果
with ThreadPoolExecutor(max_workers=2) as 池:
结果 = list(池.map(跑, ["tester", "admin"]))
assert 结果[0] == ("tester", "3")
assert 结果[1] == ("admin", "27")
代价是账号数量要跟并发数匹配。 并行度 8 就至少要 8 个账号。这是账号池唯一站得住的理由。
重复执行场景要查的是残留:跑两轮,断言必须完全一样。第二轮数字变了,说明有数据没清干净。
七、什么时候别做这套东西
最后说停下来的时机。以下几种情况,保持简单就好:
- 用例只有几条------固定分配账号,别上池
- 业务没有同账号互踢规则------账号池整个不需要
- 系统是内部工具、只有你自己用------一个账号一路登录到底,省下的时间够你多写十条用例
- 只想验证登录功能本身------每次真登录,这本来就是被测对象,复用它反而测不到东西
反过来,这几种情况值得投入:
- 同一批账号要撑几十个并发任务 → 账号池
- 服务端有单点登录踢人 → 账号分配必须做
- 用例会写业务数据 → 隔离验证用例必须有
- 登录态过期会导致业务用例随机挂 → 重登分支 + 上限 1 次
小结
按代价从高到低排:
- 别写清 cookie 的代码------
new_context()已经隔开了,这活儿是白干 - 账号池------大部分团队不需要;有互踢规则才上,用例少就固定分配
- 重试上限 1 次------重试能救会话过期,也能掩盖真 bug
- 清理分三层------账号会话那一层会踢掉别人,默认不做
- 业务数据不在隔离范围内------context 隔的是浏览器状态,不是数据库
本节是教学演示用的最小实现,只覆盖 cookie 级隔离,没做 token 刷新、没做单点登录踢人、没做账号池。
下一篇是 Day43《失败列表只有一行 Timeout 2000ms,怎么追到具体步骤》,讲怎么把一次「失败了」变成一条能定位到具体断言的证据链------没有证据的失败,等于没失败。
本文出自《AI 重塑测试 · 90 天实测日记》系列(第 42 篇),作者「楠风测开」。系列按天记录测试平台的实测过程、本机环境与未验证项,结论以实跑记录为准。