Playwright:认证
简介
Playwright 在称为浏览器上下文的隔离环境中运行测试。这种隔离提升可重复性,并避免测试失败相互连锁。测试可以加载已有的认证状态,从而无需在每个测试中重新登录,也能加快执行。
核心概念
无论采用哪种认证策略,都很可能需要将已经认证的浏览器状态保存到文件系统。
建议创建 playwright/.auth 目录,并将它加入 .gitignore。认证流程把浏览器状态写入该目录,后续测试复用这些状态,从已登录的状态开始。
浏览器状态文件可能包含敏感 Cookie 和请求头,被他人获取后可能用于冒充你或测试账户。原文强烈建议不要将这些文件提交到私有或公开仓库。
bash tab=bash-bash mkdir -p playwright/.auth echo $'\nplaywright/.auth' >> .gitignore
batch tab=bash-batch md playwright\.auth echo. >> .gitignore echo "playwright/.auth" >> .gitignore powershell tab=bash-powershell New-Item -ItemType Directory -Force -Path playwright\.auth Add-Content -path .gitignore "`r`nplaywright/.auth" ## 基础方案:所有测试共享一个账户
对于不修改服务端状态的测试,这是推荐方案:在 setup 项目中登录一次,保存认证状态,然后让每个测试通过该状态启动。
适用场景
- 所有测试可以同时使用同一账户运行,且彼此不影响。
不适用场景
- 测试会修改服务端状态。例如,一个测试检查设置页的渲染,另一个并行测试修改设置。这种情况下应使用不同账户。
- 认证状态与具体浏览器绑定。
详细步骤
创建 tests/auth.setup.ts,为其他测试准备已认证的浏览器状态。 “`js title=“tests/auth.setup.ts” 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(); // Wait until the page receives the cookies. // // Sometimes login flow sets cookies in the process of several redirects. // Wait for the final URL to ensure that the cookies are actually set. await page.waitForURL(‘https://github.com/’); // Alternatively, you can wait until the page reaches a state where all cookies are set. await expect(page.getByRole(‘button’, { name: ‘View profile and more’ })).toBeVisible(); // End of authentication steps.
await page.context().storageState({ path: authFile }); });
在配置中创建新的 `setup` 项目,将它声明为所有测试项目的[依赖](https://playwright.dev/docs/test-projects#dependencies)。它会在其他测试之前完成认证。所有测试项目都应通过 `storageState` 使用保存的认证状态。
```js title="playwright.config.ts"
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'],
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
// Use prepared auth state.
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
],
});
因为配置指定了 storageState,测试启动时已经登录。
“`js title=“tests/example.spec.ts” import { test } from ‘@playwright/test’;
test(‘test’, async ({ page }) => { // page is authenticated });
认证状态过期后,应删除保存的状态文件。如果不需要跨测试运行保留它,可以将状态写入 [`TestProject.outputDir`](https://playwright.dev/docs/api/class-testproject#test-project-output-dir),该目录会在每轮测试之前自动清理。
### 在 UI 模式中认证
为提高测试速度,UI 模式默认不会运行 `setup` 项目。已有状态过期时,建议手动运行 `auth.setup.ts` 重新登录。
先在[筛选器中启用 setup 项目](https://playwright.dev/docs/test-ui-mode#filtering-tests),再点击 `auth.setup.ts` 旁的三角按钮。完成后,在筛选器中再次关闭 setup 项目。
## 中等方案:每个并行 worker 使用一个账户
对于**修改服务端状态**的测试,这是**推荐方案**。Playwright 的 worker 进程并行执行;每个 worker 登录一次,其运行的全部测试复用同一认证状态。因此需要多个测试账户,每个并行 worker 对应一个。
**适用场景**
- 测试会修改共享服务端状态。例如,一个测试检查设置页的渲染,另一个测试修改设置。
**不适用场景**
- 测试不修改任何共享服务端状态。此时全部测试可以共享一个账户。
**详细步骤**
每个 [worker 进程](https://playwright.dev/docs/test-parallel#worker-processes)使用独立账户,只认证一次。创建 `playwright/fixtures.ts`,通过[重写 storageState fixture](https://playwright.dev/docs/test-fixtures#overriding-fixtures)实现按 worker 认证,并使用 [`TestInfo.parallelIndex`](https://playwright.dev/docs/api/class-testinfo#test-info-parallel-index)区分不同 worker。
```js title="playwright/fixtures.ts"
import { test as baseTest, expect } from '@playwright/test';
import fs from 'fs';
import path from 'path';
export * from '@playwright/test';
export const test = baseTest.extend<{}, { workerStorageState: string }>({
// Use the same storage state for all tests in this worker.
storageState: ({ workerStorageState }, use) => use(workerStorageState),
// Authenticate once per worker with a worker-scoped fixture.
workerStorageState: [async ({ browser }, use) => {
// Use parallelIndex as a unique identifier for each worker.
const id = test.info().parallelIndex;
const fileName = path.resolve(test.info().project.outputDir, `.auth/${id}.json`);
if (fs.existsSync(fileName)) {
// Reuse existing authentication state if any.
await use(fileName);
return;
}
// Important: make sure we authenticate in a clean environment by unsetting storage state.
const page = await browser.newPage({ storageState: undefined });
// Acquire a unique account, for example create a new one.
// Alternatively, you can have a list of precreated accounts for testing.
// Make sure that accounts are unique, so that multiple team members
// can run tests at the same time without interference.
const account = await acquireAccount(id);
// Perform authentication steps. Replace these actions with your own.
await page.goto('https://github.com/login');
await page.getByLabel('Username or email address').fill(account.username);
await page.getByLabel('Password').fill(account.password);
await page.getByRole('button', { name: 'Sign in' }).click();
// Wait until the page receives the cookies.
//
// Sometimes login flow sets cookies in the process of several redirects.
// Wait for the final URL to ensure that the cookies are actually set.
await page.waitForURL('https://github.com/');
// Alternatively, you can wait until the page reaches a state where all cookies are set.
await expect(page.getByRole('button', { name: 'View profile and more' })).toBeVisible();
// End of authentication steps.
await page.context().storageState({ path: fileName });
await page.close();
await use(fileName);
}, { scope: 'worker' }],
});
此后每个测试文件应从自己的 fixtures 文件导入 test,而非从 @playwright/test 导入。无需更改配置。
“`js title=“tests/example.spec.ts” // Important: import our fixtures. import { test, expect } from ‘../playwright/fixtures’;
test(‘test’, async ({ page }) => { // page is authenticated });
## 每个测试之前登录
Playwright API 可以[自动操作](https://playwright.dev/docs/input)登录表单。
下面的示例登录 GitHub。执行这些步骤后,浏览器上下文即处于已认证状态。
```java
Page page = context.newPage();
page.navigate("https://github.com/login");
// Interact with login form
page.getByLabel("Username or email address").fill("username");
page.getByLabel("Password").fill("password");
page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Sign in"))
.click();
// Continue with the test
python async page = await context.new_page() await page.goto('https://github.com/login') # Interact with login form await page.get_by_label("Username or email address").fill("username") await page.get_by_label("Password").fill("password") await page.get_by_role("button", name="Sign in").click() # Continue with the test
python sync page = context.new_page() page.goto('https://github.com/login') # Interact with login form page.get_by_label("Username or email address").fill("username") page.get_by_label("Password").fill("password") page.get_by_role("button", name="Sign in").click() # Continue with the test
var page = await context.NewPageAsync();
await page.GotoAsync("https://github.com/login");
// Interact with login form
await page.GetByLabel("Username or email address").FillAsync("username");
await page.GetByLabel("Password").FillAsync("password");
await page.GetByRole(AriaRole.Button, new() { Name = "Sign in" }).ClickAsync();
// Continue with the test
每个测试都重复登录会拖慢执行。可以改用已有认证状态,减少重复登录。
复用已登录状态
Playwright 允许在测试中复用登录状态,因此只登录一次,后续测试跳过登录步骤。
Web 应用通常使用 Cookie 或令牌认证。认证状态可能保存在 Cookie、local storage、IndexedDB,或作为通行密钥,即 WebAuthn 凭据。
BrowserContext.storageState可从已认证的上下文提取状态,再用这些预先填充的状态创建新上下文。
Cookie、local storage、IndexedDB 和虚拟 WebAuthn 凭据可以跨浏览器使用。实际需要哪些状态,取决于应用的认证模型,可能是这些方式的组合。
下面的片段从已认证上下文获取状态,再以该状态创建新上下文。
// Save storage state into the file.
context.storageState(new BrowserContext.StorageStateOptions().setPath(Paths.get("state.json")));
// Create a new context with the saved storage state.
BrowserContext context = browser.newContext(
new Browser.NewContextOptions().setStorageStatePath(Paths.get("state.json")));
python async # Save storage state into the file. storage = await context.storage_state(path="state.json") # Create a new context with the saved storage state. context = await browser.new_context(storage_state="state.json")
python sync # 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")
// Save storage state into the file.
// Tests are executed in <TestProject>\bin\Debug\netX.0\ therefore relative path is used to reference playwright/.auth created in project root
await context.StorageStateAsync(new()
{
Path = "../../../playwright/.auth/state.json"
});
// Create a new context with the saved storage state.
var context = await browser.NewContextAsync(new()
{
StorageStatePath = "../../../playwright/.auth/state.json"
});
进阶场景
使用 API 请求认证
适用场景
- 应用提供认证 API,通过 API 登录比操作 UI 更简单或更快。
详细步骤
通过 APIRequestContext发送 API 请求,然后像通常一样保存认证状态。
在setup 项目中:
“`js title=“tests/auth.setup.ts” import { test as setup } from ‘@playwright/test’;
const authFile = ‘playwright/.auth/user.json’; setup(‘authenticate’, async ({ request }) => { // Send authentication request. Replace with your own. await request.post(‘https://github.com/login’, { form: { ‘user’: ‘user’, ‘password’: ‘password’ } }); await request.storageState({ path: authFile }); });
也可以在 [worker fixture](https://playwright.dev/docs/auth#moderate-one-account-per-parallel-worker)中:
```js title="playwright/fixtures.ts"
import { test as baseTest, request } from '@playwright/test';
import fs from 'fs';
import path from 'path';
export * from '@playwright/test';
export const test = baseTest.extend<{}, { workerStorageState: string }>({
// Use the same storage state for all tests in this worker.
storageState: ({ workerStorageState }, use) => use(workerStorageState),
// Authenticate once per worker with a worker-scoped fixture.
workerStorageState: [async ({}, use) => {
// Use parallelIndex as a unique identifier for each worker.
const id = test.info().parallelIndex;
const fileName = path.resolve(test.info().project.outputDir, `.auth/${id}.json`);
if (fs.existsSync(fileName)) {
// Reuse existing authentication state if any.
await use(fileName);
return;
}
// Important: make sure we authenticate in a clean environment by unsetting storage state.
const context = await request.newContext({ storageState: undefined });
// Acquire a unique account, for example create a new one.
// Alternatively, you can have a list of precreated accounts for testing.
// Make sure that accounts are unique, so that multiple team members
// can run tests at the same time without interference.
const account = await acquireAccount(id);
// Send authentication request. Replace with your own.
await context.post('https://github.com/login', {
form: {
'user': 'user',
'password': 'password'
}
});
await context.storageState({ path: fileName });
await context.dispose();
await use(fileName);
}, { scope: 'worker' }],
});
多个已登录角色
适用场景
- 端到端测试涉及多个角色,但各测试仍能复用账户。
详细步骤
在 setup 项目中多次登录,分别准备角色状态。
“`js title=“tests/auth.setup.ts” import { test as setup, expect } from ‘@playwright/test’;
const adminFile = ‘playwright/.auth/admin.json’; setup(‘authenticate as admin’, 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(‘admin’); await page.getByLabel(‘Password’).fill(‘password’); await page.getByRole(‘button’, { name: ‘Sign in’ }).click(); // Wait until the page receives the cookies. // // Sometimes login flow sets cookies in the process of several redirects. // Wait for the final URL to ensure that the cookies are actually set. await page.waitForURL(‘https://github.com/’); // Alternatively, you can wait until the page reaches a state where all cookies are set. await expect(page.getByRole(‘button’, { name: ‘View profile and more’ })).toBeVisible(); // End of authentication steps.
await page.context().storageState({ path: adminFile }); });
const userFile = ‘playwright/.auth/user.json’; setup(‘authenticate as user’, 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(‘user’); await page.getByLabel(‘Password’).fill(‘password’); await page.getByRole(‘button’, { name: ‘Sign in’ }).click(); // Wait until the page receives the cookies. // // Sometimes login flow sets cookies in the process of several redirects. // Wait for the final URL to ensure that the cookies are actually set. await page.waitForURL(‘https://github.com/’); // Alternatively, you can wait until the page reaches a state where all cookies are set. await expect(page.getByRole(‘button’, { name: ‘View profile and more’ })).toBeVisible(); // End of authentication steps.
await page.context().storageState({ path: userFile }); });
随后为每个测试文件或测试分组指定 `storageState`,不要在全局配置中统一设置。
```js title="tests/example.spec.ts"
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
});
});
另请参阅在 UI 模式中认证。
在同一测试中使用多个角色
适用场景
- 需要在单个测试中检查多个已认证角色之间的交互。
详细步骤
在同一测试中使用多个 BrowserContext 和 Page,分别加载不同角色的存储状态。
“`js title=“tests/example.spec.ts” import { test } from ‘@playwright/test’; test(‘admin and user’, async ({ browser }) => { // adminContext and all pages inside, including adminPage, are signed in as “admin”. const adminContext = await browser.newContext({ storageState: ‘playwright/.auth/admin.json’ }); const adminPage = await adminContext.newPage(); // userContext and all pages inside, including userPage, are signed in as “user”. const userContext = await browser.newContext({ storageState: ‘playwright/.auth/user.json’ }); const userPage = await userContext.newPage();
// … interact with both adminPage and userPage …
await adminContext.close(); await userContext.close(); });
### 使用 POM fixture 测试多个角色
**适用场景**
- 需要在单个测试中检查多个已认证角色之间的交互。
**详细步骤**
可以通过 fixture 为每个角色提供已经登录的页面。
下面的示例为两个[页面对象模型](https://playwright.dev/docs/pom),即管理员 POM 与用户 POM,[创建 fixture](https://playwright.dev/docs/test-fixtures#creating-a-fixture)。它假设全局 setup 已创建 `adminStorageState.json` 与 `userStorageState.json`。
```js title="playwright/fixtures.ts"
import { test as base, type Page, type Locator } from '@playwright/test';
// Page Object Model for the "admin" page.
// Here you can add locators and helper methods specific to the admin page.
class AdminPage {
// Page signed in as "admin".
page: Page;
// Example locator pointing to "Welcome, Admin" greeting.
greeting: Locator;
constructor(page: Page) {
this.page = page;
this.greeting = page.locator('#greeting');
}
}
// Page Object Model for the "user" page.
// Here you can add locators and helper methods specific to the user page.
class UserPage {
// Page signed in as "user".
page: Page;
// Example locator pointing to "Welcome, User" greeting.
greeting: Locator;
constructor(page: Page) {
this.page = page;
this.greeting = page.locator('#greeting');
}
}
// Declare the types of your fixtures.
type MyFixtures = {
adminPage: AdminPage;
userPage: UserPage;
};
export * from '@playwright/test';
export const test = base.extend<MyFixtures>({
adminPage: async ({ browser }, use) => {
const context = await browser.newContext({ storageState: 'playwright/.auth/admin.json' });
const adminPage = new AdminPage(await context.newPage());
await use(adminPage);
await context.close();
},
userPage: async ({ browser }, use) => {
const context = await browser.newContext({ storageState: 'playwright/.auth/user.json' });
const userPage = new UserPage(await context.newPage());
await use(userPage);
await context.close();
},
});
“`js title=“tests/example.spec.ts” // Import test with our new fixtures. import { test, expect } from ‘../playwright/fixtures’;
// Use adminPage and userPage fixtures in the test. test(‘admin and user’, async ({ adminPage, userPage }) => { // … interact with both adminPage and userPage … await expect(adminPage.greeting).toHaveText(‘Welcome, Admin’); await expect(userPage.greeting).toHaveText(‘Welcome, User’); });
### Session storage
复用认证状态覆盖 [Cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies)、[local storage](https://developer.mozilla.org/en-US/docs/Web/API/Storage)、[IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) 和[WebAuthn 通行密钥](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API)认证。
少数应用将登录状态相关的信息放在 [session storage](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage) 中。它按来源和浏览器标签页隔离,页面刷新时可以保留,但关闭对应标签页或窗口后会清除。Playwright 没有专门持久化 session storage 的 API,但可以用下面的片段保存和加载它。
```js
// Get session storage and store as env variable
const sessionStorage = await page.evaluate(() => JSON.stringify(sessionStorage));
fs.writeFileSync('playwright/.auth/session.json', sessionStorage, 'utf-8');
// Set session storage in a new context
const sessionStorage = JSON.parse(fs.readFileSync('playwright/.auth/session.json', 'utf-8'));
await context.addInitScript(storage => {
if (window.location.hostname === 'example.com') {
for (const [key, value] of Object.entries(storage))
window.sessionStorage.setItem(key, value);
}
}, sessionStorage);
// Get session storage and store as env variable
String sessionStorage = (String) page.evaluate("JSON.stringify(sessionStorage)");
System.getenv().put("SESSION_STORAGE", sessionStorage);
// Set session storage in a new context
String sessionStorage = System.getenv("SESSION_STORAGE");
String escapedSessionStorage = sessionStorage.replace("\\", "\\\\").replace("'", "\\'");
context.addInitScript("(storage => {\n" +
" if (window.location.hostname === 'example.com') {\n" +
" const entries = JSON.parse(storage);\n" +
" for (const [key, value] of Object.entries(entries)) {\n" +
" window.sessionStorage.setItem(key, value);\n" +
" };\n" +
" }\n" +
"})('" + escapedSessionStorage + "')");
python async import os # Get session storage and store as env variable session_storage = await page.evaluate("() => JSON.stringify(sessionStorage)") os.environ["SESSION_STORAGE"] = session_storage # Set session storage in a new context session_storage = os.environ["SESSION_STORAGE"] escaped_session_storage = session_storage.replace("\\", "\\\\").replace("'", "\\'") await context.add_init_script("""(storage => { if (window.location.hostname === 'example.com') { const entries = JSON.parse(storage) for (const [key, value] of Object.entries(entries)) { window.sessionStorage.setItem(key, value) } } })('""" + escaped_session_storage + "')") python sync import os # Get session storage and store as env variable session_storage = page.evaluate("() => JSON.stringify(sessionStorage)") os.environ["SESSION_STORAGE"] = session_storage # Set session storage in a new context session_storage = os.environ["SESSION_STORAGE"] escaped_session_storage = session_storage.replace("\\", "\\\\").replace("'", "\\'") context.add_init_script("""(storage => { if (window.location.hostname === 'example.com') { const entries = JSON.parse(storage) for (const [key, value] of Object.entries(entries)) { window.sessionStorage.setItem(key, value) } } })('""" + escaped_session_storage + "')")
// Get session storage and store as env variable
var sessionStorage = await page.EvaluateAsync<string>("() => JSON.stringify(sessionStorage)");
Environment.SetEnvironmentVariable("SESSION_STORAGE", sessionStorage);
// Set session storage in a new context
var loadedSessionStorage = Environment.GetEnvironmentVariable("SESSION_STORAGE");
var escapedSessionStorage = loadedSessionStorage.Replace("\\", "\\\\").Replace("'", "\\'");
await context.AddInitScriptAsync(@"(storage => {
if (window.location.hostname === 'example.com') {
const entries = JSON.parse(storage);
for (const [key, value] of Object.entries(entries)) {
window.sessionStorage.setItem(key, value);
}
}
})('" + escapedSessionStorage + "')");
在部分测试中取消认证
可以在测试文件中重置存储状态,让该文件不使用项目全局设置的认证。
“`js title=“not-signed-in.spec.ts” import { test } from ‘@playwright/test’;
// Reset storage state for this file to avoid being authenticated test.use({ storageState: { cookies: [], origins: [] } });
test(‘not signed in test’, async ({ page }) => { // … }); “`
原文:Authentication,Microsoft Playwright 官方文档。中文翻译;代码保留原文的 JavaScript、Java、Python 与 C# 版本。原项目许可见 Apache License 2.0。











暂无评论内容