每个用例都重新登录太慢:用 Playwright storageState 存下登录态复用
写端到端用例的人大多都经历过这个阶段:第一个用例里老老实实填了用户名密码点了登录,跑通了很高兴;等到用例攒到几十个,每个用例前面都挂着同一段登录动作,整套用例就变成了「登录 N 遍,顺便测点别的」。更难受的是登录页往往还带几跳重定向,登录本身还容易成为最不稳的一环——真正想测的功能没坏,用例却红了。
Playwright 仓库里对这件事的定位写得很直白。docs/src/auth.md 开篇说明,Playwright 把用例跑在互相隔离的 browser context 里,这种隔离让结果可复现、也避免失败连锁;而用例可以加载已有的认证状态,从而省掉每个用例都认证一遍。这篇就沿着这份文档和 docs/src/api/class-browsercontext.md 的签名,把「存」和「读」两头讲清楚。
前置条件:先分清哪些能力只有 JS 绑定有
这一步最容易被跳过,跳过了会白折腾半天。
storageState 这个方法本身是跨语言的。docs/src/api/class-browsercontext.md 里 BrowserContext.storageState 标着 since: v1.8,各语言绑定都有;对应的读取入口是 Browser.newContext 的 storageState 选项,同样标 since: v1.8。所以「存下来再读回去」这套动作,JavaScript、Python、Java、C# 都做得了。
但围绕它的那套工程化写法是 Test Runner 的能力,而 Test Runner 只有 JS 绑定。docs/src/auth.md 里「Basic: shared account in all tests」「Moderate: one account per parallel worker」「Multiple signed in roles」这几节的小标题下面都单独标了一行 * langs: js;而「Signing in before each test」「Reusing signed in state」两节的 * langs: 里列的都是 java、python、csharp 这三种绑定,没有 js。也就是说:setup 项目、test.use({ storageState })、worker 级 fixture 这些写法,是给 @playwright/test 用的,Python 那边的对应做法是文档里另外那一节的手写版本,别把 JS 的写法直接套过去。
Python 侧文档给的是这样两句(同步版本):
# Save storage state into the file.
storage = context.storage_state(path="state.json")
# Create a new context with the saved storage state.
context = browser.new_context(storage_state="state.json")
方法名是下划线风格的 storage_state 与 new_context,参数名是 storage_state——这些都能在文档里逐字对上,不是从 JS 版本推出来的。C# 侧文档还额外提醒了一句:用例在 <TestProject>\bin\Debug\netX.0\ 下执行,所以它的示例里用的是相对路径 ../../../playwright/.auth/state.json 去引项目根下的目录。
第一步:先把存放目录和 .gitignore 建好
docs/src/auth.md 建议建一个 playwright/.auth 目录并加进 .gitignore,认证流程把状态写进这个目录,后续用例从这里读。文档在这里挂了一个 :::danger 块,原话的意思是:这个状态文件里可能含有敏感 cookie 和 header,足以被用来冒充你或你的测试账号,强烈不建议提交进任何仓库——不管公开还是私有。
仓库把这一步的命令按 shell 分了三份,Windows 侧不用自己猜。bash 版:
mkdir -p playwright/.auth
echo $'\nplaywright/.auth' >> .gitignore
Windows 的 batch 版:
md playwright\.auth
echo. >> .gitignore
echo "playwright/.auth" >> .gitignore
PowerShell 版:
New-Item -ItemType Directory -Force -Path playwright\.auth
Add-Content -path .gitignore "`r`nplaywright/.auth"
三份是文档里原样并列给出的,注意 batch 与 PowerShell 版建目录用的是反斜杠,写进 .gitignore 的路径用的是正斜杠。
第二步:把登录动作写成一个 setup 用例
文档的示例文件叫 tests/auth.setup.ts,核心是最后一行——把当前 context 的状态落盘:
import { test as setup, expect } from '@playwright/test';
import path from 'path';
const authFile = path.join(__dirname, '../playwright/.auth/user.json');
setup('authenticate', async ({ page }) => {
// Perform authentication steps. Replace these actions with your own.
await page.goto('https://github.com/login');
await page.getByLabel('Username or email address').fill('username');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('https://github.com/');
await expect(page.getByRole('button', { name: 'View profile and more' })).toBeVisible();
await page.context().storageState({ path: authFile });
});
上面那个登录地址与两个字段名是仓库示例里的值,换成你自己应用的登录流程即可。这段里有两处值得单独说:
一是 waitForURL 和那句 expect(...).toBeVisible() 不是凑数的。文档在注释里写明,登录流程有时会在若干次重定向的过程中才把 cookie 设上,等到最终 URL 才能确保 cookie 真的落下了;或者换个办法,等页面进入一个「所有 cookie 都已就位」的状态。少了这一步,落盘的可能是一份还没认证完的状态。
二是 storageState({ path }) 的语义。docs/src/api/params.md 里 path 的说明写明:相对路径按当前工作目录解析;如果不给 path,状态照样返回,只是不会写到磁盘。所以这个方法既是「存文件」也是「取对象」。
第三步:让所有项目读这份状态
docs/src/auth.md 给的挂载方式是新建一个 setup 项目,再让其它项目通过 dependencies 依赖它:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
// Setup project
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
// Use prepared auth state.
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
],
});
testMatch 负责把 *.setup.ts 挑进 setup 项目,dependencies 保证它先跑,use.storageState 则是每个用例开局要加载的那份状态。配好之后用例里什么都不用写,拿到的 page 就已经是登录态。
文档还补了一句运维层面的提醒:状态过期时你得自己去删。如果不需要跨轮次保留,可以把状态写进 TestProject.outputDir——那个目录每次运行前会被自动清理。
那 globalSetup 呢
落到 globalSetup 上也能做,docs/src/test-global-setup-teardown-js.md 给了完整示例:配置里指定 globalSetup: require.resolve('./global-setup'),并在 use 里同时给出 baseURL 和 storageState;global-setup.ts 里导出一个接收 config 的函数,从 config.projects[0].use 里取出这两个值,用 chromium.launch() 自己开浏览器、登录、page.context().storageState({ path: storageState as string }) 落盘,最后 browser.close()。
但这份文档开篇就把两种方式的差别列成了表,并明说 project dependencies 是推荐做法。表里 globalSetup 这一列标为不支持的有这么几项:不出现在 HTML 报告里、不支持 trace 录制、不支持 Playwright fixture、浏览器要靠 browserType.launch() 全手动管理、headless 或 testIdAttribute 这类配置项会被忽略。文档在 globalSetup 那一节还挂了一个 :::note 重复提醒了一次。如果你正打算把登录搬进 globalSetup,先看一眼这张表——尤其是 trace 这条,登录失败时没有 trace 可看,排查起来是实打实的痛。需要补一句的是,同一份文档后面还有「Capturing trace of failures during global setup」一节,给的是手动补救:自己在 setup 里 context.tracing.start(),并用 try...catch 保证抛错前先 context.tracing.stop() 落盘。也就是说表里那个「不支持」指的是自动录制,手动录还是能录的,只是要你自己写。
多角色:按文件分,不要按配置分
一旦出现 admin 和普通 user 两种身份,配置里那个全局 storageState 就不够用了。docs/src/auth.md 的「Multiple signed in roles」一节给的做法是:在 setup 文件里写多个 setup 用例,各自落到各自的文件,adminFile 是 playwright/.auth/admin.json,userFile 是 playwright/.auth/user.json,两段登录动作除了填的账号不同、其余结构与前面那段一致。
关键在下一步——文档特意加粗写了「instead of」:这时候要为每个用例文件或用例组单独指定 storageState,而不是在配置里设:
import { test } from '@playwright/test';
test.use({ storageState: 'playwright/.auth/admin.json' });
test('admin test', async ({ page }) => {
// page is authenticated as admin
});
test.describe(() => {
test.use({ storageState: 'playwright/.auth/user.json' });
test('user test', async ({ page }) => {
// page is authenticated as a user
});
});
文件级 test.use 管整个文件,套在 test.describe 里的 test.use 只管这一组。
如果要在同一个用例里让两种身份互相打交道,文档给的是另一节的写法:用 browser.newContext({ storageState: ... }) 各开一个 context 各开一个 page,用完 adminContext.close() 和 userContext.close()。再讲究一点可以按「Testing multiple roles with POM fixtures」那节,把两个 context 包成 adminPage / userPage 两个 fixture。
反过来,某个文件需要未登录状态时,不要去删文件,用这一句把状态清空即可:
test.use({ storageState: { cookies: [], origins: [] } });
这也印证了 storageState 的类型:docs/src/api/params.md 里它是 [path]|[Object],可以给路径,也可以直接给一个含 cookies 与 origins 的对象。
边界:文档明说不覆盖的几处
- session storage 没有官方 API。
docs/src/auth.md写明,复用认证状态覆盖的是 cookie、local storage、IndexedDB 和基于 WebAuthn 的 passkey;session storage 是按域且不跨页面加载保留的,Playwright 不提供持久化它的 API,文档只给了一段用page.evaluate读出来、再用context.addInitScript写回去的手写代码。 - IndexedDB 默认不在快照里。
BrowserContext.storageState的indexedDB选项标着since: v1.51,要设成true才会把 IndexedDB 纳入快照;文档点名如果你的应用像 Firebase Authentication 那样把 token 存在 IndexedDB 里,就得开这个。 - passkey 快照会顺带装上虚拟 authenticator。
credentials选项标着since: v1.61,开启后捕获的凭据会带上私钥;文档同时警告:恢复含凭据的状态会自动安装虚拟 WebAuthn authenticator,并让该 context 内所有真实 authenticator 失效。 - UI mode 默认不跑 setup 项目。 文档说明这是为了提速,建议在认证过期时手动跑一次
auth.setup.ts:先在过滤器里启用 setup 项目,点该文件旁的三角按钮,跑完再把它禁用回去。 - 和
reuseContext有冲突。docs/src/test-api/class-testoptions.md里TestOptions.reuseContext明确标着 Experimental,并挂了一条discouraged说明,意思是它拿隔离性换速度、是给驱动 story gallery 的组件测试用的,端到端测试建议不要设。它的限制里还写明:只有少数几个 context 选项允许在连续用例之间不同,在test.use里改动其它选项(文中点名了locale与storageState)会静默强制创建新 context,从而抵消掉复用带来的收益。既然标了 experimental,这个行为随版本改动的概率也更高,以仓库最新内容为准。 - 状态里就是能冒充你的东西。 前面那个
:::danger值得再说一遍,尤其是在 CI 上:这份文件不该进仓库,也不该随构件到处传。安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。
怎么确认配对了
一是看落盘。setup 跑完后 playwright/.auth/user.json 应该存在;不给 path 时状态只返回不落盘,这一点前面已经引过。
二是看报告。文档那张对比表写明,project dependencies 方式下 setup 会作为一个独立 project 出现在 HTML 报告里,并且有完整 trace——所以「报告里根本没有 setup 这个 project」本身就是个信号。
三是做反向验证。新建一个未登录用例文件,用上面那句空 storageState 把状态清掉,断言它看到的是登录页。正反两边都对上,才说明状态确实是从文件加载进来的,而不是碰巧共用了什么残留。
另外,docs/src/test-global-setup-teardown-js.md 提到命令行有 --no-deps 选项,作用是忽略所有依赖与 teardown、只跑你直接选中的项目——在你怀疑「到底是 setup 生效了还是别的什么在起作用」时,这是个能把变量摘干净的开关。
最后补一句这套方案的适用面。文档自己在「Basic」一节写了「When not to use」:如果你的用例会改服务端状态(一个用例在看设置页渲染、另一个在改设置,还并行跑),就不该共用一个账号,那种情况文档指向的是「一个并行 worker 一个账号」的 worker 级 fixture 方案,用 TestInfo.parallelIndex 区分 worker、把状态落到 .auth/${id}.json。另外,如果你的认证是浏览器特定的,共享状态这条路同样不适用。
以上为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。
本文依据 github.com/microsoft/playwright 仓库于 2026-08-18 的公开内容整理,
事实来自仓库内的文档与源码。我们没有对文中涉及的功能做过实测,
因此不涉及运行速度、稳定性与实际表现的任何描述。
该项目迭代频繁,文中涉及的 API 签名、配置项与默认值随版本变动,请以仓库最新内容为准。
本文不涉及浏览器版本矩阵与版本清单,相关信息请以官方发布说明为准。