Fixtures

简介

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, firefoxwebkit

request

APIRequestContext

当前测试运行的隔离 API 请求上下文实例。

没有夹具

传统的测试环境设置与基于夹具的设置的区别如下:

TodoPage 是一个帮助与 “待办事项” 页面交互的类,遵循了页面对象模型(【Page Object Model】)模式。它内部使用了 Playwright 的 page

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 钩子有许多优点:

  1. 封装设置和清理:Fixtures 将设置和清理代码放在同一个地方,因此更容易编写。

  2. 可重用性:Fixtures 可以在多个测试文件中重用——你可以一次定义它们并在所有测试中使用。这就是 Playwright 内置的 page fixture 的工作方式。

  3. 按需加载:你可以定义任意数量的 fixture,而 Playwright Test 会根据你的测试需求,仅设置所需的 fixture,其他的不会被设置。

  4. 可组合性:Fixtures 可以相互依赖,从而提供复杂的行为。

  5. 灵活性:测试可以根据需要使用不同组合的 fixtures,精确地调整所需的环境,而不影响其他测试。

  6. 简化分组:你不再需要将测试包裹在设置环境的 describe 块中,可以根据测试的意义自由地分组。

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,它们遵循 【页面对象模型】。

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();
    }
  }
}

设置页面相似:

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';

自定义的 fixture 名称应以字母或下划线开头,且只能包含字母、数字和下划线。

使用夹具

在测试函数中只需提到夹具,测试运行器将自动处理它们。夹具在钩子和其他夹具中也可用。如果使用 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']);
});

重写夹具

除了创建自己的夹具,还可以根据需求重写现有的夹具。例如,重写 page 夹具,使其在每次测试之前自动导航到一个 baseURL

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

Fixtures 还可以被覆盖,其中基础 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 使用 【worker 进程】来运行测试文件。与每个测试运行单独设置测试 fixtures 类似,worker fixtures 是为每个 worker 进程设置的。在这里,你可以设置服务、运行服务器等。Playwright Test 会尽可能重用 worker 进程,只要它们的 worker fixtures 匹配,环境就可以保持一致。

下面我们将创建一个账户 fixture,该 fixture 会在同一 worker 中共享,并覆盖 page fixture,以便每个测试都登录到这个账户。为了生成唯一的账户,我们将使用 【workerInfo.workerIndex】,这是任何测试或 fixture 都可以访问的。注意 worker fixture 的元组语法——我们需要传递 {scope: 'worker'},这样测试运行器就会为每个 worker 设置这个 fixture。

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';

自动夹具

自动 fixtures 会为每个 test/worker 设置,即使测试没有直接列出它们。要创建一个自动 fixture,可以使用元组语法并传递 { auto: true }

下面是一个示例 fixture,当测试失败时,它会自动附加调试日志,以便我们稍后在报告中查看日志。注意它如何使用 【TestInfo】 对象,该对象在每个 test/fixture 中可用,用于获取有关正在运行的测试的元数据。

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';

夹具超时

默认情况下,fixture 与测试共享超时。然而,对于较慢的 fixture,特别是 【worker 范围】的 fixture,拥有单独的超时是很方便的。这样,你可以保持整体测试的超时较小,同时给较慢的 fixture 更多的时间。

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" 类型的 fixture 来使配置选项具有声明性并进行类型检查。了解更多关于 【测试参数化】 的内容。

下面,我们将创建一个 defaultItem 选项,并结合其他示例中的 todoPage fixture。这个选项将在配置文件中设置。注意,元组语法和 { option: true } 参数。

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 fixture,并在配置文件中设置 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,
});

执行顺序

每个 fixture 都有设置和拆解阶段,这两个阶段通过 fixture 中的 await use() 调用分隔。设置阶段在 fixture 被测试/钩子使用之前执行,而拆解阶段在 fixture 不再被测试/钩子使用时执行。

fixtures 按照以下规则来确定执行顺序:

  • 当 fixture A 依赖于 fixture B 时:B 总是在 A 之前设置,并在 A 之后拆解。

  • 非自动 fixtures 会懒加载,仅在测试/钩子需要它们时才会执行。

  • 测试范围的 fixtures 在每个测试后会被拆解,而工作者范围的 fixtures 只有在执行测试的工作者进程关闭时才会被拆解。

考虑以下示例:

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 设置,因为自动工作者 fixture 总是在其他所有 fixture 之前设置。

    • 执行 beforeAll

  • 第一个测试阶段:

    • autoTestFixture 设置,因为自动测试 fixture 总是在测试和 beforeEach 钩子之前设置。

    • page 设置,因为它是 beforeEach 钩子所需的。

    • 执行 beforeEach

    • 执行第一个测试。

    • 执行 afterEach

    • page 拆解,因为它是测试范围的 fixture,应该在测试结束后拆解。

    • autoTestFixture 拆解,因为它是测试范围的 fixture,应该在测试结束后拆解。

  • 第二个测试阶段:

    • autoTestFixture 设置,因为自动测试 fixture 总是在测试和 beforeEach 钩子之前设置。

    • page 设置,因为它是 beforeEach 钩子所需的。

    • 执行 beforeEach

    • workerFixture 设置,因为它是 testFixture 所需的,testFixture 又是第二个测试所需的。

    • testFixture 设置,因为它是第二个测试所需的。

    • 执行第二个测试。

    • 执行 afterEach。

    • testFixture 拆解,因为它是测试范围的 fixture,应该在测试结束后拆解。

    • page 拆解,因为它是测试范围的 fixture,应该在测试结束后拆解。

    • autoTestFixture 拆解,因为它是测试范围的 fixture,应该在测试结束后拆解。

  • afterAll 和工作者拆解阶段:

    • 执行 afterAll

    • workerFixture 拆解,因为它是工作者范围的 fixture,应该在工作者关闭时拆解一次。

    • autoWorkerFixture 拆解,因为它是工作者范围的 fixture,应该在工作者关闭时拆解一次。

    • browser 拆解,因为它是工作者范围的 fixture,应该在工作者关闭时拆解一次。

几个观察点:

  • pageautoTestFixture 会为每个测试设置和拆解,因为它们是测试范围的 fixtures。

  • unusedFixture 永远不会被设置,因为它没有被任何测试/钩子使用。

  • testFixture 依赖于 workerFixture,并触发它的设置。

  • workerFixture 会在第二个测试之前懒加载设置,但在工作者关闭时拆解一次,因为它是工作者范围的 fixture。

  • autoWorkerFixture 会在 beforeAll 钩子中设置,但 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 }) => {
  // use database and a11y fixtures.
});

Box Fixtures

通常,自定义 fixtures 会在 UI 模式、Trace Viewer 和各种测试报告中作为单独的步骤显示。它们还会出现在测试运行器的错误消息中。对于经常使用的 fixtures,这可能会导致很多噪音。你可以通过 “框住”(boxing)它们来阻止在 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('http://localhost: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('http://localhost:8000');
  await page.goto('https://playwright.dev');
});

添加全局的 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 ({ }) => {
  // ...
});

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