跳到主要内容

测试固件

简介

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 固件并将其提供给测试函数。

以下是您最可能经常使用的预定义固件列表:

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

不使用 fixtures 的情况

以下是传统测试风格与基于 fixtures 的测试风格在典型测试环境设置上的区别。

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');
// ...
});
});

使用夹具(Fixtures)

相比 before/after 钩子,夹具具有以下优势:

  • 封装性:夹具将设置和清理操作封装在同一位置,更易于编写。如果你有一个 after 钩子用于清理 before 钩子创建的内容,考虑将它们转换为夹具。
  • 可复用性:夹具可以在测试文件间复用——只需定义一次即可在所有测试中使用。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';

// 通过提供 "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 对象。

下面我们创建了两个遵循页面对象模型模式的夹具 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';

// 声明夹具的类型
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 将是类型安全的。

下面我们使用之前定义的 todoPagesettingsPage 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() 在测试文件中局部配置。

example.spec.ts

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'},以便测试运行器为每个工作进程设置一次此夹具。

my-test.ts
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 对象来获取正在运行的测试的元数据。

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

// 声明您的选项以类型检查配置
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选项。

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[] }>({
// 声明选项,默认值为空数组
persons: [[], { option: true }],
});

// 选项值是一个人员数组
const actualPersons = [{ name: 'Alice' }, { name: 'Bob' }];
test.use({
// 正确:将值包装到数组中并传递作用域
persons: [actualPersons, { scope: 'test' }],
});

test.use({
// 错误:直接传递数组值将不起作用
persons: actualPersons,
});

重置选项

您可以通过将选项设置为undefined来将其重置为配置文件中定义的值。考虑以下设置baseURL的配置:

playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
use: {
baseURL: 'https://playwright.dev',
},
});

现在您可以为文件配置baseURL,也可以为单个测试选择退出。

intro.spec.ts
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,请使用长格式夹具表示法。

intro.spec.ts
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,应在最后统一拆卸。

几点观察:

  • pageautoTestFixture 作为测试作用域的 fixture,会为每个测试设置和拆卸。
  • unusedFixture 从未设置,因为没有测试/钩子使用它。
  • testFixture 依赖 workerFixture 并触发其设置。
  • workerFixture 在第二个测试前延迟设置,但作为工作器作用域的 fixture,在工作器关闭时统一拆卸。
  • autoWorkerFixturebeforeAll 钩子设置,但 autoTestFixture 不会。

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

你可以将来自多个文件或模块的测试 fixtures 进行合并:

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 }) => {
// 使用 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() 块(如果有)中的测试前后运行。如果你想声明全局在每个测试前后运行的钩子,可以像这样将它们声明为自动夹具:

fixtures.ts
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 }], // 自动为每个测试启动
});

然后在所有测试中导入这些夹具:

mytest.spec.ts
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,如下所示:

fixtures.ts
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:

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

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

请注意,这些 fixtures 仍然会为每个 worker 进程 运行一次,但你不需要在每个文件中重新声明它们。