开篇:一个让人头疼的现象
如果你的 Playwright 测试套件从最初的几分钟膨胀到现在的半个多小时,每次 CI 跑完都要等到天荒地老,那你一定遇到过这个问题。
我们团队也走过这条路。从几十个用例扩展到几百个之后,测试开始变得不稳定——周一通过的测试周二突然失败,本地跑得好好的用例到 CI 里随机报错。更要命的是,测试越跑越慢,反馈周期越来越长,整个团队的交付节奏都被拖慢了。
有人可能会说:加机器、加 worker 不就行了?但如果问题出在根子上,加再多资源也只是治标不治本。今天我想聊聊一个经常被忽视的罪魁祸首——测试之间的同步依赖,以及如何让测试从“排队等号”进化到“各跑各的”。
问题出在哪:我们为什么会写出同步依赖的测试?
先说一个场景,你看看熟不熟悉。
有一个用户管理系统的测试套件,包含创建用户、登录用户、更新资料、删除用户这几个测试。最初可能是这样写的:
// ❌ 反面示例:存在隐藏依赖 test('创建新用户', async ({ page }) => { await page.goto('/register'); await page.fill('#email', 'test@example.com'); await page.fill('#password', 'password123'); await page.click('#submit'); }); test('登录用户', async ({ page }) => { await page.goto('/login'); // 这里假设 test@example.com 用户已经存在! await page.fill('#email', 'test@example.com'); await page.fill('#password', 'password123'); await page.click('#submit'); });这种写法的隐患很明显:第二个测试的成功完全依赖于第一个测试的顺利执行。如果第一个测试失败了,第二个必然跟着失败。更糟糕的是,如果 Playwright 默认并行执行,执行顺序无法保证,第二个可能在第一个之前运行,直接报错。
为什么会有人写出这样的测试?
核心原因就一个:想少写重复代码。
“登录只需要做一次,后面的测试直接用登录后的状态就行了”——这个想法本身没毛病,谁都不想每个测试都从头登录一遍。问题在于实现方式。很多人会用一个全局变量保存登录态,或者依赖某个测试先跑完来做初始化。在 Reddit 和 Playwright 的论坛上,经常能看到有人试图通过添加硬等待来让某个测试先跑。
但这种方式带来的代价是什么?测试越多,跑得越慢。
想象一下赛马比赛,如果每匹马都要等前面那匹跑完了才能出发,那每增加一匹马,总时间就增加一匹马的奔跑时间。顺序执行的测试也是这个道理。
解决方案一:用 Fixture 替代“前置测试”
Playwright 的 fixture 机制是解决这个问题的第一把钥匙。
Fixtures 默认是test作用域,每个测试都会拿到一个全新的、隔离的实例。但你也可以把它设为worker作用域,让同一个 worker 里的所有测试共享一份。
关键在于:只对只读的、昂贵的资源使用 worker 作用域。
举个例子,一个需要登录才能访问的测试套件:
// ✅ 用 fixture 管理登录态 import { test as base } from'@playwright/test'; // 定义一个 worker 作用域的 fixture const test = base.extend<{ authenticatedPage: Page }>({ authenticatedPage: async ({ browser }, use) => { const context = await browser.newContext({ storageState: 'auth.json'// 加载预先保存的登录状态 }); const page = await context.newPage(); await use(page); await context.close(); }, // 手动指定 worker 作用域 { scope: 'worker' } }); test('访问个人资料', async ({ authenticatedPage }) => { await authenticatedPage.goto('/profile'); // 直接就是登录状态,不用再登录一次 });这样一来,登录这个“昂贵”的操作只做一次,但每个测试都拿到了一个独立的 page 实例,互不干扰。既避免了重复登录浪费时间,又保证了测试之间的隔离。
解决方案二:storageState——登录态复用神器
如果只是想在测试之间共享登录状态,storageState是更直接的办法。
先跑一个 setup 脚本把登录态保存下来:
// global-setup.ts import { chromium } from'@playwright/test'; asyncfunction globalSetup() { const browser = await chromium.launch(); const context = await browser.newContext(); const page = await context.newPage(); await page.goto('/login'); await page.fill('#email', 'admin@example.com'); await page.fill('#password', 'password123'); await page.click('#submit'); // 保存登录状态到文件 await context.storageState({ path: 'auth.json' }); await browser.close(); } exportdefault globalSetup;然后在配置里加载这个状态:
// playwright.config.ts export default defineConfig({ globalSetup: './global-setup.ts', use: { storageState: 'auth.json', }, });之后每个测试启动时就已经是登录状态了。我们团队用这个方法后,每个测试平均节省了 3-5 秒的登录时间,几百个用例下来就是几十分钟的差异。
解决方案三:让测试真正“并行”起来
解决了依赖问题之后,就可以放心大胆地开启并行执行了。
Playwright 默认是以测试文件为单位并行的——不同文件跑到不同的 worker 里。配置起来很简单:
// playwright.config.ts export default defineConfig({ // CI 环境用 4 个 worker,本地用一半核心数 workers: process.env.CI ? 4 : '50%', // 开启测试级别的并行(同一个文件里的测试也并行跑) fullyParallel: true, });但这里有个前提:测试之间必须真正独立。如果测试之间共享了可变状态,开了fullyParallel: true反而会出问题。
什么样的状态是“可变”的?举个例子:
// ❌ 反面模式:模块级的可变状态 let authToken: string; let page: Page; test.beforeAll(async ({ browser }) => { const context = await browser.newContext(); page = await context.newPage(); authToken = await getAuthToken(); }); test('读取用户信息', async () => { await page.goto('/profile'); // 使用了共享的 page }); test('更新用户设置', async () => { await page.click('#settings'); // 同一个 page,状态已经被改了 });page对象在多个测试之间共享,一个测试导航走了,另一个测试可能就找不到元素了。这种问题在并行执行时会被放大,因为执行顺序不确定,失败也变得随机。
正确的做法是:每个测试用自己独立的 context 和 page。
进阶:分片(Sharding)——当单机不够用时
当测试数量继续增长,单台机器的资源终归是有限的。这时候可以考虑分片(Sharding)——把测试分摊到多台机器上跑。
# 4 台机器各跑四分之一 npx playwright test --shard=1/4 npx playwright test --shard=2/4 npx playwright test --shard=3/4 npx playwright test --shard=4/4在 CI 里可以这样配置:
# GitHub Actions jobs: test-shard: strategy: matrix: shard-index:[1,2,3,4] shard-total:[4] runs-on:ubuntu-latest steps: -run:npxplaywrighttest--shard=${{matrix.shard-index}}/${{matrix.shard-total}}开了fullyParallel: true之后,分片会更均匀,因为拆分粒度从文件级别降到了测试级别。
还有几个容易忽略的坑
1. 硬等待是隐形杀手
很多人习惯了用waitForTimeout等固定时间:
// ❌ 不管实际需要多久,都等 5 秒 await page.waitForTimeout(5000);正确的做法是等待具体的条件:
// ✅ 等元素出现 await page.locator('.data-loaded').waitFor({ state: 'visible' }); // ✅ 等 API 请求完成 const responsePromise = page.waitForResponse('/api/data'); await page.click('#load-data'); const response = await responsePromise;硬等待不仅慢,而且不稳定——网络快了浪费时间,网络慢了照样失败。
2. Trace 和视频不要全程开
Trace 和视频在调试时非常有用,但如果全程开启会严重拖慢测试。建议配置成只在失败时记录:
// playwright.config.ts export default defineConfig({ use: { trace: 'on-first-retry', // 重试时才记录 trace video: 'on-first-retry', // 重试时才录视频 }, });3. 用 API 准备数据,别用 UI
每个测试都通过 UI 去创建数据,又慢又脆弱。更好的做法是:
// ✅ 通过 API 准备测试数据 test('验证订单详情页', async ({ page }) => { // 用 API 创建订单 const order = await createOrderViaAPI({ userId: 'test-user', items: [{ id: 'product-1', quantity: 1 }] }); // UI 只做展示验证 await page.goto(`/orders/${order.id}`); await expect(page.locator('.order-status')).toHaveText('待支付'); });总结:从“排队”到“并行”的进化路径
回头看,让 Playwright 测试从越跑越慢到越跑越快,核心就三件事:
第一步,切断依赖。别让测试 A 的结果成为测试 B 的前置条件。用 fixture、用 storageState,让每个测试都能独立运行。
第二步,资源复用要克制。只对“只读的、昂贵的”资源做 worker 级别的共享。page、context 这类可变的东西,该隔离就隔离。
第三步,放心并行。把 workers 开起来,把 fullyParallel 打开,把分片用上。前提是前两步做好了。
我们团队把一个 45 分钟的测试套件优化到了 8 分钟。不是靠堆机器,而是靠把测试从“排队等号”改成了“各跑各的”。当每个测试都独立、自治、不依赖别人的时候,跑得快就是水到渠成的事。