Playwright:认证

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。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容