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

5 阅读9分钟

《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隔离最彻底,排查最简单每次都要登录,慢
按任务固定分配账号简单,一行映射任务数不能超过账号数
账号池任务数不受限,并行度高要管取号 / 还号 / 超时回收 / 脏号

判断线很简单:用例不超过十条,就用「按任务固定分配」。 账号池是为「几十个任务并发跑同一批账号」准备的,那是另一个量级的问题。

真要用池子,最小实现也得有这两样:

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(账号)

注意 取号 抛异常而不是阻塞等待。阻塞等待会把「账号不够」这个配置问题变成一个跑到一半才出现的超时,难查得多。

二、登录态复用:省下的时间要算进账

复用登录态的收益很实在:省掉重复登录。 登录要走密码校验、加解密、可能还有短信验证码,这几秒乘以几十条用例就是好几分钟。

但代价必须写下来:

可以复用:只读断言、不改数据、不依赖登录态内容
不能复用:会下单、会改金额、会写数据、跑失败后要重试

判断标准:这条用例跑完之后,状态变了吗? 变了就不能复用。

还有一个隐蔽的代价:复用之后,「登录态有效」这个前提从用例的前提变成了一个隐式假设。 哪天认证服务改版或者 token 过期策略变了,你的业务用例会莫名其妙挂一批,而且挂的原因看起来跟业务无关。

一条经验:认证功能测试和已认证业务测试分开组织。 前者每次真登录,后者可以复用。混在一起的时候,你会分不清「登录挂了」和「业务挂了」。

另外别用 storage_state 文件在任务之间共享登录态。那个文件被一个任务覆盖,另一个任务读到的就是另一个身份,而且不会报错。

三、重试策略:最贵的那个「贴心」

先给实测:密码错误时接口返回 401,页面提示「登录失败」;登录成功时接口返回:

{
  "username": "tester",
  "order_count": 3,
  "token": "TOKEN-tester"
}

会话失效的信号有三种:跳回登录页(地址栏变成 /login-slow)、接口 401、页面出现「请先登录」。

你当然可以写成「检测到未登录就重登一次」:

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 这个账号下」:

账号表 = {
    "tester": {"密码": "pass123", "订单数": "3"},
    "admin":  {"密码": "pass456", "订单数": "27"},
}
页面.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 能解决的,也别指望它解决。

六、并发场景的代价

并发跑的时候,最容易出的事是两个任务抢同一个账号。

防护很简单:每个任务一个账号,断言只认自己那个账号的数据,断言放在写操作之前。

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 篇),作者「楠风测开」。系列按天记录测试平台的实测过程、本机环境与未验证项,结论以实跑记录为准。