返回首页

Playwright:为 1000 个测试提供 DI 和隔离

本文描述了运行 1000+ 个 Playwright 测试的架构:POM 中的 getters 以避免 state traps,通过 fixtures 与 mergeTests 实现 DI,使用 Faker 和 factories 的 RUN_ID seeding 实现 data isolation。适合中高级开发者。

Playwright 中 1000 个并行测试无失败
Advertisement 728x90

规模化Playwright测试:从页面对象到依赖注入与数据隔离

在动态渲染的单页应用中,页面对象的构造函数因异步性而带来风险。尝试通过立即执行函数表达式在构造函数中初始化状态会导致竞态条件:测试在数据准备就绪前访问它们,这在持续集成并行运行时尤为明显。

// 错误:在构造函数中初始化状态
constructor(page: Page) {
  (async () => {
    this.initialCount = await page.locator('.items').count();
  })();
}

标准的同步定位器初始化同样不够理想——对象在创建时捕获的是DOM的快照。使用getter通过延迟绑定解决问题:

// 正确:使用getter获取当前DOM
get submitButton() {
  return this.page.getByRole('button', { name: '提交订单' });
}

这确保了无状态行为:每次调用都看到当前的DOM,而不受初始状态束缚。

Google AdInline article slot

通过Playwright Fixtures实现依赖注入

在测试中直接创建对象(new LoginPage(page))会使重构复杂化:更改构造函数需要在项目中多处编辑。Fixtures充当依赖注入容器,并带有实例缓存功能。

export const test = base.extend({
  cartPage: async ({ page }, use) => {
    await use(new CartPage(page));
  },
  checkoutFlow: async ({ cartPage, checkoutPage }, use) => {
    await use(new CheckoutFlow(cartPage, checkoutPage));
  }
});

test('提交订单', async ({ authFlow, checkoutFlow }) => {
  await authFlow.loginAs(user);
  await checkoutFlow.submitOrder();
});

测试专注于规范,独立于实现细节。

扩展Fixtures:mergeTests与命名空间

当拥有20个以上的fixtures时,单一文件变得难以管理。按领域拆分,使用mergeTests

Google AdInline article slot
// auth.fixtures.ts
export const authTest = base.extend({ ... });

// fixtures.ts
import { mergeTests } from '@playwright/test';
export const test = mergeTests(authTest, cartTest);

通过命名空间避免命名冲突:

export const test = base.extend({
  auth: async ({ page }, use) => {
    await use({ admin: new Admin(page), user: new User(page) });
  }
});

test('管理员可以登录', async ({ auth }) => {
  await auth.admin.loginAs();
});

并行运行中的数据隔离

workerIndex在持续集成分片场景下不足。使用组合方法:

  • RUN_ID 来自CI环境(例如GITHUB_RUN_ID)
  • testId——测试路径的哈希值
  • repeatEachIndex 用于重试

用这个复合键为Faker设置种子以确保可复现性:

Google AdInline article slot
export function seedFaker(testInfo: TestInfo) {
  const RUN_ID = process.env.RUN_ID || 'local';
  const seed = hashCode(`${testInfo.testId}-${RUN_ID}`);
  faker.seed(seed);
  return faker;
}

export const test = base.extend({
  faker: async ({}, use, testInfo) => {
    await use(seedFaker(testInfo));
  }
});

本地运行结合CI-RUN_ID可以复现失败时的精确数据。

数据层:工厂、覆盖、数据集

  • Faker:为非关键字段生成随机数据(如姓名、邮箱)
  • 工厂:提供带默认值的结构化数据
  • 覆盖:在测试中修改关键数据
  • 数据集:重复出现的业务场景
export function createUser(overrides?: Partial<User>, f = faker): User {
  return {
    id: f.string.uuid(),
    name: f.person.fullName(),
    role: 'customer',
    ...overrides
  };
}

export const VIP_USER = { role: 'vip', discount: 0.15 };

测试示例:const vipUser = createUser({ ...VIP_USER }, faker);

步骤语义与ESLint防护

使用@Step将技术性点击与业务逻辑分离:

@Step('提交订单 #${orderId}')
async submitOrder(orderId: string) {
  await this.checkoutPage.fillDetails(orderId);
  await this.checkoutPage.submit();
}

业务步骤(如“登录”)比技术步骤(如“点击登录按钮”)更能适应UI变化。

ESLint禁止直接使用new PageObject

{
  "no-restricted-syntax": ["error", {
    "selector": "NewExpression[callee.name=/.*Page$/]",
    "message": "请使用fixtures而非new"
  }]
}

关键要点

  • 页面对象模型中的getter确保获取当前DOM,避免状态陷阱
  • Fixtures作为依赖注入,在1000+测试中最小化重构成本
  • RUN_ID + testId为Faker设置种子,确保CI分片中的稳定性
  • 工厂 + 数据集隔离数据,避免冗余代码
  • 命名空间fixtures防止mergeTests中的冲突

— Editorial Team

Advertisement 728x90

继续阅读