认证

简介

Playwright 在被称为【浏览器上下文】的隔离环境中执行测试。此隔离模型可以提高可重复性并防止级联的测试失败。测试可以加载已存在的认证状态,从而避免每次测试都进行认证,并加快测试执行速度。

核心概念

无论选择哪种认证策略,您很可能会将认证后的浏览器状态存储在文件系统中。

我们建议创建 playwright/.auth 目录并将其添加到 .gitignore 文件中。您的认证流程将生成认证后的浏览器状态并保存到 playwright/.auth 目录中的一个文件。随后,测试将重用此状态,已自动完成认证。

  • Bash

  • PowerShell

  • Batch

mkdir -p playwright/.auth
echo $'\nplaywright/.auth' >> .gitignore
New-Item -ItemType Directory -Force -Path playwright\.auth
Add-Content -path .gitignore "`r`nplaywright/.auth"
md playwright\.auth
echo. >> .gitignore
echo "playwright/.auth" >> .gitignore

基本方案:在所有测试中共享一个账户

这是推荐的做法,适用于没有服务器端状态的测试。在项目的设置阶段进行一次认证,保存认证状态,然后在每个测试中重用该状态以跳过认证过程。

适用场景:

  • 测试可以想象所有测试同时使用同一个账户运行,且互不影响。

不适用场景:

  • 测试会修改服务器端状态。例如,一个测试检查设置页面的渲染,另一个测试修改设置,并且测试是并行运行的。在这种情况下,每个测试必须使用不同的账户。

  • 认证是浏览器特定的。

步骤:

创建 tests/auth.setup.ts,为其他测试准备认证后的浏览器状态。

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 项目,并将其声明为所有测试项目的【依赖项】。该项目将始终在所有测试之前运行并进行认证。所有测试项目都应使用经过认证的状态作为 storageState

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

tests/example.spec.ts
import { test } from '@playwright/test';

test('test', async ({ page }) => {
  // page is authenticated
});

注意:如果认证状态过期,您需要删除存储的状态。如果不需要在测试之间保留状态,可以将浏览器状态保存在 【testProject.outputDir】 下,这个目录会在每次测试运行前自动清理。

UI 模式下的认证

UI 模式默认不会运行 setup 项目以提高测试速度。我们建议定期手动运行 auth.setup.ts,每当现有认证过期时重新认证。

启用 setup 项目的筛选器,然后点击 auth.setup.ts 文件旁的三角按钮,最后再次禁用 setup 项目的筛选器。

中等方案:每个并行工作者使用一个账户

这是推荐的做法,适用于修改服务器端状态的测试。在 Playwright 中,工作者进程是并行运行的。在这种方案中,每个并行工作者只认证一次,每个工作者运行的所有测试都重用相同的认证状态。我们需要多个测试账户,每个工作者一个账户。

适用场景:

  • 测试修改了共享的服务器端状态。例如,一个测试检查设置页面的渲染,另一个测试修改设置。

不适用场景:

  • 测试不修改共享的服务器端状态。此时所有测试可以使用一个共享账户。

步骤:

创建 playwright/fixtures.ts 文件,重写 storageState fixture,以便每个 worker 只进行一次认证。使用 testInfo.parallelIndex 来区分不同的 workers。

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 导入。配置文件无需做任何更改。

tests/example.spec.ts
// Important: import our fixtures.
import { test, expect } from '../playwright/fixtures';

test('test', async ({ page }) => {
  // page is authenticated
});

高级场景

通过 API 请求进行认证

适用场景:

  • 您的 Web 应用支持通过 API 进行认证,比与应用 UI 交互更简单/更快。

步骤:

在设置项目中使用 APIRequestContext 发送认证请求并保存认证状态。

在 setup 项目:

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 });
});

或者,在工作夹具中:

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 项目中进行多次认证,分别为不同角色保存认证状态。

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,而不是在配置中设置。

tests/example.spec.ts
import { test } from '@playwright/test';

test.use({ storageState: 'playwright/.auth/admin.json' });

test('admin test', async ({ page }) => {
  // page 已经以管理员身份认证
});

test.describe(() => {
  test.use({ storageState: 'playwright/.auth/user.json' });

  test('user test', async ({ page }) => {
    // page 已经以用户身份认证
  });
});

还可以查看 【UI 模式下的认证方式】。

测试多个角色一起工作

使用场景

  • 当你需要测试多个认证角色如何在同一个测试中相互作用时。

实现细节

在同一个测试中使用多个 BrowserContext 和 Page,且每个上下文使用不同的 storageState。

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 Fixtures 测试多个角色

使用场景

  • 你需要测试多个认证角色如何在同一个测试中相互作用。

实现细节

你可以引入 fixtures 来为每个角色提供认证页面。

以下示例为两个 Page Object Model(POM) - admin POM 和 user POM 创建了 fixtures。假设 adminStorageState.jsonuserStorageState.json 文件已在全局设置中创建。

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();
  },
});
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');
});

会话存储

重新使用认证状态包括基于 cookies 和 local storage 的认证。很少使用 session storage 来存储与登录状态相关的信息。Session storage 只对特定域有效,并且不会在页面加载之间持久化。Playwright 没有提供 API 来持久化 session storage,但以下代码可以用来保存和加载 session storage。

// 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);

避免在某些测试中进行认证

你可以在测试文件中重置存储状态,以避免使用为整个项目设置的认证。

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 }) => {
  // ...
});