夹具
简介
Playwright Test 基于测试夹具的概念。测试夹具用于为每个测试建立环境,为测试提供所需的一切,不多也不少。测试夹具在各个测试之间是相互隔离的。使用夹具,您可以根据测试的含义而不是公共设置来对测试进行分组。
内置夹具
您已经在第一个测试中使用了测试夹具。
import { test, expect } from '@playwright/test';
test('basic test', async ({ page }) => {
await page.goto('https://playwright.net.cn/');
await expect(page).toHaveTitle(/Playwright/);
});
{ page }
参数告诉 Playwright Test 设置 page
夹具并将其提供给您的测试函数。
以下是您最常使用的预定义夹具列表
夹具 | 类型 | 描述 |
---|---|---|
page | Page | 本次测试运行的隔离页面。 |
context | BrowserContext | 本次测试运行的隔离上下文。page 夹具也属于此上下文。了解如何配置上下文。 |
browser | Browser | 浏览器在测试之间共享以优化资源。了解如何配置浏览器。 |
browserName | string | 当前运行测试的浏览器名称。可以是 chromium 、firefox 或 webkit 。 |
request | APIRequestContext | 本次测试运行的隔离 APIRequestContext 实例。 |
不使用夹具
以下是传统测试风格和基于夹具的测试风格之间典型测试环境设置的差异。
TodoPage
是一个类,它遵循页面对象模型模式,帮助与 Web 应用程序的“待办事项列表”页面进行交互。它内部使用 Playwright 的 page
。
点击展开 TodoPage
的代码
import type { Page, Locator } from '@playwright/test';
export class TodoPage {
private readonly inputBox: Locator;
private readonly todoItems: Locator;
constructor(public readonly page: Page) {
this.inputBox = this.page.locator('input.new-todo');
this.todoItems = this.page.getByTestId('todo-item');
}
async goto() {
await this.page.goto('https://demo.playwright.dev/todomvc/');
}
async addToDo(text: string) {
await this.inputBox.fill(text);
await this.inputBox.press('Enter');
}
async remove(text: string) {
const todo = this.todoItems.filter({ hasText: text });
await todo.hover();
await todo.getByLabel('Delete').click();
}
async removeAll() {
while ((await this.todoItems.count()) > 0) {
await this.todoItems.first().hover();
await this.todoItems.getByLabel('Delete').first().click();
}
}
}
const { test } = require('@playwright/test');
const { TodoPage } = require('./todo-page');
test.describe('todo tests', () => {
let todoPage;
test.beforeEach(async ({ page }) => {
todoPage = new TodoPage(page);
await todoPage.goto();
await todoPage.addToDo('item1');
await todoPage.addToDo('item2');
});
test.afterEach(async () => {
await todoPage.removeAll();
});
test('should add an item', async () => {
await todoPage.addToDo('my item');
// ...
});
test('should remove an item', async () => {
await todoPage.remove('item1');
// ...
});
});
使用夹具
夹具相对于 before/after 钩子有许多优点
- 夹具将设置和清理封装在同一个地方,更容易编写。因此,如果您有一个 after 钩子用于清理在 before 钩子中创建的内容,请考虑将它们转换为夹具。
- 夹具可以在测试文件之间复用 - 您可以定义一次并在所有测试中使用。Playwright 的内置
page
夹具就是这样工作的。因此,如果您有一个在多个测试中使用的辅助函数,请考虑将其转换为夹具。 - 夹具是按需的 - 您可以定义任意数量的夹具,Playwright Test 只会设置您的测试所需的夹具,而不会设置其他夹具。
- 夹具是可组合的 - 它们可以相互依赖以提供复杂的行为。
- 夹具是灵活的。测试可以使用夹具的任意组合来定制它们所需的精确环境,而不会影响其他测试。
- 夹具简化了分组。您不再需要将测试包装在设置环境的
describe
块中,而是可以自由地根据测试的含义对其进行分组。
点击展开 TodoPage
的代码
import type { Page, Locator } from '@playwright/test';
export class TodoPage {
private readonly inputBox: Locator;
private readonly todoItems: Locator;
constructor(public readonly page: Page) {
this.inputBox = this.page.locator('input.new-todo');
this.todoItems = this.page.getByTestId('todo-item');
}
async goto() {
await this.page.goto('https://demo.playwright.dev/todomvc/');
}
async addToDo(text: string) {
await this.inputBox.fill(text);
await this.inputBox.press('Enter');
}
async remove(text: string) {
const todo = this.todoItems.filter({ hasText: text });
await todo.hover();
await todo.getByLabel('Delete').click();
}
async removeAll() {
while ((await this.todoItems.count()) > 0) {
await this.todoItems.first().hover();
await this.todoItems.getByLabel('Delete').first().click();
}
}
}
import { test as base } from '@playwright/test';
import { TodoPage } from './todo-page';
// Extend basic test by providing a "todoPage" fixture.
const test = base.extend<{ todoPage: TodoPage }>({
todoPage: async ({ page }, use) => {
const todoPage = new TodoPage(page);
await todoPage.goto();
await todoPage.addToDo('item1');
await todoPage.addToDo('item2');
await use(todoPage);
await todoPage.removeAll();
},
});
test('should add an item', async ({ todoPage }) => {
await todoPage.addToDo('my item');
// ...
});
test('should remove an item', async ({ todoPage }) => {
await todoPage.remove('item1');
// ...
});
创建夹具
要创建您自己的夹具,请使用 test.extend() 来创建一个新的 test
对象,该对象将包含您的夹具。
下面我们创建两个遵循页面对象模型模式的夹具 todoPage
和 settingsPage
。
点击展开 TodoPage
和 SettingsPage
的代码
import type { Page, Locator } from '@playwright/test';
export class TodoPage {
private readonly inputBox: Locator;
private readonly todoItems: Locator;
constructor(public readonly page: Page) {
this.inputBox = this.page.locator('input.new-todo');
this.todoItems = this.page.getByTestId('todo-item');
}
async goto() {
await this.page.goto('https://demo.playwright.dev/todomvc/');
}
async addToDo(text: string) {
await this.inputBox.fill(text);
await this.inputBox.press('Enter');
}
async remove(text: string) {
const todo = this.todoItems.filter({ hasText: text });
await todo.hover();
await todo.getByLabel('Delete').click();
}
async removeAll() {
while ((await this.todoItems.count()) > 0) {
await this.todoItems.first().hover();
await this.todoItems.getByLabel('Delete').first().click();
}
}
}
SettingsPage 类似
import type { Page } from '@playwright/test';
export class SettingsPage {
constructor(public readonly page: Page) {
}
async switchToDarkMode() {
// ...
}
}
import { test as base } from '@playwright/test';
import { TodoPage } from './todo-page';
import { SettingsPage } from './settings-page';
// Declare the types of your fixtures.
type MyFixtures = {
todoPage: TodoPage;
settingsPage: SettingsPage;
};
// Extend base test by providing "todoPage" and "settingsPage".
// This new "test" can be used in multiple test files, and each of them will get the fixtures.
export const test = base.extend<MyFixtures>({
todoPage: async ({ page }, use) => {
// Set up the fixture.
const todoPage = new TodoPage(page);
await todoPage.goto();
await todoPage.addToDo('item1');
await todoPage.addToDo('item2');
// Use the fixture value in the test.
await use(todoPage);
// Clean up the fixture.
await todoPage.removeAll();
},
settingsPage: async ({ page }, use) => {
await use(new SettingsPage(page));
},
});
export { expect } from '@playwright/test';
自定义夹具名称应以字母或下划线开头,且只能包含字母、数字和下划线。
使用夹具
只需在您的测试函数参数中提及夹具,测试运行器就会处理它。夹具也可用于钩子和其他夹具中。如果您使用 TypeScript,夹具将具有正确的类型。
下面我们使用上面定义的 todoPage
和 settingsPage
夹具。
import { test, expect } from './my-test';
test.beforeEach(async ({ settingsPage }) => {
await settingsPage.switchToDarkMode();
});
test('basic test', async ({ todoPage, page }) => {
await todoPage.addToDo('something nice');
await expect(page.getByTestId('todo-title')).toContainText(['something nice']);
});
覆盖夹具
除了创建您自己的夹具外,您还可以覆盖现有的夹具以满足您的需求。考虑以下示例,它通过自动导航到某个 baseURL
来覆盖 page
夹具
import { test as base } from '@playwright/test';
export const test = base.extend({
page: async ({ baseURL, page }, use) => {
await page.goto(baseURL);
await use(page);
},
});
请注意,在此示例中,page
夹具能够依赖其他内置夹具,例如 testOptions.baseURL。现在我们可以在配置文件中配置 baseURL
,或者在测试文件中使用 test.use() 本地配置。
test.use({ baseURL: 'https://playwright.net.cn' });
夹具也可以被覆盖,将基本夹具完全替换为不同的内容。例如,我们可以覆盖 testOptions.storageState 夹具来提供我们自己的数据。
import { test as base } from '@playwright/test';
export const test = base.extend({
storageState: async ({}, use) => {
const cookie = await getAuthCookie();
await use({ cookies: [cookie] });
},
});
Worker 范围的夹具
Playwright Test 使用 worker 进程来运行测试文件。与为单个测试运行设置测试夹具类似,为每个 worker 进程设置 worker 夹具。您可以在其中设置服务、运行服务器等。Playwright Test 将尽可能多地重复使用 worker 进程来运行测试文件,前提是它们的 worker 夹具匹配,从而环境相同。
下面我们将创建一个 account
夹具,该夹具将在同一 worker 中的所有测试之间共享,并覆盖 page
夹具以在每个测试中登录此帐户。为了生成唯一的帐户,我们将使用任何测试或夹具都可以访问的 workerInfo.workerIndex。请注意 worker 夹具的类似元组的语法 - 我们必须传入 {scope: 'worker'}
,以便测试运行器为每个 worker 设置一次此夹具。
import { test as base } from '@playwright/test';
type Account = {
username: string;
password: string;
};
// Note that we pass worker fixture types as a second template parameter.
export const test = base.extend<{}, { account: Account }>({
account: [async ({ browser }, use, workerInfo) => {
// Unique username.
const username = 'user' + workerInfo.workerIndex;
const password = 'verysecure';
// Create the account with Playwright.
const page = await browser.newPage();
await page.goto('/signup');
await page.getByLabel('User Name').fill(username);
await page.getByLabel('Password').fill(password);
await page.getByText('Sign up').click();
// Make sure everything is ok.
await expect(page.getByTestId('result')).toHaveText('Success');
// Do not forget to cleanup.
await page.close();
// Use the account value.
await use({ username, password });
}, { scope: 'worker' }],
page: async ({ page, account }, use) => {
// Sign in with our account.
const { username, password } = account;
await page.goto('/signin');
await page.getByLabel('User Name').fill(username);
await page.getByLabel('Password').fill(password);
await page.getByText('Sign in').click();
await expect(page.getByTestId('userinfo')).toHaveText(username);
// Use signed-in page in the test.
await use(page);
},
});
export { expect } from '@playwright/test';
自动夹具
自动夹具会为每个测试/worker 设置,即使测试没有直接列出它们。要创建自动夹具,请使用元组语法并传入 { auto: true }
。
这是一个示例夹具,当测试失败时会自动附加调试日志,以便我们稍后在报告器中查看日志。请注意它是如何使用在每个测试/夹具中都可用的 TestInfo 对象来检索有关正在运行的测试的元数据的。
import debug from 'debug';
import fs from 'fs';
import { test as base } from '@playwright/test';
export const test = base.extend<{ saveLogs: void }>({
saveLogs: [async ({}, use, testInfo) => {
// Collecting logs during the test.
const logs = [];
debug.log = (...args) => logs.push(args.map(String).join(''));
debug.enable('myserver');
await use();
// After the test we can check whether the test passed or failed.
if (testInfo.status !== testInfo.expectedStatus) {
// outputPath() API guarantees a unique file name.
const logFile = testInfo.outputPath('logs.txt');
await fs.promises.writeFile(logFile, logs.join('\n'), 'utf8');
testInfo.attachments.push({ name: 'logs', contentType: 'text/plain', path: logFile });
}
}, { auto: true }],
});
export { expect } from '@playwright/test';
夹具超时
默认情况下,夹具与测试共享超时。然而,对于速度较慢的夹具,特别是 worker 范围的夹具,拥有单独的超时时间会很方便。这样可以保持整体测试超时时间较短,并为慢夹具留出更多时间。
import { test as base, expect } from '@playwright/test';
const test = base.extend<{ slowFixture: string }>({
slowFixture: [async ({}, use) => {
// ... perform a slow operation ...
await use('hello');
}, { timeout: 60000 }]
});
test('example test', async ({ slowFixture }) => {
// ...
});
夹具选项
Playwright Test 支持运行多个可以单独配置的测试项目。您可以使用“选项”夹具使您的配置选项具有声明性并进行类型检查。了解更多关于参数化测试的信息。
下面我们将除了其他示例中的 todoPage
夹具外,再创建一个 defaultItem
选项。此选项将在配置文件中设置。请注意元组语法和 { option: true }
参数。
点击展开 TodoPage
的代码
import type { Page, Locator } from '@playwright/test';
export class TodoPage {
private readonly inputBox: Locator;
private readonly todoItems: Locator;
constructor(public readonly page: Page) {
this.inputBox = this.page.locator('input.new-todo');
this.todoItems = this.page.getByTestId('todo-item');
}
async goto() {
await this.page.goto('https://demo.playwright.dev/todomvc/');
}
async addToDo(text: string) {
await this.inputBox.fill(text);
await this.inputBox.press('Enter');
}
async remove(text: string) {
const todo = this.todoItems.filter({ hasText: text });
await todo.hover();
await todo.getByLabel('Delete').click();
}
async removeAll() {
while ((await this.todoItems.count()) > 0) {
await this.todoItems.first().hover();
await this.todoItems.getByLabel('Delete').first().click();
}
}
}
import { test as base } from '@playwright/test';
import { TodoPage } from './todo-page';
// Declare your options to type-check your configuration.
export type MyOptions = {
defaultItem: string;
};
type MyFixtures = {
todoPage: TodoPage;
};
// Specify both option and fixture types.
export const test = base.extend<MyOptions & MyFixtures>({
// Define an option and provide a default value.
// We can later override it in the config.
defaultItem: ['Something nice', { option: true }],
// Our "todoPage" fixture depends on the option.
todoPage: async ({ page, defaultItem }, use) => {
const todoPage = new TodoPage(page);
await todoPage.goto();
await todoPage.addToDo(defaultItem);
await use(todoPage);
await todoPage.removeAll();
},
});
export { expect } from '@playwright/test';
现在我们可以照常使用 todoPage
夹具,并在配置文件中设置 defaultItem
选项。
import { defineConfig } from '@playwright/test';
import type { MyOptions } from './my-test';
export default defineConfig<MyOptions>({
projects: [
{
name: 'shopping',
use: { defaultItem: 'Buy milk' },
},
{
name: 'wellbeing',
use: { defaultItem: 'Exercise!' },
},
]
});
数组作为选项值
如果您的选项值是一个数组,例如 [{ name: 'Alice' }, { name: 'Bob' }]
,则在提供值时需要将其包装在一个额外的数组中。最好的说明是看示例。
type Person = { name: string };
const test = base.extend<{ persons: Person[] }>({
// Declare the option, default value is an empty array.
persons: [[], { option: true }],
});
// Option value is an array of persons.
const actualPersons = [{ name: 'Alice' }, { name: 'Bob' }];
test.use({
// CORRECT: Wrap the value into an array and pass the scope.
persons: [actualPersons, { scope: 'test' }],
});
test.use({
// WRONG: passing an array value directly will not work.
persons: actualPersons,
});
执行顺序
每个夹具都有一个 setup 和 teardown 阶段,由夹具中的 await use()
调用分隔。Setup 在测试/钩子使用夹具之前执行,teardown 在测试/钩子不再使用夹具时执行。
夹具遵循这些规则来确定执行顺序
- 当夹具 A 依赖于夹具 B 时:B 始终在 A 之前设置,并在 A 之后清理。
- 非自动夹具是惰性执行的,仅在测试/钩子需要时执行。
- 测试范围的夹具在每次测试后清理,而 worker 范围的夹具仅在执行测试的 worker 进程关闭时清理。
考虑以下示例
import { test as base } from '@playwright/test';
const test = base.extend<{
testFixture: string,
autoTestFixture: string,
unusedFixture: string,
}, {
workerFixture: string,
autoWorkerFixture: string,
}>({
workerFixture: [async ({ browser }) => {
// workerFixture setup...
await use('workerFixture');
// workerFixture teardown...
}, { scope: 'worker' }],
autoWorkerFixture: [async ({ browser }) => {
// autoWorkerFixture setup...
await use('autoWorkerFixture');
// autoWorkerFixture teardown...
}, { scope: 'worker', auto: true }],
testFixture: [async ({ page, workerFixture }) => {
// testFixture setup...
await use('testFixture');
// testFixture teardown...
}, { scope: 'test' }],
autoTestFixture: [async () => {
// autoTestFixture setup...
await use('autoTestFixture');
// autoTestFixture teardown...
}, { scope: 'test', auto: true }],
unusedFixture: [async ({ page }) => {
// unusedFixture setup...
await use('unusedFixture');
// unusedFixture teardown...
}, { scope: 'test' }],
});
test.beforeAll(async () => { /* ... */ });
test.beforeEach(async ({ page }) => { /* ... */ });
test('first test', async ({ page }) => { /* ... */ });
test('second test', async ({ testFixture }) => { /* ... */ });
test.afterEach(async () => { /* ... */ });
test.afterAll(async () => { /* ... */ });
通常,如果所有测试都通过且没有抛出错误,执行顺序如下。
- worker setup 和
beforeAll
部分browser
setup 因为它被autoWorkerFixture
所需。autoWorkerFixture
setup 因为自动 worker 夹具总是在其他任何东西之前设置。beforeAll
运行。
第一个测试
部分autoTestFixture
setup 因为自动测试夹具总是在测试和beforeEach
钩子之前设置。page
setup 因为它在beforeEach
钩子中必需。beforeEach
运行。第一个测试
运行。afterEach
运行。page
teardown 因为它是测试范围的夹具,应在测试完成后清理。autoTestFixture
teardown 因为它是测试范围的夹具,应在测试完成后清理。
第二个测试
部分autoTestFixture
setup 因为自动测试夹具总是在测试和beforeEach
钩子之前设置。page
setup 因为它在beforeEach
钩子中必需。beforeEach
运行。workerFixture
setup 因为它被testFixture
所需,而testFixture
又被第二个测试
所需。testFixture
setup 因为它被第二个测试
所需。第二个测试
运行。afterEach
运行。testFixture
teardown 因为它是测试范围的夹具,应在测试完成后清理。page
teardown 因为它是测试范围的夹具,应在测试完成后清理。autoTestFixture
teardown 因为它是测试范围的夹具,应在测试完成后清理。
afterAll
和 worker teardown 部分afterAll
运行。workerFixture
teardown 因为它是 worker 范围的夹具,应在结束时清理一次。autoWorkerFixture
teardown 因为它是 worker 范围的夹具,应在结束时清理一次。browser
teardown 因为它是 worker 范围的夹具,应在结束时清理一次。
一些观察
page
和autoTestFixture
作为测试范围的夹具,为每个测试进行设置和清理。unusedFixture
从未设置,因为它没有被任何测试/钩子使用。testFixture
依赖于workerFixture
并触发其设置。workerFixture
作为 worker 范围的夹具,在第二个测试之前惰性设置,但在 worker 关闭时清理一次。autoWorkerFixture
为beforeAll
钩子设置,但autoTestFixture
不是。
合并来自多个模块的自定义夹具
您可以合并来自多个文件或模块的测试夹具
import { mergeTests } from '@playwright/test';
import { test as dbTest } from 'database-test-utils';
import { test as a11yTest } from 'a11y-test-utils';
export const test = mergeTests(dbTest, a11yTest);
import { test } from './fixtures';
test('passes', async ({ database, page, a11y }) => {
// use database and a11y fixtures.
});
Box 夹具
通常,自定义夹具在 UI 模式、跟踪查看器和各种测试报告中作为单独的步骤报告。它们也出现在测试运行器的错误消息中。对于频繁使用的夹具,这可能意味着大量噪音。您可以通过“装箱”夹具来阻止其步骤显示在 UI 中。
import { test as base } from '@playwright/test';
export const test = base.extend({
helperFixture: [async ({}, use, testInfo) => {
// ...
}, { box: true }],
});
这对于不重要的辅助夹具很有用。例如,设置一些常用数据的自动夹具可以安全地从测试报告中隐藏起来。
自定义夹具标题
除了通常的夹具名称,您可以为夹具指定一个自定义标题,该标题将显示在测试报告和错误消息中。
import { test as base } from '@playwright/test';
export const test = base.extend({
innerFixture: [async ({}, use, testInfo) => {
// ...
}, { title: 'my fixture' }],
});
添加全局 beforeEach/afterEach 钩子
test.beforeEach() 和 test.afterEach() 钩子在同一文件和同一 test.describe() 块(如果有)中声明的每个测试之前/之后运行。如果您想声明在每个测试全局运行的钩子,您可以将它们声明为自动夹具,如下所示
import { test as base } from '@playwright/test';
export const test = base.extend<{ forEachTest: void }>({
forEachTest: [async ({ page }, use) => {
// This code runs before every test.
await page.goto('http://localhost:8000');
await use();
// This code runs after every test.
console.log('Last URL:', page.url());
}, { auto: true }], // automatically starts for every test.
});
然后在所有测试中导入这些夹具
import { test } from './fixtures';
import { expect } from '@playwright/test';
test('basic', async ({ page }) => {
expect(page).toHaveURL('http://localhost:8000');
await page.goto('https://playwright.net.cn');
});
添加全局 beforeAll/afterAll 钩子
test.beforeAll() 和 test.afterAll() 钩子在同一文件和同一 test.describe() 块(如果有)中声明的所有测试之前/之后运行,每个 worker 进程运行一次。如果您想声明在每个文件的所有测试之前/之后运行的钩子,您可以将它们声明为带有 scope: 'worker'
的自动夹具,如下所示
import { test as base } from '@playwright/test';
export const test = base.extend<{}, { forEachWorker: void }>({
forEachWorker: [async ({}, use) => {
// This code runs before all the tests in the worker process.
console.log(`Starting test worker ${test.info().workerIndex}`);
await use();
// This code runs after all the tests in the worker process.
console.log(`Stopping test worker ${test.info().workerIndex}`);
}, { scope: 'worker', auto: true }], // automatically starts for every worker.
});
然后在所有测试中导入这些夹具
import { test } from './fixtures';
import { expect } from '@playwright/test';
test('basic', async ({ }) => {
// ...
});
请注意,这些夹具仍然会为每个 worker 进程运行一次,但您无需在每个文件中重新声明它们。