开篇:一个让人头疼的现象
如果你的 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 之后,分片会更均匀,因为拆分粒度从文件级别降到了测试级别。
还有几个容易忽略的坑
- 硬等待是隐形杀手
很多人习惯了用 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;
硬等待不仅慢,而且不稳定——网络快了浪费时间,网络慢了照样失败。
- Trace 和视频不要全程开
Trace 和视频在调试时非常有用,但如果全程开启会严重拖慢测试。建议配置成只在失败时记录:
// playwright.config.ts
export default defineConfig({
use: {
trace: 'on-first-retry', // 重试时才记录 trace
video: 'on-first-retry', // 重试时才录视频
},
});
- 用 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 分钟。不是靠堆机器,而是靠把测试从“排队等号”改成了“各跑各的”。当每个测试都独立、自治、不依赖别人的时候,跑得快就是水到渠成的事。