身份验证
Playwright 可用于自动化需要身份验证的场景。
使用 Playwright 编写的测试在称为 浏览器上下文 的隔离、干净环境中执行。这种隔离模型提高了可重复性并防止级联测试失败。新的浏览器上下文可以加载现有的身份验证状态。这消除了在每个上下文中登录的需要,并加快了测试执行速度。
注意:本指南涵盖基于 cookie/token 的身份验证(通过应用程序 UI 登录)。对于 HTTP 身份验证,请使用 browser.newContext([options])。
自动化登录
Playwright API 可以 自动化交互 以登录表单。
以下示例自动化登录 GitHub。一旦执行这些步骤,浏览器上下文将被验证。
- TypeScript
- JavaScript
- Library
import { test } from '@playwright/test';
test.beforeEach(async ({ page }) => {
// 在每个测试之前运行并登录每个页面。
await page.goto('https://github.com/login');
await page.getByText('Login').click();
await page.getByLabel('User Name').fill('username');
await page.getByLabel('Password').fill('password');
await page.getByText('Submit').click();
});
test('first', async ({ page }) => {
// 页面已登录。
});
test('second', async ({ page }) => {
// 页面已登录。
});
const { test } = require('@playwright/test');
test.beforeEach(async ({ page }) => {
// 在每个测试之前运行并登录每个页面。
await page.goto('https://github.com/login');
await page.getByText('Login').click();
await page.getByLabel('User name').fill('username');
await page.getByLabel('Password').fill('password');
await page.getByText('Submit').click();
});
test('first', async ({ page }) => {
// 页面已登录。
});
test('second', async ({ page }) => {
// 页面已登录。
});
const page = await context.newPage();
await page.goto('https://github.com/login');
// 与登录表单交互
await page.getByText('Login').click();
await page.getByLabel('User Name').fill(USERNAME);
await page.getByLabel('Password').fill(PASSWORD);
await page.getByText('Submit').click();
// 继续测试
为每个测试重做登录会减慢测试执行速度。为了缓解这种情况,请重用现有的身份验证状态。
重用登录状态
Playwright 提供了一种在测试中重用登录状态的方法。这样,您只需登录一次,然后就可以跳过所有测试的登录步骤。
Web 应用程序使用基于 cookie 或基于令牌的身份验证,其中经过身份验证的状态存储为 cookies 或 本地存储。Playwright 提供 browserContext.storageState([options]) 方法,可用于从经过身份验证的上下文中检索存储状态,然后创建具有预填充状态的新上下文。
Cookie 和本地存储状态可以在不同的浏览器中使用。它们取决于您的应用程序的身份验证模型:某些应用程序可能同时需要 cookie 和本地存储。
创建一个新的全局设置脚本:
- TypeScript
- JavaScript
// global-setup.ts
import { chromium, FullConfig } from '@playwright/test';
async function globalSetup(config: FullConfig) {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://github.com/login');
await page.getByLabel('User Name').fill('user');
await page.getByLabel('Password').fill('password');
await page.getByText('Sign in').click();
// 将登录状态保存到 'storageState.json'。
await page.context().storageState({ path: 'storageState.json' });
await browser.close();
}
export default globalSetup;
// global-setup.js
const { chromium } = require('@playwright/test');
module.exports = async config => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://github.com/login');
await page.getByLabel('User Name').fill('user');
await page.getByLabel('Password').fill('password');
await page.getByText('Sign in').click();
// 将登录状态保存到 'storageState.json'。
await page.context().storageState({ path: 'storageState.json' });
await browser.close();
};
在 Playwright 配置文件中注册全局设置脚本:
- TypeScript
- JavaScript
// playwright.config.ts
import type { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
globalSetup: require.resolve('./global-setup'),
use: {
// 告诉所有测试从 'storageState.json' 加载登录状态。
storageState: 'storageState.json'
}
};
export default config;
// playwright.config.js
// @ts-check
/** @type {import('@playwright/test').PlaywrightTestConfig} */
const config = {
globalSetup: require.resolve('./global-setup'),
use: {
// 告诉所有测试从 'storageState.json' 加载登录状态。
storageState: 'storageState.json'
}
};
module.exports = config;
测试开始时已经过身份验证,因为我们指定了由全局设置填充的 storageState。
- TypeScript
- JavaScript
import { test } from '@playwright/test';
test('test', async ({ page }) => {
// 页面已登录。
});
const { test } = require('@playwright/test');
test('test', async ({ page }) => {
// 页面已登录。
});
如果您可以登录一次并将 storageState.json 提交到存储库中,则根本不需要全局设置,只需如上所述在 Playwright Config 中指定 storageState.json 即可。但是,如果您的应用程序要求您在一段时间后重新进行身份验证,您可能需要定期更新 storageState.json 文件。例如,如果您的应用程序每周提示您登录一次,即使您在同一台计算机/浏览器上也是如此,您也需要至少以此频率更新 storageState.json。
通过 API 请求登录
如果您的 Web 应用程序支持通过 API 登录,您可以使用 APIRequestContext 来简化登录流程。上面的示例中的全局设置脚本将如下所示:
- TypeScript
- JavaScript
// global-setup.ts
import { request } from '@playwright/test';
async function globalSetup() {
const requestContext = await request.newContext();
await requestContext.post('https://github.com/login', {
form: {
'user': 'user',
'password': 'password'
}
});
// 将登录状态保存到 'storageState.json'。
await requestContext.storageState({ path: 'storageState.json' });
await requestContext.dispose();
}
export default globalSetup;
// global-setup.js
const { request } = require('@playwright/test');
module.exports = async () => {
const requestContext = await request.newContext();
await requestContext.post('https://github.com/login', {
form: {
'user': 'user',
'password': 'password'
}
});
// 将登录状态保存到 'storageState.json'。
await requestContext.storageState({ path: 'storageState.json' });
await requestContext.dispose();
}
避免每个帐户同时有多个会话
默认情况下,Playwright Test 并行运行测试。如果您对所有测试重用单个登录状态,这通常会导致同一帐户同时从多个测试登录。如果这种行为对您的应用程序不可取,您可以在 Playwright Test 创建的每个 工作进程 中使用不同的帐户登录。
在这个例子中,我们 覆盖 storageState fixture 并确保我们每个 worker 只登录一次,使用 testInfo.workerIndex 来区分 worker。
- TypeScript
- JavaScript
// fixtures.ts
import { test as baseTest } from '@playwright/test';
export { expect } from '@playwright/test';
const users = [
{ username: 'user-1', password: 'password-1' },
{ username: 'user-2', password: 'password-2' },
// ... 在这里放置您的测试用户 ...
];
export const test = baseTest.extend({
storageState: async ({ browser }, use, testInfo) => {
// 覆盖存储状态,使用 worker 索引查找登录信息并延迟生成。
const fileName = path.join(testInfo.project.outputDir, 'storage-' + testInfo.workerIndex);
if (!fs.existsSync(fileName)) {
// 确保我们没有使用任何其他存储状态。
const page = await browser.newPage({ storageState: undefined });
await page.goto('https://github.com/login');
// 为每个 worker 创建唯一的用户名。
await page.getByLabel('User Name').fill(users[testInfo.workerIndex].username);
await page.getByLabel('Password').fill(users[testInfo.workerIndex].password);
await page.getByText('Sign in').click();
await page.context().storageState({ path: fileName });
await page.close();
}
await use(fileName);
},
});
// example.spec.ts
import { test, expect } from './fixtures';
test('test', async ({ page }) => {
// 页面已登录。
});
// fixtures.js
const { test: base } = require('@playwright/test');
const users = [
{ username: 'user-1', password: 'password-1' },
{ username: 'user-2', password: 'password-2' },
// ... 在这里放置您的测试用户 ...
];
exports.test = base.extend({
storageState: async ({ browser }, use, testInfo) => {
// 覆盖存储状态,使用 worker 索引查找登录信息并延迟生成。
const fileName = path.join(testInfo.project.outputDir, 'storage-' + testInfo.workerIndex);
if (!fs.existsSync(fileName)) {
// 确保我们没有使用任何其他存储状态。
const page = await browser.newPage({ storageState: undefined });
await page.goto('https://github.com/login');
await page.getByLabel('User Name').fill(users[testInfo.workerIndex].username);
await page.getByLabel('Password').fill(users[testInfo.workerIndex].password);
await page.getByText('Sign in').click();
await page.context().storageState({ path: fileName });
await page.close();
}
await use(fileName);
},
});
exports.expect = base.expect;
// example.spec.js
const { test, expect } = require('./fixtures');
test('test', async ({ page }) => {
// 页面已登录。
});
多个已登录角色
有时您的端到端测试中有多个已登录用户。您可以通过在 globalSetup 中多次登录这些用户并将该状态保存到不同的文件中来实现这一点。
- TypeScript
- JavaScript
// global-setup.ts
import { chromium, FullConfig } from '@playwright/test';
async function globalSetup(config: FullConfig) {
const browser = await chromium.launch();
const adminPage = await browser.newPage();
// ... 登录
await adminPage.context().storageState({ path: 'adminStorageState.json' });
const userPage = await browser.newPage();
// ... 登录
await userPage.context().storageState({ path: 'userStorageState.json' });
await browser.close();
}
export default globalSetup;
// global-setup.js
const { chromium } = require('@playwright/test');
module.exports = async config => {
const browser = await chromium.launch();
const adminPage = await browser.newPage();
// ... 登录
await adminPage.context().storageState({ path: 'adminStorageState.json' });
const userPage = await browser.newPage();
// ... 登录
await userPage.context().storageState({ path: 'userStorageState.json' });
await browser.close();
};
之后,您可以为每个测试文件或每个测试组指定要使用的用户:
- TypeScript
- JavaScript
import { test } from '@playwright/test';
test.use({ storageState: 'adminStorageState.json' });
test('admin test', async ({ page }) => {
// 页面以管理员身份登录。
});
test.describe(() => {
test.use({ storageState: 'userStorageState.json' });
test('user test', async ({ page }) => {
// 页面以用户身份登录。
});
});
const { test } = require('@playwright/test');
test.use({ storageState: 'adminStorageState.json' });
test('admin test', async ({ page }) => {
// 页面以管理员身份登录。
});
test.describe(() => {
test.use({ storageState: 'userStorageState.json' });
test('user test', async ({ page }) => {
// 页面以用户身份登录。
});
});
一起测试多个角色
如果您需要测试多个经过身份验证的角色如何相互交互,请在同一测试中使用具有不同存储状态的多个 BrowserContext 和 Page。上面创建多个存储状态文件的任何方法都适用。
- TypeScript
- JavaScript
import { test } from '@playwright/test';
test('admin and user', async ({ browser }) => {
// adminContext 及其内部的所有页面(包括 adminPage)都以 "admin" 身份登录。
const adminContext = await browser.newContext({ storageState: 'adminStorageState.json' });
const adminPage = await adminContext.newPage();
// userContext 及其内部的所有页面(包括 userPage)都以 "user" 身份登录。
const userContext = await browser.newContext({ storageState: 'userStorageState.json' });
const userPage = await userContext.newPage();
// ... 与 adminPage 和 userPage 交互 ...
});
const { test } = require('@playwright/test');
test('admin and user', async ({ browser }) => {
// adminContext 及其内部的所有页面(包括 adminPage)都以 "admin" 身份登录。
const adminContext = await browser.newContext({ storageState: 'adminStorageState.json' });
const adminPage = await adminContext.newPage();
// userContext 及其内部的所有页面(包括 userPage)都以 "user" 身份登录。
const userContext = await browser.newContext({ storageState: 'userStorageState.json' });
const userPage = await userContext.newPage();
// ... 与 adminPage 和 userPage 交互 ...
});
使用 POM fixtures 测试多个角色
如果您的许多测试需要同一个测试中的多个经过身份验证的角色,您可以为每个角色引入 fixtures。上面创建多个存储状态文件的任何方法都适用。
下面是一个示例,它为两个 Page Object Models(admin POM 和 user POM)创建 fixtures。它假设 adminStorageState.json 和 userStorageState.json 文件已创建。
- TypeScript
- JavaScript
// fixtures.ts
import { test as base, Page, Browser, Locator } from '@playwright/test';
export { expect } from '@playwright/test';
// "admin" 页面的页面对象模型。
// 在这里,您可以添加特定于 admin 页面的定位器和辅助方法。
class AdminPage {
// 以 "admin" 身份登录的页面。
page: Page;
constructor(page: Page) {
this.page = page;
}
static async create(browser: Browser) {
const context = await browser.newContext({ storageState: 'adminStorageState.json' });
const page = await context.newPage();
return new AdminPage(page);
}
}
// "user" 页面的页面对象模型。
// 在这里,您可以添加特定于 user 页面的定位器和辅助方法。
class UserPage {
// 以 "user" 身份登录的页面。
page: Page;
// 指向 "Welcome, User" 问候语的示例定位器。
greeting: Locator;
constructor(page: Page) {
this.page = page;
this.greeting = page.locator('#greeting');
}
static async create(browser: Browser) {
const context = await browser.newContext({ storageState: 'userStorageState.json' });
const page = await context.newPage();
return new UserPage(page);
}
}
// 声明 fixtures 的类型。
type MyFixtures = {
adminPage: AdminPage;
userPage: UserPage;
};
// 通过提供 "adminPage" 和 "userPage" 扩展基础测试。
// 这个新的 "test" 可以在多个测试文件中使用,并且每个文件都将获得 fixtures。
export const test = base.extend<MyFixtures>({
adminPage: async ({ browser }, use) => {
await use(await AdminPage.create(browser));
},
userPage: async ({ browser }, use) => {
await use(await UserPage.create(browser));
},
});
// example.spec.ts
// 导入带有我们新 fixtures 的 test。
import { test, expect } from './fixtures';
// 在测试中使用 adminPage 和 userPage fixtures。
test('admin and user', async ({ adminPage, userPage }) => {
// ... 与 adminPage 和 userPage 交互 ...
await adminPage.page.screenshot();
await expect(userPage.greeting).toHaveText('Welcome, User');
});
// fixtures.js
const { test: base } = require('@playwright/test');
// "admin" 页面的页面对象模型。
// 在这里,您可以添加特定于 admin 页面的定位器和辅助方法。
class AdminPage {
constructor(page) {
// 以 "admin" 身份登录的页面。
this.page = page;
}
static async create(browser) {
const context = await browser.newContext({ storageState: 'adminStorageState.json' });
const page = await context.newPage();
return new AdminPage(page);
}
}
// "user" 页面的页面对象模型。
// 在这里,您可以添加特定于 user 页面的定位器和辅助方法。
class UserPage {
constructor(page) {
// 以 "user" 身份登录的页面。
this.page = page;
// 指向 "Welcome, User" 问候语的示例定位器。
this.greeting = page.locator('#greeting');
}
static async create(browser) {
const context = await browser.newContext({ storageState: 'userStorageState.json' });
const page = await context.newPage();
return new UserPage(page);
}
}
// 通过提供 "adminPage" 和 "userPage" 扩展基础测试。
// 这个新的 "test" 可以在多个测试文件中使用,并且每个文件都将获得 fixtures。
exports.test = base.extend({
adminPage: async ({ browser }, use) => {
await use(await AdminPage.create(browser));
},
userPage: async ({ browser }, use) => {
await use(await UserPage.create(browser));
},
});
exports.expect = base.expect;
// example.spec.ts
// 导入带有我们新 fixtures 的 test。
const { test, expect } = require('./fixtures');
// 在测试中使用 adminPage 和 userPage fixtures。
test('admin and user', async ({ adminPage, userPage }) => {
// ... 与 adminPage 和 userPage 交互 ...
await adminPage.page.screenshot();
await expect(userPage.greeting).toHaveText('Welcome, User');
});
在多个测试中重用已登录的页面
虽然不鼓励,但有时必须牺牲隔离性并在同一页面中运行多个测试。在这种情况下,您可以在 beforeAll 中登录该页面一次,然后在所有测试中使用该同一页面。请注意,您需要使用 test.describe.serial 串行运行这些测试才能实现此目的:
- TypeScript
- JavaScript
// example.spec.ts
import { test, Page } from '@playwright/test';
test.describe.configure({ mode: 'serial' });
let page: Page;
test.beforeAll(async ({ browser }) => {
// 创建页面一次并登录。
page = await browser.newPage();
await page.goto('https://github.com/login');
await page.getByLabel('User Name').fill('user');
await page.getByLabel('Password').fill('password');
await page.getByText('Sign in').click();
});
test.afterAll(async () => {
await page.close();
});
test('first test', async () => {
// 页面已登录。
});
test('second test', async () => {
// 页面已登录。
});
// example.spec.js
// @ts-check
const { test } = require('@playwright/test');
test.describe.configure({ mode: 'serial' });
/** @type {import('@playwright/test').Page} */
let page;
test.beforeAll(async ({ browser }) => {
// 自己创建页面并登录。
page = await browser.newPage();
await page.goto('https://github.com/login');
await page.getByLabel('User Name').fill('user');
await page.getByLabel('Password').fill('password');
await page.getByText('Sign in').click();
});
test.afterAll(async () => {
await page.close();
});
test('first test', async () => {
// 页面已登录。
});
test('second test', async () => {
// 页面已登录。
});
您也可以在创建 browser.newPage([options]) 时使用 storageState 属性,以便向其传递现有的登录状态。
会话存储 (Session Storage)
在极少数情况下,session storage 会被用于存储与登录状态相关的信息。会话存储特定于某个特定域,且不会在页面加载之间持久保存。Playwright 没有提供持久化会话存储的 API,但可以使用以下代码片段来保存/加载会话存储。
// 获取 session storage 并将其存储为环境变量
const sessionStorage = await page.evaluate(() => JSON.stringify(sessionStorage));
process.env.SESSION_STORAGE = sessionStorage;
// 在新上下文中设置 session storage
const sessionStorage = process.env.SESSION_STORAGE;
await context.addInitScript(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);
}
}
}, sessionStorage);
多重身份验证 (MFA)
使用多重身份验证 (MFA) 的账户无法完全自动化,通常需要人工干预。持久化身份验证可用于部分自动化 MFA 场景。
持久化身份验证
请注意,持久化身份验证不适合 CI 环境,因为它依赖于磁盘位置。用户数据目录特定于浏览器类型,无法在不同浏览器类型之间共享。
用户数据目录可以与 browserType.launchPersistentContext(userDataDir[, options]) API 一起使用。
const { chromium } = require('playwright');
const userDataDir = '/path/to/directory';
const context = await chromium.launchPersistentContext(userDataDir, { headless: false });
// 在浏览器窗口中手动执行登录步骤
生命周期
- 在磁盘上创建一个用户数据目录。
- 使用该用户数据目录启动持久化上下文,并登录 MFA 账户。
- 重用该用户数据目录运行自动化场景。
手动重用登录状态
以下代码片段从已认证的上下文中检索状态,并使用该状态创建一个新上下文。
// 将存储状态保存到文件中。
await context.storageState({ path: 'state.json' });
// 使用保存的存储状态创建一个新上下文。
const context = await browser.newContext({ storageState: 'state.json' });