名称: playwright-fixtures-and-hooks 用户可调用: false 描述: 在使用可重用Playwright夹具和生命周期钩子管理测试状态和基础设施时使用,以实现高效的测试设置和拆卸。 允许工具:
- Bash
- Read
- Write
- Edit
Playwright 夹具与钩子
掌握Playwright的夹具系统和生命周期钩子,以创建可重用的测试基础设施,管理测试状态,并构建可维护的测试套件。 此技能涵盖内置夹具、自定义夹具以及测试设置和拆卸的最佳实践。
内置夹具
核心夹具
import { test, expect } from '@playwright/test';
test('使用内置夹具', async ({
page, // 页面实例
context, // 浏览器上下文
browser, // 浏览器实例
request, // API请求上下文
}) => {
// 每个测试获取新的页面和上下文
await page.goto('https://example.com');
await expect(page).toHaveTitle(/Example/);
});
页面夹具
test('页面夹具示例', async ({ page }) => {
// 导航
await page.goto('https://example.com');
// 交互
await page.getByRole('button', { name: '点击我' }).click();
// 等待
await page.waitForLoadState('networkidle');
// 评估
const title = await page.title();
expect(title).toBe('Example Domain');
});
上下文夹具
test('上下文夹具示例', async ({ context, page }) => {
// 添加Cookie
await context.addCookies([
{
name: 'session',
value: 'abc123',
domain: 'example.com',
path: '/',
},
]);
// 设置权限
await context.grantPermissions(['geolocation']);
// 在同一上下文中创建额外页面
const page2 = await context.newPage();
await page2.goto('https://example.com');
// 两个页面共享Cookie和存储
await page.goto('https://example.com');
});
浏览器夹具
test('浏览器夹具示例', async ({ browser }) => {
// 使用选项创建自定义上下文
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
locale: 'en-US',
timezoneId: 'America/New_York',
permissions: ['geolocation'],
});
const page = await context.newPage();
await page.goto('https://example.com');
await context.close();
});
请求夹具
test('使用请求夹具进行API测试', async ({ request }) => {
// 发起GET请求
const response = await request.get('https://api.example.com/users');
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
const users = await response.json();
expect(users).toHaveLength(10);
// 发起POST请求
const createResponse = await request.post('https://api.example.com/users', {
data: {
name: 'John Doe',
email: 'john@example.com',
},
});
expect(createResponse.ok()).toBeTruthy();
});
自定义夹具
基本自定义夹具
// fixtures/base-fixtures.ts
import { test as base } from '@playwright/test';
type MyFixtures = {
timestamp: string;
};
export const test = base.extend<MyFixtures>({
timestamp: async ({}, use) => {
const timestamp = new Date().toISOString();
await use(timestamp);
},
});
export { expect } from '@playwright/test';
// tests/example.spec.ts
import { test, expect } from '../fixtures/base-fixtures';
test('使用自定义时间戳夹具', async ({ timestamp, page }) => {
console.log(`测试开始于: ${timestamp}`);
await page.goto('https://example.com');
});
带有设置和拆卸的夹具
import { test as base } from '@playwright/test';
type DatabaseFixtures = {
database: Database;
};
export const test = base.extend<DatabaseFixtures>({
database: async ({}, use) => {
// 设置: 创建数据库连接
const db = await createDatabaseConnection();
console.log('数据库已连接');
// 提供夹具给测试
await use(db);
// 拆卸: 关闭数据库连接
await db.close();
console.log('数据库已关闭');
},
});
夹具作用域: 测试与工作者
import { test as base } from '@playwright/test';
type TestScopedFixtures = {
uniqueId: string;
};
type WorkerScopedFixtures = {
apiToken: string;
};
export const test = base.extend<TestScopedFixtures, WorkerScopedFixtures>({
// 测试作用域: 为每个测试创建
uniqueId: async ({}, use) => {
const id = `test-${Date.now()}-${Math.random()}`;
await use(id);
},
// 工作者作用域: 每个工作者创建一次
apiToken: [
async ({}, use) => {
const token = await generateApiToken();
await use(token);
await revokeApiToken(token);
},
{ scope: 'worker' },
],
});
认证夹具
认证用户夹具
// fixtures/auth-fixtures.ts
import { test as base } from '@playwright/test';
type AuthFixtures = {
authenticatedPage: Page;
};
export const test = base.extend<AuthFixtures>({
authenticatedPage: async ({ browser }, use) => {
// 创建带认证的新上下文
const context = await browser.newContext({
storageState: 'auth.json',
});
const page = await context.newPage();
await use(page);
await context.close();
},
});
export { expect } from '@playwright/test';
多用户角色
// fixtures/multi-user-fixtures.ts
import { test as base } from '@playwright/test';
type UserFixtures = {
adminPage: Page;
userPage: Page;
guestPage: Page;
};
export const test = base.extend<UserFixtures>({
adminPage: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: 'auth/admin.json',
});
const page = await context.newPage();
await use(page);
await context.close();
},
userPage: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: 'auth/user.json',
});
const page = await context.newPage();
await use(page);
await context.close();
},
guestPage: async ({ browser }, use) => {
const context = await browser.newContext();
const page = await context.newPage();
await use(page);
await context.close();
},
});
认证设置
// auth/setup.ts
import { test as setup } from '@playwright/test';
setup('作为管理员认证', async ({ page }) => {
await page.goto('https://example.com/login');
await page.getByLabel('Email').fill('admin@example.com');
await page.getByLabel('Password').fill('admin123');
await page.getByRole('button', { name: '登录' }).click();
await page.waitForURL('**/dashboard');
await page.context().storageState({ path: 'auth/admin.json' });
});
setup('作为用户认证', async ({ page }) => {
await page.goto('https://example.com/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('user123');
await page.getByRole('button', { name: '登录' }).click();
await page.waitForURL('**/dashboard');
await page.context().storageState({ path: 'auth/user.json' });
});
数据库夹具
测试数据库夹具
// fixtures/database-fixtures.ts
import { test as base } from '@playwright/test';
import { PrismaClient } from '@prisma/client';
type DatabaseFixtures = {
db: PrismaClient;
cleanDb: void;
};
export const test = base.extend<DatabaseFixtures>({
db: [
async ({}, use) => {
const db = new PrismaClient();
await use(db);
await db.$disconnect();
},
{ scope: 'worker' },
],
cleanDb: async ({ db }, use) => {
// 测试前清理数据库
await db.user.deleteMany();
await db.product.deleteMany();
await db.order.deleteMany();
await use();
// 测试后清理数据库
await db.user.deleteMany();
await db.product.deleteMany();
await db.order.deleteMany();
},
});
种子数据夹具
// fixtures/seed-fixtures.ts
import { test as base } from './database-fixtures';
type SeedFixtures = {
testUser: User;
testProducts: Product[];
};
export const test = base.extend<SeedFixtures>({
testUser: async ({ db, cleanDb }, use) => {
const user = await db.user.create({
data: {
email: 'test@example.com',
name: '测试用户',
password: 'hashedpassword',
},
});
await use(user);
},
testProducts: async ({ db, cleanDb }, use) => {
const products = await db.product.createMany({
data: [
{ name: '产品 1', price: 10.99 },
{ name: '产品 2', price: 20.99 },
{ name: '产品 3', price: 30.99 },
],
});
const allProducts = await db.product.findMany();
await use(allProducts);
},
});
API模拟夹具
模拟API夹具
// fixtures/mock-api-fixtures.ts
import { test as base } from '@playwright/test';
type MockApiFixtures = {
mockApi: void;
};
export const test = base.extend<MockApiFixtures>({
mockApi: async ({ page }, use) => {
// 模拟API响应
await page.route('**/api/users', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: '用户 1' },
{ id: 2, name: '用户 2' },
]),
});
});
await page.route('**/api/products', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: '产品 1', price: 10 },
{ id: 2, name: '产品 2', price: 20 },
]),
});
});
await use();
// 清理: 取消所有路由
await page.unrouteAll();
},
});
条件模拟
// fixtures/conditional-mock-fixtures.ts
import { test as base } from '@playwright/test';
type ConditionalMockFixtures = {
mockFailedApi: void;
mockSlowApi: void;
};
export const test = base.extend<ConditionalMockFixtures>({
mockFailedApi: async ({ page }, use) => {
await page.route('**/api/**', async (route) => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: '内部服务器错误' }),
});
});
await use();
await page.unrouteAll();
},
mockSlowApi: async ({ page }, use) => {
await page.route('**/api/**', async (route) => {
// 模拟慢速网络
await new Promise((resolve) => setTimeout(resolve, 3000));
await route.continue();
});
await use();
await page.unrouteAll();
},
});
生命周期钩子
测试钩子
import { test, expect } from '@playwright/test';
test.describe('用户管理', () => {
test.beforeAll(async () => {
// 在此描述块中的所有测试之前运行一次
console.log('设置测试套件');
});
test.beforeEach(async ({ page }) => {
// 每个测试之前运行
await page.goto('https://example.com');
console.log('测试开始');
});
test.afterEach(async ({ page }, testInfo) => {
// 每个测试之后运行
console.log(`测试 ${testInfo.status}: ${testInfo.title}`);
if (testInfo.status !== testInfo.expectedStatus) {
// 测试失败 - 捕获额外调试信息
const screenshot = await page.screenshot();
await testInfo.attach('失败截图', {
body: screenshot,
contentType: 'image/png',
});
}
});
test.afterAll(async () => {
// 在此描述块中的所有测试之后运行一次
console.log('清理测试套件');
});
test('测试 1', async ({ page }) => {
// 测试实现
});
test('测试 2', async ({ page }) => {
// 测试实现
});
});
嵌套钩子
test.describe('父套件', () => {
test.beforeEach(async ({ page }) => {
console.log('父beforeEach');
await page.goto('https://example.com');
});
test.describe('子套件 1', () => {
test.beforeEach(async ({ page }) => {
console.log('子 1 beforeEach');
await page.getByRole('link', { name: '产品' }).click();
});
test('子套件 1 中的测试', async ({ page }) => {
// 先运行父beforeEach,然后子beforeEach
});
});
test.describe('子套件 2', () => {
test.beforeEach(async ({ page }) => {
console.log('子 2 beforeEach');
await page.getByRole('link', { name: '关于' }).click();
});
test('子套件 2 中的测试', async ({ page }) => {
// 先运行父beforeEach,然后子beforeEach
});
});
});
条件钩子
test.describe('功能测试', () => {
test.beforeEach(async ({ page, browserName }) => {
// 跳过Firefox的设置
if (browserName === 'firefox') {
test.skip();
}
await page.goto('https://example.com');
});
test.afterEach(async ({ page }, testInfo) => {
// 仅对失败测试运行拆卸
if (testInfo.status === 'failed') {
await page.screenshot({ path: `failure-${testInfo.title}.png` });
}
});
test('功能测试', async ({ page }) => {
// 测试实现
});
});
夹具依赖
依赖夹具
// fixtures/dependent-fixtures.ts
import { test as base } from '@playwright/test';
type DependentFixtures = {
config: Config;
apiClient: ApiClient;
authenticatedClient: ApiClient;
};
export const test = base.extend<DependentFixtures>({
// 基础夹具
config: async ({}, use) => {
const config = {
apiUrl: process.env.API_URL || 'http://localhost:3000',
timeout: 30000,
};
await use(config);
},
// 依赖于config
apiClient: async ({ config }, use) => {
const client = new ApiClient(config.apiUrl, config.timeout);
await use(client);
},
// 依赖于apiClient
authenticatedClient: async ({ apiClient }, use) => {
const token = await apiClient.login('user@example.com', 'password');
apiClient.setAuthToken(token);
await use(apiClient);
},
});
组合多个夹具
// fixtures/combined-fixtures.ts
import { test as base } from '@playwright/test';
type CombinedFixtures = {
setupComplete: void;
};
export const test = base.extend<CombinedFixtures>({
setupComplete: async (
{ page, db, mockApi, testUser },
use
) => {
// 所有依赖夹具都已初始化
await page.goto('https://example.com');
await page.context().addCookies([
{
name: 'userId',
value: testUser.id.toString(),
domain: 'example.com',
path: '/',
},
]);
await use();
},
});
高级夹具模式
工厂夹具
// fixtures/factory-fixtures.ts
import { test as base } from '@playwright/test';
type FactoryFixtures = {
createUser: (data: Partial<User>) => Promise<User>;
createProduct: (data: Partial<Product>) => Promise<Product>;
};
export const test = base.extend<FactoryFixtures>({
createUser: async ({ db }, use) => {
const users: User[] = [];
const createUser = async (data: Partial<User>) => {
const user = await db.user.create({
data: {
email: data.email || `user-${Date.now()}@example.com`,
name: data.name || '测试用户',
password: data.password || 'password123',
...data,
},
});
users.push(user);
return user;
};
await use(createUser);
// 清理: 删除所有创建的用户
for (const user of users) {
await db.user.delete({ where: { id: user.id } });
}
},
createProduct: async ({ db }, use) => {
const products: Product[] = [];
const createProduct = async (data: Partial<Product>) => {
const product = await db.product.create({
data: {
name: data.name || `产品 ${Date.now()}`,
price: data.price || 9.99,
description: data.description || '测试产品',
...data,
},
});
products.push(product);
return product;
};
await use(createProduct);
// 清理: 删除所有创建的产品
for (const product of products) {
await db.product.delete({ where: { id: product.id } });
}
},
});
选项夹具
// fixtures/option-fixtures.ts
import { test as base } from '@playwright/test';
type OptionsFixtures = {
slowNetwork: boolean;
};
export const test = base.extend<OptionsFixtures>({
slowNetwork: [false, { option: true }],
page: async ({ page, slowNetwork }, use) => {
if (slowNetwork) {
await page.route('**/*', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
await route.continue();
});
}
await use(page);
},
});
// tests/slow-network.spec.ts
import { test, expect } from '../fixtures/option-fixtures';
test('慢速网络测试', async ({ page }) => {
test.use({ slowNetwork: true });
await page.goto('https://example.com');
// 由于网络限制,这将很慢
});
test('正常网络测试', async ({ page }) => {
await page.goto('https://example.com');
// 正常速度
});
测试信息和附件
使用测试信息
test('使用测试信息的示例', async ({ page }, testInfo) => {
console.log(`测试标题: ${testInfo.title}`);
console.log(`项目: ${testInfo.project.name}`);
console.log(`重试: ${testInfo.retry}`);
await page.goto('https://example.com');
// 附加截图
const screenshot = await page.screenshot();
await testInfo.attach('页面截图', {
body: screenshot,
contentType: 'image/png',
});
// 附加JSON数据
await testInfo.attach('测试数据', {
body: JSON.stringify({ foo: 'bar' }),
contentType: 'application/json',
});
// 附加文本
await testInfo.attach('备注', {
body: '测试成功完成',
contentType: 'text/plain',
});
});
条件测试执行
test('浏览器特定测试', async ({ page, browserName }) => {
test.skip(browserName === 'webkit', 'Safari中不支持');
await page.goto('https://example.com');
// 测试仅在Chromium和Firefox中运行
});
test('慢速测试', async ({ page }) => {
test.slow(); // 将此测试的超时时间增加三倍
await page.goto('https://slow-site.example.com');
// 长时间运行的操作
});
test('预期失败', async ({ page }) => {
test.fail(); // 标记为预期失败
await page.goto('https://example.com');
await expect(page.getByText('不存在的')).toBeVisible();
});
夹具最佳实践
组织夹具
fixtures/
├── index.ts # 导出所有夹具
├── auth-fixtures.ts # 认证夹具
├── database-fixtures.ts # 数据库夹具
├── mock-api-fixtures.ts # API模拟夹具
└── page-fixtures.ts # 页面相关夹具
// fixtures/index.ts
import { test as authTest } from './auth-fixtures';
import { test as dbTest } from './database-fixtures';
import { test as mockTest } from './mock-api-fixtures';
export const test = authTest.extend(dbTest.fixtures).extend(mockTest.fixtures);
export { expect } from '@playwright/test';
夹具命名约定
// 好的命名
export const test = base.extend({
authenticatedPage: async ({}, use) => { /* ... */ },
testUser: async ({}, use) => { /* ... */ },
mockApi: async ({}, use) => { /* ... */ },
});
// 避免
export const test = base.extend({
page2: async ({}, use) => { /* ... */ }, // 不具描述性
data: async ({}, use) => { /* ... */ }, // 太通用
fixture1: async ({}, use) => { /* ... */ }, // 无意义名称
});
何时使用此技能
- 设置可重用的测试基础设施
- 跨测试管理认证状态
- 创建数据库种子和清理逻辑
- 为测试实现API模拟
- 构建测试数据生成的工厂夹具
- 建立测试生命周期模式
- 创建性能优化的工作者作用域夹具
- 组织复杂的测试设置和拆卸
- 实现条件测试行为
- 构建类型安全的夹具系统
资源
- Playwright夹具: https://playwright.dev/docs/test-fixtures
- Playwright测试钩子: https://playwright.dev/docs/test-hooks
- Playwright API测试: https://playwright.dev/docs/api-testing