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 测试的必备技能。