Playwright 第15章:测试结构与钩子
Playwright Test 基于流行的测试框架,提供了丰富的测试结构组织和生命周期管理能力。合理地组织测试结构和运用钩子函数,可以大幅提高测试代码的可维护性、可读性和执行效率。
测试文件组织
文件结构规范
Playwright 测试通常按功能模块组织文件,推荐的文件结构如下:
tests/
auth/
login.spec.ts
register.spec.ts
logout.spec.ts
products/
product-list.spec.ts
product-detail.spec.ts
product-search.spec.ts
cart/
add-to-cart.spec.ts
checkout.spec.ts
profile/
view-profile.spec.ts
edit-profile.spec.ts
测试文件命名约定:
- 文件名以
.spec.ts或.test.ts结尾 - 文件名使用连字符分隔,全小写
- 相关测试放在同一目录下
Playwright 配置文件中的测试路径
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests', // 测试文件根目录
testMatch: '**/*.spec.ts', // 匹配模式
testIgnore: '**/fixtures/**', // 排除目录
});
describe 块
describe 用于将相关的测试用例分组,形成清晰的层级结构。
基本用法
import { test, expect } from '@playwright/test';
describe('购物车功能', () => {
test('添加商品到购物车', async ({ page }) => {
// 测试代码
});
test('从购物车移除商品', async ({ page }) => {
// 测试代码
});
test('更新商品数量', async ({ page }) => {
// 测试代码
});
});
嵌套分组
describe('用户管理', () => {
describe('注册功能', () => {
test('使用邮箱注册成功', async ({ page }) => {
// ...
});
test('使用已注册邮箱会报错', async ({ page }) => {
// ...
});
});
describe('登录功能', () => {
test('使用正确密码登录成功', async ({ page }) => {
// ...
});
test('使用错误密码登录失败', async ({ page }) => {
// ...
});
});
});
带条件的分组
// 仅在移动端环境下执行的测试
describe('移动端专属功能', () => {
test.skip(!process.env.MOBILE_TEST, '仅在移动端测试时执行');
test('触摸滑动切换页面', async ({ page }) => {
// ...
});
});
test 块
test 定义
每个 test 块定义一个独立的测试用例:
test('测试用例的标题描述', async ({ page }) => {
// 测试步骤
await page.goto('https://example.com');
await page.click('.login-button');
await page.fill('#username', 'testuser');
await page.fill('#password', 'password123');
// 断言
await expect(page.locator('.welcome-message')).toContainText('欢迎回来');
});
test 修饰符
Playwright 提供了多种 test 修饰符来控制测试行为:
test.skip('这个测试将被跳过', async ({ page }) => {
// 不会执行
});
test.fail('这个测试预期会失败', async ({ page }) => {
// 如果通过了会被标记为意外通过
await expect(page.locator('.buggy-element')).toBeVisible();
});
test.fixme('这个测试需要修复', async ({ page }) => {
// 等同于 skip,但表示需要关注
});
test.slow('这个测试比较慢', async ({ page }) => {
// 超时时间自动延长为原来的 3 倍
await page.goto('https://example.com/heavy-page');
});
条件修饰符
// 仅在 Chromium 中运行
test('Chromium 专属测试', async ({ page, browserName }) => {
test.skip(browserName !== 'chromium', '仅 Chromium 运行');
// ...
});
// 根据环境变量跳过
test('仅在 CI 中执行', async ({ page }) => {
test.skip(!process.env.CI, '仅在 CI 环境下执行');
// ...
});
// 带描述的条件跳过
test.skip(
process.env.ENVIRONMENT === 'production',
'生产环境跳过此测试'
);
标签与注解
import { test, expect } from '@playwright/test';
test('结账流程测试', {
tag: '@smoke',
annotation: {
type: 'issue',
description: 'https://github.com/example/issue/123',
},
}, async ({ page }) => {
// ...
});
// 使用标签过滤运行:npx playwright test --grep @smoke
测试钩子
测试钩子(Hooks)负责在特定时机执行准备和清理工作,是保持测试独立性和可靠性的关键。
beforeAll / afterAll
beforeAll 在作用域内所有测试执行之前运行一次,afterAll 在之后运行一次:
import { test, expect, Browser, BrowserContext, Page } from '@playwright/test';
import { chromium } from '@playwright/test';
let browser: Browser;
let context: BrowserContext;
let page: Page;
beforeAll(async () => {
// 启动浏览器(所有测试共享)
browser = await chromium.launch();
console.log('浏览器已启动');
});
afterAll(async () => {
// 关闭浏览器
await browser.close();
console.log('浏览器已关闭');
});
test('第一个测试', async () => {
context = await browser.newContext();
page = await context.newPage();
await page.goto('https://example.com');
// ...
});
test('第二个测试', async () => {
context = await browser.newContext();
page = await context.newPage();
await page.goto('https://example.com/about');
// ...
});
beforeEach / afterEach
beforeEach 在每个测试执行之前运行,afterEach 在每个测试执行之后运行:
import { test, expect } from '@playwright/test';
let page: Page;
beforeEach(async ({ browser }) => {
// 每个测试前创建新的页面
const context = await browser.newContext();
page = await context.newPage();
await page.goto('https://example.com');
});
afterEach(async () => {
// 每个测试后清理
await page.close();
});
test('页面标题正确', async () => {
await expect(page).toHaveTitle(/Example/);
});
test('搜索功能正常', async () => {
await page.fill('#search', '测试内容');
await page.press('#search', 'Enter');
await expect(page.locator('.search-results')).toBeVisible();
});
完整的钩子生命周期
| 钩子 | 执行时机 | 典型用途 |
|---|---|---|
beforeAll | 所有测试前,执行一次 | 启动浏览器、初始化数据库、设置全局测试数据 |
beforeEach | 每个测试前,执行多次 | 导航到初始页面、创建测试数据、登录用户 |
afterEach | 每个测试后,执行多次 | 清理测试数据、截图失败用例、清除缓存 |
afterAll | 所有测试后,执行一次 | 关闭浏览器、断开数据库连接、生成测试报告 |
钩子的作用域
Playwright 中的钩子作用于所在的 describe 块:
describe('外层分组', () => {
beforeAll(async () => {
console.log('1 - 外层 beforeAll');
});
beforeEach(async () => {
console.log('2 - 外层 beforeEach');
});
afterEach(async () => {
console.log('3 - 外层 afterEach');
});
afterAll(async () => {
console.log('4 - 外层 afterAll');
});
test('外层测试 A', async ({ page }) => {
console.log('5 - 外层测试 A');
});
describe('内层分组', () => {
beforeAll(async () => {
console.log('6 - 内层 beforeAll');
});
beforeEach(async () => {
console.log('7 - 内层 beforeEach');
});
afterEach(async () => {
console.log('8 - 内层 afterEach');
});
afterAll(async () => {
console.log('9 - 内层 afterAll');
});
test('内层测试 B', async ({ page }) => {
console.log('10 - 内层测试 B');
});
});
});
执行顺序:
1 - 外层 beforeAll
2 - 外层 beforeEach
5 - 外层测试 A
3 - 外层 afterEach
2 - 外层 beforeEach
6 - 内层 beforeAll
7 - 内层 beforeEach
10 - 内层测试 B
8 - 内层 afterEach
3 - 外层 afterEach
9 - 内层 afterAll
4 - 外层 afterAll
测试隔离
测试隔离是保证测试可靠性的核心原则——每个测试不应依赖其他测试的执行结果。
通过 Fixture 实现隔离
import { test as base, expect } from '@playwright/test';
// 自定义 fixture 为每个测试创建独立数据
const test = base.extend<{ testUser: { username: string; password: string } }>({
testUser: async ({}, use) => {
// 创建独立用户
const user = {
username: `user_${Date.now()}`,
password: 'testpass123',
};
await registerUser(user);
await use(user);
// 清理
await deleteUser(user.username);
},
});
test('用户可以使用个人信息', async ({ page, testUser }) => {
await login(page, testUser.username, testUser.password);
await page.goto('/profile');
await expect(page.locator('.username')).toHaveText(testUser.username);
});
页面隔离
test.describe('每个测试使用独立上下文', () => {
// 方式一:测试接收的 page 是自动隔离的
test('测试 A', async ({ page }) => {
// 每个测试都有自己的 page
await page.goto('/page-a');
});
test('测试 B', async ({ page }) => {
// page 与测试 A 的 page 完全隔离
await page.goto('/page-b');
});
});
状态清理策略
afterEach(async ({ page, context }) => {
// 清除 localStorage
await page.evaluate(() => localStorage.clear());
// 清除 Cookies
await context.clearCookies();
// 清除浏览器缓存
await context.clearPermissions();
});
自定义 Fixture
Fixture 是 Playwright 测试的依赖注入机制。通过自定义 fixture,可以复用测试准备逻辑:
import { test as base, expect } from '@playwright/test';
// 定义自定义 fixture
type MyFixtures = {
authenticatedPage: any;
testData: Record<string, any>;
};
const test = base.extend<MyFixtures>({
// 登录并返回已验证的页面
authenticatedPage: async ({ browser }, use) => {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://example.com/login');
await page.fill('#username', 'admin');
await page.fill('#password', 'admin123');
await page.click('#login-button');
await page.waitForURL('**/dashboard');
await use(page);
await context.close();
},
// 准备测试数据
testData: async ({}, use) => {
const data = {
productName: '测试商品',
productPrice: 99.99,
quantity: 1,
};
await use(data);
},
});
// 使用自定义 fixture
test('已登录用户可以查看商品详情', async ({ authenticatedPage, testData }) => {
const page = authenticatedPage;
await page.goto(`/products/${testData.productName}`);
await expect(page.locator('.product-name')).toHaveText(testData.productName);
await expect(page.locator('.product-price')).toContainText(String(testData.productPrice));
});
test('已登录用户可以添加商品到购物车', async ({ authenticatedPage, testData }) => {
const page = authenticatedPage;
await page.goto(`/products/${testData.productName}`);
await page.click('.add-to-cart');
await expect(page.locator('.cart-count')).toContainText('1');
});
Fixture 的作用域
| 作用域 | 说明 | 生命周期 |
|---|---|---|
test | 每个测试独享 | 测试开始创建,测试结束销毁 |
worker | 每个 Worker 进程共享 | Worker 启动创建,Worker 结束销毁 |
const test = base.extend({
// worker 作用域——所有测试共享一个数据库连接
dbConnection: [async ({}, use) => {
const db = await createDatabaseConnection();
await use(db);
await db.close();
}, { scope: 'worker' }],
});
测试超时控制
合理配置超时可以防止测试无限等待,同时避免因网络波动导致的误报。
全局超时配置
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
// 每个测试的超时时间(毫秒)
timeout: 30000,
// 断言的超时时间(毫秒)
expect: {
timeout: 10000,
},
// 全局超时
globalTimeout: 3600000, // 1 小时
});
单测试超时
// 单独设置超时
test('加载大数据页面', {
timeout: 120000, // 2 分钟
}, async ({ page }) => {
await page.goto('https://example.com/big-data');
await expect(page.locator('.data-loaded')).toBeVisible();
});
// 标记为慢速测试(超时自动 3 倍)
test.slow('慢速动画测试', async ({ page }) => {
await page.goto('https://example.com/animation');
await page.waitForTimeout(15000);
});
操作超时
// 单个操作设置超时
await page.click('.submit-button', { timeout: 5000 });
await page.waitForSelector('.success-message', { timeout: 10000 });
// 关闭自动等待
await page.click('.button', { force: true, timeout: 3000 });
总结
Playwright Test 的测试结构和钩子系统为组织大规模测试套件提供了坚实的基础。通过合理使用 describe 分组、test 修饰符、生命周期的钩子函数和自定义 fixture,可以构建出高可维护性、高可靠性的自动化测试体系。测试隔离确保了每个测试的独立性,灵活的超时控制则平衡了测试的稳定性和执行效率。掌握这些组织结构,是编写专业级 Playwright 测试的必备技能。