两个任务同时跑,B 的登录态为什么被踢回登录页

《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 次

小结

按代价从高到低排:

  1. 别写清 cookie 的代码------new_context() 已经隔开了,这活儿是白干
  2. 账号池------大部分团队不需要;有互踢规则才上,用例少就固定分配
  3. 重试上限 1 次------重试能救会话过期,也能掩盖真 bug
  4. 清理分三层------账号会话那一层会踢掉别人,默认不做
  5. 业务数据不在隔离范围内------context 隔的是浏览器状态,不是数据库

本节是教学演示用的最小实现,只覆盖 cookie 级隔离,没做 token 刷新、没做单点登录踢人、没做账号池。

下一篇是 Day43《失败列表只有一行 Timeout 2000ms,怎么追到具体步骤》,讲怎么把一次「失败了」变成一条能定位到具体断言的证据链------没有证据的失败,等于没失败。


本文出自《AI 重塑测试 · 90 天实测日记》系列(第 42 篇),作者「楠风测开」。系列按天记录测试平台的实测过程、本机环境与未验证项,结论以实跑记录为准。

相关推荐
codigger1 小时前
大厂git提交规范
git·程序员
挖掘狂人1 小时前
六条分支,一条主干:我的 Git 分支管理心得
git·后端·程序员
程序员cxuan1 小时前
再见了 Typora
人工智能·后端·程序员
深蓝AI4 小时前
45 万星的开源逆向 MCP:REA 让 Agent 无源码读懂任意应用,把证据和边界写进设计
人工智能·程序员
陈随易16 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
kyriewen16 小时前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员
陈随易17 小时前
3000 万人在用的翻译插件,重写一遍就是一门生意
前端·后端·程序员
Hilaku19 小时前
MCP 协议会成为前端 AI 工程的标准接口吗?
前端·javascript·程序员
陈随易1 天前
Cloudflare 出钱,Vite 团队挑人,44 个维护者分 30 万美元
前端·后端·程序员