测试固件
简介
Playwright Test 基于测试固件(fixtures)的概念构建。测试固件用于为每个测试建立运行环境,为测试提供所需的一切资源且不包含多余内容。测试固件在测试之间是相互隔离的。通过固件,您可以根据测试的实际意义而非通用设置来对测试进行分组。
内置固件
您已经在第一个测试中使用过测试固件。
import { test, expect } from '@playwright/test';
test('basic test', async ({ page }) => {
await page.goto('https://playwright.dev/');
await expect(page).toHaveTitle(/Playwright/);
});
{ page }
参数告诉 Playwright Test 需要设置 page
固件并将其提供给测试函数。
以下是您最可能经常使用的预定义固件列表:
固件 | 类型 | 描述 |
---|---|---|
page | Page | 为本次测试运行的独立页面 |
context | BrowserContext | 为本次测试运行的独立上下文。page 固件也属于此上下文。了解如何 配置上下文。 |
browser | Browser | 浏览器在测试间共享以优化资源。了解如何 配置浏览器。 |
browserName | string | 当前运行测试的浏览器名称。可能是 chromium 、firefox 或 webkit 。 |
request | APIRequestContext | 为本次测试运行的独立 APIRequestContext 实例。 |
不使用 fixtures 的情况
以下是传统测试风格与基于 fixtures 的测试风格在典型测试环境设置上的区别。
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');
// ...
});
});
使用夹具(Fixtures)
相比 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';
// 通过提供 "todoPage" 夹具扩展基础测试
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('应该能添加项目', async ({ todoPage }) => {
await todoPage.addToDo('我的项目');
// ...
});
test('应该能删除项目', 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';
// 声明夹具的类型
type MyFixtures = {
todoPage: TodoPage;
settingsPage: SettingsPage;
};
// 通过提供 "todoPage" 和 "settingsPage" 扩展基础测试
// 这个新的 "test" 可以在多个测试文件中使用,每个文件都会获得这些夹具
export const test = base.extend<MyFixtures>({
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();
},
settingsPage: async ({ page }, use) => {
await use(new SettingsPage(page));
},
});
export { expect } from '@playwright/test';
自定义夹具名称应以字母或下划线开头,且只能包含字母、数字和下划线。
使用 fixture
只需在测试函数的参数中提及 fixture,测试运行器就会自动处理它。Fixture 也可以在钩子函数和其他 fixture 中使用。如果使用 TypeScript,fixture 将是类型安全的。
下面我们使用之前定义的 todoPage
和 settingsPage
fixture。
import { test, expect } from './my-test';
test.beforeEach(async ({ settingsPage }) => {
await settingsPage.switchToDarkMode();
});
test('基本测试', async ({ todoPage, page }) => {
await todoPage.addToDo('一些好事');
await expect(page.getByTestId('todo-title')).toContainText(['一些好事']);
});
覆盖 fixture
除了创建自己的 fixture 外,你还可以覆盖现有的 fixture 以满足需求。请看以下示例,它通过自动导航到 baseURL
来覆盖 page
fixture:
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
fixture 能够依赖于其他内置 fixture,比如 testOptions.baseURL。我们现在可以在配置文件中配置 baseURL
,或者通过 test.use() 在测试文件中局部配置。
test.use({ baseURL: 'https://playwright.dev' });
Fixture 也可以被完全覆盖,导致基础 fixture 被完全替换为不同的内容。例如,我们可以覆盖 testOptions.storageState fixture 来提供自己的数据。
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'}
,以便测试运行器为每个工作进程设置一次此夹具。
import { test as base } from '@playwright/test';
type Account = {
username: string;
password: string;
};
// 注意我们将工作进程夹具类型作为第二个模板参数传递
export const test = base.extend<{}, { account: Account }>({
account: [async ({ browser }, use, workerInfo) => {
// 唯一的用户名
const username = 'user' + workerInfo.workerIndex;
const password = 'verysecure';
// 使用 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();
// 确保一切正常
await expect(page.getByTestId('result')).toHaveText('Success');
// 别忘了清理
await page.close();
// 使用账户值
await use({ username, password });
}, { scope: 'worker' }],
page: async ({ page, account }, use) => {
// 使用我们的账户登录
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);
// 在测试中使用已登录的页面
await use(page);
},
});
export { expect } from '@playwright/test';
自动装置
自动装置会为每个测试/工作器设置,即使测试没有直接列出它们。要创建自动装置,请使用元组语法并传递 { 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) => {
// 在测试期间收集日志
const logs = [];
debug.log = (...args) => logs.push(args.map(String).join(''));
debug.enable('myserver');
await use();
// 测试结束后我们可以检查测试是通过还是失败
if (testInfo.status !== testInfo.expectedStatus) {
// outputPath() API 保证生成唯一的文件名
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) => {
// ... 执行一个慢速操作 ...
await use('hello');
}, { timeout: 60000 }]
});
test('示例测试', async ({ slowFixture }) => {
// ...
});
夹具选项
Playwright Test 支持运行多个可单独配置的测试项目。您可以使用"选项"夹具来使您的配置选项具有声明性和类型安全性。了解更多关于参数化测试的信息。
下面我们将创建一个defaultItem
选项,以及来自其他示例的todoPage
夹具。此选项将在配置文件中设置。注意元组语法和{ 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';
// 声明您的选项以类型检查配置
export type MyOptions = {
defaultItem: string;
};
type MyFixtures = {
todoPage: TodoPage;
};
// 指定选项和夹具类型
export const test = base.extend<MyOptions & MyFixtures>({
// 定义一个选项并提供默认值
// 稍后我们可以在配置中覆盖它
defaultItem: ['Something nice', { option: true }],
// 我们的"todoPage"夹具依赖于选项
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[] }>({
// 声明选项,默认值为空数组
persons: [[], { option: true }],
});
// 选项值是一个人员数组
const actualPersons = [{ name: 'Alice' }, { name: 'Bob' }];
test.use({
// 正确:将值包装到数组中并传递作用域
persons: [actualPersons, { scope: 'test' }],
});
test.use({
// 错误:直接传递数组值将不起作用
persons: actualPersons,
});
重置选项
您可以通过将选项设置为undefined
来将其重置为配置文件中定义的值。考虑以下设置baseURL
的配置:
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
baseURL: 'https://playwright.dev',
},
});
现在您可以为文件配置baseURL
,也可以为单个测试选择退出。
import { test } from '@playwright/test';
// 为此文件配置baseURL
test.use({ baseURL: 'https://playwright.dev/docs/intro' });
test('检查介绍内容', async ({ page }) => {
// 此测试将使用上面定义的"https://playwright.dev/docs/intro"基础URL
});
test.describe(() => {
// 将值重置为配置定义的值
test.use({ baseURL: undefined });
test('可以从主页导航到介绍', async ({ page }) => {
// 此测试将使用配置中定义的"https://playwright.dev"基础URL
});
});
如果您想完全将值重置为undefined
,请使用长格式夹具表示法。
import { test } from '@playwright/test';
// 完全取消为此文件设置baseURL
test.use({
baseURL: [async ({}, use) => use(undefined), { scope: 'test' }],
});
test('无基础URL', async ({ page }) => {
// 此测试将没有基础URL
});
执行顺序
每个 fixture 在 await use()
调用前后都有设置(setup)和拆卸(teardown)阶段。设置阶段会在需要该 fixture 的测试/钩子运行前执行,而拆卸阶段会在测试/钩子不再使用该 fixture 时执行。
Fixture 遵循以下规则确定执行顺序:
- 当 fixture A 依赖 fixture B 时:B 总是在 A 之前设置,并在 A 之后拆卸。
- 非自动(non-automatic) fixture 会延迟执行,仅在测试/钩子需要时才启动。
- 测试作用域(test-scoped)的 fixture 在每个测试后拆卸,而工作器作用域(worker-scoped)的 fixture 只在执行测试的工作器进程结束时拆卸。
考虑以下示例:
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 设置...
await use('workerFixture');
// workerFixture 拆卸...
}, { scope: 'worker' }],
autoWorkerFixture: [async ({ browser }) => {
// autoWorkerFixture 设置...
await use('autoWorkerFixture');
// autoWorkerFixture 拆卸...
}, { scope: 'worker', auto: true }],
testFixture: [async ({ page, workerFixture }) => {
// testFixture 设置...
await use('testFixture');
// testFixture 拆卸...
}, { scope: 'test' }],
autoTestFixture: [async () => {
// autoTestFixture 设置...
await use('autoTestFixture');
// autoTestFixture 拆卸...
}, { scope: 'test', auto: true }],
unusedFixture: [async ({ page }) => {
// unusedFixture 设置...
await use('unusedFixture');
// unusedFixture 拆卸...
}, { 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
设置,因为自动工作器 fixture 总是优先于其他内容设置。beforeAll
运行。
first test
部分:autoTestFixture
设置,因为自动测试 fixture 总是在测试和beforeEach
钩子之前设置。page
设置,因为beforeEach
钩子需要它。beforeEach
运行。first test
运行。afterEach
运行。page
拆卸,因为它是测试作用域的 fixture,应在测试结束后拆卸。autoTestFixture
拆卸,因为它是测试作用域的 fixture,应在测试结束后拆卸。
second test
部分:autoTestFixture
设置,因为自动测试 fixture 总是在测试和beforeEach
钩子之前设置。page
设置,因为beforeEach
钩子需要它。beforeEach
运行。workerFixture
设置,因为testFixture
需要它,而second test
又需要testFixture
。testFixture
设置,因为second test
需要它。second test
运行。afterEach
运行。testFixture
拆卸,因为它是测试作用域的 fixture,应在测试结束后拆卸。page
拆卸,因为它是测试作用域的 fixture,应在测试结束后拆卸。autoTestFixture
拆卸,因为它是测试作用域的 fixture,应在测试结束后拆卸。
afterAll
和工作器拆卸部分:afterAll
运行。workerFixture
拆卸,因为它是工作器作用域的 fixture,应在最后统一拆卸。autoWorkerFixture
拆卸,因为它是工作器作用域的 fixture,应在最后统一拆卸。browser
拆卸,因为它是工作器作用域的 fixture,应在最后统一拆卸。
几点观察:
page
和autoTestFixture
作为测试作用域的 fixture,会为每个测试设置和拆卸。unusedFixture
从未设置,因为没有测试/钩子使用它。testFixture
依赖workerFixture
并触发其设置。workerFixture
在第二个测试前延迟设置,但作为工作器作用域的 fixture,在工作器关闭时统一拆卸。autoWorkerFixture
为beforeAll
钩子设置,但autoTestFixture
不会。
合并来自多个模块的自定义 fixtures
你可以将来自多个文件或模块的测试 fixtures 进行合并:
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 }) => {
// 使用 database 和 a11y fixtures
});
盒装 fixtures
通常情况下,自定义 fixtures 会在 UI 模式、Trace Viewer 和各种测试报告中显示为单独的步骤。它们也会出现在测试运行器的错误信息中。对于频繁使用的 fixtures,这可能会造成大量干扰。你可以通过"盒装"方式来阻止这些 fixtures 步骤在 UI 中显示。
import { test as base } from '@playwright/test';
export const test = base.extend({
helperFixture: [async ({}, use, testInfo) => {
// ...
}, { box: true }],
});
这对于不重要的辅助 fixtures 非常有用。例如,一个自动设置某些通用数据的 fixture 可以安全地从测试报告中隐藏。
自定义 fixture 标题
你可以为 fixtures 指定一个自定义标题,而不是使用常规的 fixture 名称,这个标题会显示在测试报告和错误信息中。
import { test as base } from '@playwright/test';
export const test = base.extend({
innerFixture: [async ({}, use, testInfo) => {
// ...
}, { title: '我的 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) => {
// 这段代码会在每个测试之前运行
await page.goto('http://localhost:8000');
await use();
// 这段代码会在每个测试之后运行
console.log('Last URL:', page.url());
}, { auto: true }], // 自动为每个测试启动
});
然后在所有测试中导入这些夹具:
import { test } from './fixtures';
import { expect } from '@playwright/test';
test('basic', async ({ page }) => {
expect(page).toHaveURL('http://localhost:8000');
await page.goto('https://playwright.dev');
});
添加全局 beforeAll/afterAll 钩子
test.beforeAll() 和 test.afterAll() 钩子会在同一文件中声明的所有测试之前/之后运行,对于同一个 test.describe() 块(如果有)也是如此,每个 worker 进程运行一次。如果你想声明在每个文件中所有测试之前/之后运行的钩子,可以使用 scope: 'worker'
将它们声明为自动 fixtures,如下所示:
import { test as base } from '@playwright/test';
export const test = base.extend<{}, { forEachWorker: void }>({
forEachWorker: [async ({}, use) => {
// 这段代码会在 worker 进程中的所有测试之前运行
console.log(`Starting test worker ${test.info().workerIndex}`);
await use();
// 这段代码会在 worker 进程中的所有测试之后运行
console.log(`Stopping test worker ${test.info().workerIndex}`);
}, { scope: 'worker', auto: true }], // 自动为每个 worker 启动
});
然后在所有测试文件中导入这些 fixtures:
import { test } from './fixtures';
import { expect } from '@playwright/test';
test('basic', async ({ }) => {
// ...
});
请注意,这些 fixtures 仍然会为每个 worker 进程 运行一次,但你不需要在每个文件中重新声明它们。