跳到主要内容

夹具

简介

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 夹具并将其提供给您的测试函数。

以下是您可能大部分时间都会使用预定义夹具的列表

夹具类型描述
pagePage此测试运行的隔离页面。
contextBrowserContext此测试运行的隔离上下文。page 夹具也属于此上下文。了解如何配置上下文
browserBrowser浏览器在测试之间共享以优化资源。了解如何配置浏览器
browserNamestring当前运行测试的浏览器的名称。可以是 chromiumfirefoxwebkit
requestAPIRequestContext此测试运行的隔离APIRequestContext 实例。

无夹具

以下是如何在传统测试风格和基于夹具的测试风格之间区分典型的测试环境设置。

TodoPage 是一个类,它有助于与 Web 应用程序的“待办事项列表”页面交互,遵循页面对象模型模式。它在内部使用 Playwright 的 page

点击展开 TodoPage 的代码
todo-page.ts
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();
}
}
}
todo.spec.ts
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 钩子相比,夹具具有一些优势

  • 夹具封装设置和拆卸在同一位置,因此更容易编写。
  • 夹具可以在测试文件之间重用 - 您可以定义一次并在所有测试中使用。Playwright 的内置 page 夹具就是这样工作的。
  • 夹具是按需的 - 您可以根据需要定义任意数量的夹具,Playwright Test 只会设置测试所需的夹具,而不是其他任何东西。
  • 夹具是可组合的 - 它们可以相互依赖以提供复杂的行为。
  • 夹具是灵活的。测试可以使用任何夹具组合来定制它们所需的精确环境,而不会影响其他测试。
  • 夹具简化了分组。您不再需要将测试包装在设置环境的 describe 中,并且可以自由地根据其含义对测试进行分组。
点击展开 TodoPage 的代码
todo-page.ts
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();
}
}
}
example.spec.ts
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 对象,其中将包含它。

下面我们创建了两个夹具 todoPagesettingsPage,它们遵循页面对象模型模式。

点击展开 TodoPageSettingsPage 的代码
todo-page.ts
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 类似

settings-page.ts
import type { Page } from '@playwright/test';

export class SettingsPage {
constructor(public readonly page: Page) {
}

async switchToDarkMode() {
// ...
}
}
my-test.ts
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,夹具将具有正确的类型。

下面我们使用了上面定义的 todoPagesettingsPage 夹具。

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() 在本地配置。

example.spec.ts

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] });
},
});

工作程序范围的夹具

Playwright Test 使用工作程序进程 来运行测试文件。类似于为单个测试运行设置测试夹具的方式,工作程序夹具为每个工作程序进程设置。这就是您可以设置服务、运行服务器等的地方。Playwright Test 将尽可能多地为测试文件重用工作程序进程,前提是它们的工作程序夹具匹配,因此环境相同。

下面我们将创建一个 account 夹具,它将由同一工作程序中的所有测试共享,并覆盖 page 夹具以在每个测试中登录到此帐户。为了生成唯一的帐户,我们将使用workerInfo.workerIndex,它可用于任何测试或夹具。请注意工作程序夹具的元组式语法 - 我们必须传递 {scope: 'worker'},以便测试运行器为每个工作程序设置此夹具一次。

my-test.ts
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';

自动夹具

即使测试没有直接列出自动夹具,也会为每个测试/工作程序设置自动夹具。要创建自动夹具,请使用元组语法并传递 { auto: true }

这是一个示例夹具,它在测试失败时自动附加调试日志,以便我们稍后在报告器中查看日志。请注意它如何使用TestInfo 对象,该对象在每个测试/夹具中都可用,以检索有关正在运行的测试的元数据。

my-test.ts
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';

夹具超时

默认情况下,夹具与测试共享超时。但是,对于慢速夹具,尤其是工作程序范围的夹具,拥有单独的超时很方便。这样,您可以将整体测试超时保持较小,并为慢速夹具提供更多时间。

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 的代码
todo-page.ts
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();
}
}
}
my-test.ts
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 选项。

playwright.config.ts
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() 调用分隔。设置在夹具被测试/钩子使用之前执行,拆卸在夹具不再被测试/钩子使用时执行。

夹具遵循以下规则来确定执行顺序

  • 当夹具 A 依赖于夹具 B 时:B 始终在 A 之前设置并在 A 之后拆卸。
  • 非自动夹具是延迟执行的,只有当测试/钩子需要它们时才会执行。
  • 测试范围的夹具在每个测试后拆卸,而工作程序范围的夹具仅在执行测试的工作程序进程关闭时拆卸。

考虑以下示例

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 () => { /* ... */ });

通常,如果所有测试都通过并且没有抛出错误,则执行顺序如下。

  • 工作程序设置和 beforeAll 部分
    • browser 设置,因为它在 autoWorkerFixture 中是必需的。
    • autoWorkerFixture 设置,因为自动工作程序夹具始终在任何其他内容之前设置。
    • beforeAll 运行。
  • 第一个测试 部分
    • autoTestFixture 设置,因为自动测试夹具始终在测试和 beforeEach 钩子之前设置。
    • page 设置,因为它在 beforeEach 钩子中是必需的。
    • beforeEach 运行。
    • first test 运行。
    • afterEach 运行。
    • page 销毁,因为它是一个测试范围内的 fixture,应该在测试结束后销毁。
    • autoTestFixture 销毁,因为它是一个测试范围内的 fixture,应该在测试结束后销毁。
  • second test 部分
    • autoTestFixture 设置,因为自动测试夹具始终在测试和 beforeEach 钩子之前设置。
    • page 设置,因为它在 beforeEach 钩子中是必需的。
    • beforeEach 运行。
    • workerFixture 设置,因为它被 second test 所需的 testFixture 依赖。
    • testFixture 设置,因为它被 second test 依赖。
    • second test 运行。
    • afterEach 运行。
    • testFixture 销毁,因为它是一个测试范围内的 fixture,应该在测试结束后销毁。
    • page 销毁,因为它是一个测试范围内的 fixture,应该在测试结束后销毁。
    • autoTestFixture 销毁,因为它是一个测试范围内的 fixture,应该在测试结束后销毁。
  • afterAll 和 worker 销毁部分
    • afterAll 运行。
    • workerFixture 销毁,因为它是一个 worker 范围内的 fixture,应该在最后销毁一次。
    • autoWorkerFixture 销毁,因为它是一个 worker 范围内的 fixture,应该在最后销毁一次。
    • browser 销毁,因为它是一个 worker 范围内的 fixture,应该在最后销毁一次。

一些观察结果

  • pageautoTestFixture 作为测试范围内的 fixture,在每个测试中都会设置和销毁。
  • unusedFixture 从未设置,因为它没有被任何测试/钩子使用。
  • testFixture 依赖于 workerFixture 并触发其设置。
  • workerFixture 在第二个测试之前延迟设置,但在 worker 关闭期间作为 worker 范围内的 fixture 销毁一次。
  • autoWorkerFixturebeforeAll 钩子设置,但 autoTestFixture 没有。

合并来自多个模块的自定义 fixture

您可以合并来自多个文件或模块的测试 fixture

fixtures.ts
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);
test.spec.ts
import { test } from './fixtures';

test('passes', async ({ database, page, a11y }) => {
// use database and a11y fixtures.
});

封装 fixture

通常,自定义 fixture 会在 UI 模式、跟踪查看器和各种测试报告中作为单独的步骤报告。它们还会出现在测试运行程序的错误消息中。对于常用 fixture,这可能意味着很多噪音。您可以通过“封装”它来停止在 UI 中显示 fixture 步骤。

import { test as base } from '@playwright/test';

export const test = base.extend({
helperFixture: [async ({}, use, testInfo) => {
// ...
}, { box: true }],
});

这对于不重要的辅助 fixture 很有用。例如,一个自动 fixture 设置一些通用数据可以安全地从测试报告中隐藏。

自定义 fixture 标题

您可以为 fixture 提供一个自定义标题,而不是通常的 fixture 名称,该标题将显示在测试报告和错误消息中。

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() 块(如果有)中声明的每个测试之前/之后运行。如果要声明在每个测试之前/之后全局运行的钩子,可以像这样声明为自动 fixture

fixtures.ts
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.
});

然后在所有测试中导入 fixture

mytest.spec.ts
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 钩子

test.beforeAll()test.afterAll() 钩子在同一文件和同一 test.describe() 块(如果有)中声明的所有测试之前/之后运行,每个工作进程运行一次。如果要声明在每个文件中的所有测试之前/之后运行的钩子,可以像这样使用 scope: 'worker' 声明为自动 fixture

fixtures.ts
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.
});

然后在所有测试中导入 fixture

mytest.spec.ts
import { test } from './fixtures';
import { expect } from '@playwright/test';

test('basic', async ({ }) => {
// ...
});

请注意,fixture 仍然会每个工作进程运行一次,但您无需在每个文件中重新声明它们。