开篇一个让人头疼的现象如果你的 Playwright 测试套件从最初的几分钟膨胀到现在的半个多小时每次 CI 跑完都要等到天荒地老那你一定遇到过这个问题。我们团队也走过这条路。从几十个用例扩展到几百个之后测试开始变得不稳定——周一通过的测试周二突然失败本地跑得好好的用例到 CI 里随机报错。更要命的是测试越跑越慢反馈周期越来越长整个团队的交付节奏都被拖慢了。有人可能会说加机器、加 worker 不就行了但如果问题出在根子上加再多资源也只是治标不治本。今天我想聊聊一个经常被忽视的罪魁祸首——测试之间的同步依赖以及如何让测试从“排队等号”进化到“各跑各的”。问题出在哪我们为什么会写出同步依赖的测试先说一个场景你看看熟不熟悉。有一个用户管理系统的测试套件包含创建用户、登录用户、更新资料、删除用户这几个测试。最初可能是这样写的// ❌ 反面示例存在隐藏依赖 test(创建新用户, async ({ page }) { await page.goto(/register); await page.fill(#email, testexample.com); await page.fill(#password, password123); await page.click(#submit); }); test(登录用户, async ({ page }) { await page.goto(/login); // 这里假设 testexample.com 用户已经存在 await page.fill(#email, testexample.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 } fromplaywright/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 } fromplaywright/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, adminexample.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 --shard1/4 npx playwright test --shard2/4 npx playwright test --shard3/4 npx playwright test --shard4/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 分钟。不是靠堆机器而是靠把测试从“排队等号”改成了“各跑各的”。当每个测试都独立、自治、不依赖别人的时候跑得快就是水到渠成的事。