夹具
简介
Playwright 测试基于测试夹具的概念。测试夹具用于为每个测试建立环境,为测试提供所需的一切,仅此而已。测试夹具在测试之间是隔离的。使用夹具,您可以根据测试的含义而不是它们的通用设置对测试进行分组。
内置夹具
您已经在您的第一个测试中使用了测试夹具。
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 hook 相比,夹具有许多优势
- 夹具将设置和清理封装在同一位置,因此更容易编写。因此,如果您有一个 after hook 用于清理在 before hook 中创建的内容,请考虑将它们转换为夹具。
- 夹具在测试文件之间是可重用的 - 您可以定义一次并在所有测试中使用它。这就是 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';
自定义夹具名称应以字母或下划线开头,并且只能包含字母、数字、下划线。
使用夹具
只需在您的测试函数参数中提及夹具,测试运行器就会处理它。夹具在 hook 和其他夹具中也可用。如果您使用 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 * as debug from 'debug';
import * as 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 支持运行可以单独配置的多个测试项目。您可以使用“option”夹具使您的配置选项具有声明性和类型检查。了解有关参数化测试的更多信息。
下面,除了其他示例中的 `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,
});
执行顺序
每个夹具都有一个设置和清理阶段,它们之间由夹具中的 `await use()` 调用分隔。设置在测试/hook 使用夹具之前执行,清理在测试/hook 不再使用夹具时执行。
夹具遵循以下规则来确定执行顺序
- 当夹具 A 依赖于夹具 B 时:B 始终在 A 之前设置,并在 A 之后清理。
- 非自动夹具是延迟执行的,仅在测试/hook 需要它们时才执行。
- 测试作用域的夹具在每个测试之后清理,而 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 设置和 `beforeAll` 部分
browser
设置,因为它被 `autoWorkerFixture` 需要。autoWorkerFixture
设置,因为自动 worker 夹具始终在任何其他内容之前设置。beforeAll
运行。
第一个测试
部分autoTestFixture
设置,因为自动测试夹具始终在测试和 `beforeEach` hook 之前设置。page
设置,因为它在 `beforeEach` hook 中是必需的。beforeEach
运行。第一个测试
运行。afterEach
运行。page
清理,因为它是测试作用域的夹具,应在测试完成后清理。autoTestFixture
清理,因为它是测试作用域的夹具,应在测试完成后清理。
第二个测试
部分autoTestFixture
设置,因为自动测试夹具始终在测试和 `beforeEach` hook 之前设置。page
设置,因为它在 `beforeEach` hook 中是必需的。beforeEach
运行。workerFixture
设置,因为它被 `testFixture` 需要,而 `testFixture` 又被 `第二个测试` 需要。testFixture
设置,因为它被 `第二个测试` 需要。第二个测试
运行。afterEach
运行。testFixture
清理,因为它是测试作用域的夹具,应在测试完成后清理。page
清理,因为它是测试作用域的夹具,应在测试完成后清理。autoTestFixture
清理,因为它是测试作用域的夹具,应在测试完成后清理。
afterAll
和 worker 清理部分afterAll
运行。workerFixture
清理,因为它是 worker 作用域的夹具,应在最后清理一次。autoWorkerFixture
清理,因为它是 worker 作用域的夹具,应在最后清理一次。browser
清理,因为它是 worker 作用域的夹具,应在最后清理一次。
一些观察
page
和autoTestFixture
作为测试作用域的夹具,为每个测试设置和清理。unusedFixture
永远不会设置,因为它未被任何测试/hook 使用。testFixture
依赖于workerFixture
并触发其设置。workerFixture
在第二个测试之前延迟设置,但在 worker 关闭期间清理一次,作为 worker 作用域的夹具。autoWorkerFixture
为 `beforeAll` hook 设置,但 `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 模式、Trace Viewer 和各种测试报告中报告为单独的步骤。它们也会出现在测试运行器的错误消息中。对于常用夹具,这可能意味着很多噪音。您可以通过“boxing”来阻止夹具步骤显示在 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 hook
test.beforeEach() 和 test.afterEach() hook 在同一文件和同一 test.describe() 块(如果有)中声明的每个测试之前/之后运行。如果您想声明全局运行的每个测试之前/之后的 hook,您可以像这样将它们声明为自动夹具
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('https://127.0.0.1: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('https://127.0.0.1:8000');
await page.goto('https://playwright.net.cn');
});
添加全局 beforeAll/afterAll hook
test.beforeAll() 和 test.afterAll() hook 在同一文件和同一 test.describe() 块(如果有)中声明的所有测试之前/之后运行,每个 worker 进程一次。如果您想声明在每个文件的所有测试之前/之后运行的 hook,您可以将它们声明为具有 `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 进程 运行一次,但您无需在每个文件中重新声明它们。