Fixtures
简介
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 | 此测试运行的独立页面。 |
| 上下文 | BrowserContext | 此测试运行的独立上下文。page 夹具也属于此上下文。了解如何配置上下文。 |
| 浏览器 | Browser | 浏览器在测试之间共享以优化资源。了解如何配置浏览器。 |
| 浏览器名称 | 字符串 | 当前运行测试的浏览器名称。可以是 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 钩子有许多优点
- 夹具将设置和拆卸封装在同一个地方,因此更容易编写。因此,如果您有一个在 before 钩子中创建的 after 钩子用于拆卸,请考虑将它们转换为夹具。
- 夹具可以在测试文件之间重用——您可以定义它们一次并在所有测试中使用它们。这就是 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。我们现在可以在配置文件中或在测试文件中使用 test.use() 本地配置 baseURL。
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,
});
重置选项
您可以将选项重置为配置文件中定义的值,方法是将其设置为 undefined。请看以下设置 baseURL 的配置
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
baseURL: 'https://playwright.net.cn',
},
});
您现在可以为一个文件配置 baseURL,也可以为单个测试选择退出。
import { test } from '@playwright/test';
// Configure baseURL for this file.
test.use({ baseURL: 'https://playwright.net.cn/docs/intro' });
test('check intro contents', async ({ page }) => {
// This test will use "https://playwright.net.cn/docs/intro" base url as defined above.
});
test.describe(() => {
// Reset the value to a config-defined one.
test.use({ baseURL: undefined });
test('can navigate to intro from the home page', async ({ page }) => {
// This test will use "https://playwright.net.cn" base url as defined in the config.
});
});
如果您想将值完全重置为 undefined,请使用长格式夹具表示法。
import { test } from '@playwright/test';
// Completely unset baseURL for this file.
test.use({
baseURL: [async ({}, use) => use(undefined), { scope: 'test' }],
});
test('no base url', async ({ page }) => {
// This test will not have a base url.
});
执行顺序
每个夹具在夹具中的 await use() 调用之前和之后都有一个设置和拆卸阶段。设置在需要它的测试/钩子运行之前执行,拆卸在测试/钩子不再使用夹具时执行。
夹具遵循以下规则来确定执行顺序
- 当夹具 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 设置和
beforeAll部分browser设置,因为它被autoWorkerFixture需要。autoWorkerFixture设置,因为自动 worker 夹具总是在其他任何东西之前设置。beforeAll运行。
第一个测试部分autoTestFixture设置,因为自动测试夹具总是在测试和beforeEach钩子之前设置。page设置,因为它在beforeEach钩子中需要。beforeEach运行。第一个测试运行。afterEach运行。page拆卸,因为它是一个测试范围的夹具,应该在测试结束后拆卸。autoTestFixture拆卸,因为它是一个测试范围的夹具,应该在测试结束后拆卸。
第二个测试部分autoTestFixture设置,因为自动测试夹具总是在测试和beforeEach钩子之前设置。page设置,因为它在beforeEach钩子中需要。beforeEach运行。workerFixture设置,因为它被testFixture需要,而testFixture又被第二个测试需要。testFixture设置,因为它被第二个测试需要。第二个测试运行。afterEach运行。testFixture拆卸,因为它是一个测试范围的夹具,应该在测试结束后拆卸。page拆卸,因为它是一个测试范围的夹具,应该在测试结束后拆卸。autoTestFixture拆卸,因为它是一个测试范围的夹具,应该在测试结束后拆卸。
afterAll和 worker 拆卸部分afterAll运行。workerFixture拆卸,因为它是一个 worker 范围的夹具,应该在最后拆卸一次。autoWorkerFixture拆卸,因为它是一个 worker 范围的夹具,应该在最后拆卸一次。browser拆卸,因为它是一个 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.
});
盒式夹具
通常,自定义夹具在 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('https://: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://: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 进程运行一次,但您无需在每个文件中重新声明它们。