跳到主要内容

认证机制

简介

Playwright 在称为浏览器上下文的隔离环境中执行测试。这种隔离模型提高了可重现性并防止级联测试失败。测试可以加载已有的认证状态,这消除了在每个测试中进行认证的需要,从而加快了测试执行速度。

核心概念

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

我们建议创建 playwright/.auth 目录并将其添加到 .gitignore 文件中。您的认证流程将生成已认证的浏览器状态,并将其保存到 playwright/.auth 目录中的文件中。之后,测试将重用此状态并直接以已认证状态开始。

危险

浏览器状态文件可能包含可用于冒充您或测试账户的敏感 cookies 和 headers。我们强烈不建议将它们提交到私有或公共代码仓库中。

mkdir -p playwright/.auth
echo $'\nplaywright/.auth' >> .gitignore

基础:所有测试共享账户

这是推荐的适用于无服务器端状态测试的方法。在setup项目中认证一次,保存认证状态,然后在每个测试中复用该状态以保持已认证状态。

适用场景

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

不适用场景

  • 测试会修改服务器端状态。例如一个测试检查设置页面的渲染,而另一个测试正在修改设置,并且你并行运行这些测试。这种情况下,测试必须使用不同账户。
  • 你的认证方式与浏览器相关。

详细说明

创建 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 }) => {
// 执行认证步骤。请替换为你的实际认证操作。
await page.goto('https://github.com/login');
await page.getByLabel('用户名或邮箱地址').fill('username');
await page.getByLabel('密码').fill('password');
await page.getByRole('button', { name: '登录' }).click();
// 等待页面接收cookies

// 有时登录流程会在多次重定向过程中设置cookies
// 等待最终URL以确保cookies确实被设置
await page.waitForURL('https://github.com/');
// 或者,你可以等待页面到达所有cookies都已设置的状态
await expect(page.getByRole('button', { name: '查看个人资料及更多' })).toBeVisible();

// 认证步骤结束

await page.context().storageState({ path: authFile });
});

在配置中创建一个新的 setup 项目,并声明为所有测试项目的依赖项。这个项目会在所有测试之前运行并进行认证。所有测试项目都应使用 storageState 来获取已认证状态。

playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
projects: [
// 设置项目
{ name: 'setup', testMatch: /.*\.setup\.ts/ },

{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
// 使用预先准备的认证状态
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},

{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
// 使用预先准备的认证状态
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
],
});

由于我们在配置中指定了 storageState,测试开始时已经处于认证状态。

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

test('test', async ({ page }) => {
// 页面已认证
});

注意当存储状态过期时需要删除它。如果不需要在测试运行之间保留状态,可以将浏览器状态写入testProject.outputDir,该目录会在每次测试运行前自动清理。

在 UI 模式下进行认证

默认情况下,UI 模式不会运行 setup 项目以提高测试速度。我们建议在现有认证过期时,定期手动运行 auth.setup.ts 来进行认证。

首先在过滤器中启用 setup 项目,然后点击 auth.setup.ts 文件旁边的三角形按钮运行,最后再次在过滤器中禁用 setup 项目。

中等方案:每个并行工作进程使用一个账号

这是推荐用于修改服务器端状态的测试方案。在 Playwright 中,工作进程是并行运行的。此方案中,每个并行工作进程只需认证一次。该工作进程运行的所有测试都复用相同的认证状态。我们需要准备多个测试账号,每个并行工作进程对应一个。

适用场景

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

不适用场景

  • 你的测试不会修改任何共享的服务器端状态。这种情况下,所有测试可以使用单个共享账号。

实现细节

我们将为每个工作进程认证一次,每个进程使用唯一的账号。

创建 playwright/fixtures.ts 文件来重写 storageState fixture,实现每个工作进程认证一次。使用 testInfo.parallelIndex 来区分不同工作进程。

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 }>({
// 该工作进程中所有测试使用相同的存储状态
storageState: ({ workerStorageState }, use) => use(workerStorageState),

// 使用工作进程作用域的 fixture 每个工作进程认证一次
workerStorageState: [async ({ browser }, use) => {
// 使用 parallelIndex 作为每个工作进程的唯一标识符
const id = test.info().parallelIndex;
const fileName = path.resolve(test.info().project.outputDir, `.auth/${id}.json`);

if (fs.existsSync(fileName)) {
// 如果存在认证状态则复用
await use(fileName);
return;
}

// 重要:确保在干净环境中认证,取消存储状态设置
const page = await browser.newPage({ storageState: undefined });

// 获取唯一账号,例如创建新账号
// 或者可以使用预先创建的测试账号列表
// 确保账号唯一,这样多个团队成员可以同时运行测试而不会互相干扰
const account = await acquireAccount(id);

// 执行认证步骤。请替换为你自己的操作
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();
// 等待页面接收 cookies
//
// 有时登录流程会在多次重定向中设置 cookies
// 等待最终 URL 以确保 cookies 确实已设置
await page.waitForURL('https://github.com/');
// 或者可以等待页面达到所有 cookies 都已设置的状态
await expect(page.getByRole('button', { name: 'View profile and more' })).toBeVisible();

// 认证步骤结束

await page.context().storageState({ path: fileName });
await page.close();
await use(fileName);
}, { scope: 'worker' }],
});

现在,每个测试文件应该从我们的 fixtures 文件中导入 test 而不是 @playwright/test。配置文件无需修改。

tests/example.spec.ts
// 重要:导入我们的 fixtures
import { test, expect } from '../playwright/fixtures';

test('test', async ({ page }) => {
// page 已认证
});

高级应用场景

通过 API 请求进行认证

使用场景

  • 当您的 Web 应用程序支持通过 API 进行认证时(这种方式比与应用 UI 交互更简单/更快速)

详细说明

我们将使用 APIRequestContext 发送 API 请求,然后像往常一样保存认证状态。

项目设置 中:

tests/auth.setup.ts
import { test as setup } from '@playwright/test';

const authFile = 'playwright/.auth/user.json';

setup('authenticate', async ({ request }) => {
// 发送认证请求。请替换为您自己的请求。
await request.post('https://github.com/login', {
form: {
'user': 'user',
'password': 'password'
}
});
await request.storageState({ path: authFile });
});

或者,在 worker 夹具 中:

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 }>({
// 在此 worker 中的所有测试中使用相同的存储状态
storageState: ({ workerStorageState }, use) => use(workerStorageState),

// 每个 worker 认证一次,使用 worker 作用域的夹具
workerStorageState: [async ({}, use) => {
// 使用 parallelIndex 作为每个 worker 的唯一标识符
const id = test.info().parallelIndex;
const fileName = path.resolve(test.info().project.outputDir, `.auth/${id}.json`);

if (fs.existsSync(fileName)) {
// 如果存在认证状态则复用
await use(fileName);
return;
}

// 重要:确保在干净环境中进行认证,取消设置存储状态
const context = await request.newContext({ storageState: undefined });

// 获取唯一账户,例如创建一个新账户
// 或者,您可以准备一个预创建的测试账户列表
// 确保账户是唯一的,这样多个团队成员可以同时运行测试而不会相互干扰
const account = await acquireAccount(id);

// 发送认证请求。请替换为您自己的请求。
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' }],
});

多角色登录场景

使用场景

  • 在端到端测试中有多个角色,但可以在所有测试中复用这些账号

实现细节

我们将在初始化项目中完成多次认证。

tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';

const adminFile = 'playwright/.auth/admin.json';

setup('以管理员身份认证', async ({ page }) => {
// 执行认证步骤。请替换为你的实际认证操作
await page.goto('https://github.com/login');
await page.getByLabel('用户名或邮箱地址').fill('admin');
await page.getByLabel('密码').fill('password');
await page.getByRole('button', { name: '登录' }).click();
// 等待页面接收cookies
//
// 有时登录流程会在多次重定向过程中设置cookies
// 等待最终URL以确保cookies确实被设置
await page.waitForURL('https://github.com/');
// 或者,你可以等待页面到达所有cookies都已设置的状态
await expect(page.getByRole('button', { name: '查看个人资料及更多' })).toBeVisible();

// 认证步骤结束

await page.context().storageState({ path: adminFile });
});

const userFile = 'playwright/.auth/user.json';

setup('以普通用户身份认证', async ({ page }) => {
// 执行认证步骤。请替换为你的实际认证操作
await page.goto('https://github.com/login');
await page.getByLabel('用户名或邮箱地址').fill('user');
await page.getByLabel('密码').fill('password');
await page.getByRole('button', { name: '登录' }).click();
// 等待页面接收cookies
//
// 有时登录流程会在多次重定向过程中设置cookies
// 等待最终URL以确保cookies确实被设置
await page.waitForURL('https://github.com/');
// 或者,你可以等待页面到达所有cookies都已设置的状态
await expect(page.getByRole('button', { name: '查看个人资料及更多' })).toBeVisible();

// 认证步骤结束

await page.context().storageState({ path: userFile });
});

之后,为每个测试文件或测试组指定 storageState而不是在配置文件中设置。

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

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

test('管理员测试', async ({ page }) => {
// 页面已以管理员身份认证
});

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

test('普通用户测试', async ({ page }) => {
// 页面已以普通用户身份认证
});
});

另请参阅 在UI模式下认证

测试多个角色的交互

使用场景

  • 当您需要在单个测试中验证多个已认证角色之间的交互行为时

实现细节

在同一个测试中使用多个具有不同存储状态的 BrowserContextPage 实例。

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

test('管理员与普通用户', async ({ browser }) => {
// adminContext 及其内部所有页面(包括 adminPage)都以"admin"身份登录
const adminContext = await browser.newContext({ storageState: 'playwright/.auth/admin.json' });
const adminPage = await adminContext.newPage();

// userContext 及其内部所有页面(包括 userPage)都以"user"身份登录
const userContext = await browser.newContext({ storageState: 'playwright/.auth/user.json' });
const userPage = await userContext.newPage();

// ... 同时与 adminPage 和 userPage 进行交互 ...

await adminContext.close();
await userContext.close();
});

使用 POM 夹具测试多角色场景

使用场景

  • 当您需要在单个测试中验证多个已认证角色之间的交互时

实现细节

您可以创建为每个角色提供已认证页面的夹具。

以下示例展示了如何为两个页面对象模型(管理员 POM 和用户 POM)创建夹具。假设全局设置中已创建了 adminStorageState.jsonuserStorageState.json 文件。

playwright/fixtures.ts
import { test as base, type Page, type Locator } from '@playwright/test';

// "管理员"页面的页面对象模型
// 这里可以添加管理员页面特有的定位器和辅助方法
class AdminPage {
// 以"admin"身份登录的页面
page: Page;

// 示例定位器,指向"欢迎,管理员"问候语
greeting: Locator;

constructor(page: Page) {
this.page = page;
this.greeting = page.locator('#greeting');
}
}

// "用户"页面的页面对象模型
// 这里可以添加用户页面特有的定位器和辅助方法
class UserPage {
// 以"user"身份登录的页面
page: Page;

// 示例定位器,指向"欢迎,用户"问候语
greeting: Locator;

constructor(page: Page) {
this.page = page;
this.greeting = page.locator('#greeting');
}
}

// 声明夹具类型
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, expect } from '../playwright/fixtures';

// 在测试中使用 adminPage 和 userPage 夹具
test('管理员与用户', async ({ adminPage, userPage }) => {
// ... 同时与 adminPage 和 userPage 交互 ...
await expect(adminPage.greeting).toHaveText('欢迎,管理员');
await expect(userPage.greeting).toHaveText('欢迎,用户');
});

会话存储

复用认证状态涵盖了基于 cookieslocal storageIndexedDB 的认证方式。少数情况下,session storage 会用于存储与登录状态相关的信息。会话存储是特定于某个域的,且不会在页面加载之间持久化。Playwright 没有提供直接持久化会话存储的 API,但可以使用以下代码片段来保存/加载会话存储。

// 获取会话存储并保存为环境变量
const sessionStorage = await page.evaluate(() => JSON.stringify(sessionStorage));
fs.writeFileSync('playwright/.auth/session.json', sessionStorage, 'utf-8');

// 在新上下文中设置会话存储
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';

// 为此文件重置存储状态以避免使用认证信息
test.use({ storageState: { cookies: [], origins: [] } });

test('not signed in test', async ({ page }) => {
// ...
});