1. 为什么用 Playwright 给 Next.js 项目做自动化测试去年年底我接手了一个跑了两年的 Next.js 后台管理系统,功能越加越多,每次发版前都要靠人工把核心流程点一遍,点得人昏头昏脑。后来我下定决心补自动化测试,选型选了一圈,最终还是用 Playwright 落地了全套端到端用例。这篇博客不聊虚的,直接把我这一轮实战里踩过的坑、验证过的写法、以及后续做性能优化和 CI 接入的经验完整记录下来,希望能帮到正在计划给 Next.js 项目接入自动化测试的人。先说结论:Playwright 这套方案特别适合 Next.js 这种前后端耦合比较紧密的 React 应用,因为 Next.js 页面的数据来源很杂,有静态生成、服务端渲染、客户端请求,测试时既要等页面结构稳定,又要处理接口数据,还要处理路由跳转和组件交互,而这些恰恰都是 Playwright 最擅长的场景。1.1 端到端自动化测试在项目里到底该放在什么位置很多团队一提到自动化测试,第一反应是「每个页面都写用例,覆盖所有按钮和跳转」。我见过不少项目是这么死的,因为测试脚本维护成本高、用例之间互相踩、跑一次十几分钟还经常误报,最后整个测试套件沦为摆设。我的思路是做取舍,不是「全量覆盖」,而是「优先覆盖核心业务链路」。端到端测试应该管的是用户真正会反复操作的关键路径,比如登录、查询列表、提交表单、导出数据,这类流程一旦出错影响最大。至于单个按钮的样式、某个表单校验文案这种细节点,该交给单测和组件测试去管,不该全部压到 Playwright 头上。而且 Next.js 有一个其他纯前端框架少有的特点:页面渲染模式不统一。有的页面是静态生成,构建时就把 HTML 输出好了;有的是服务端渲染,每次请求实时生成;还有一部分关键数据是客户端异步请求补全的。这三种情况在 Playwright 里的等待策略完全不同,这也是为什么很多人在 Next.js 项目里写测试时总觉得「元素明明存在但点击就是不稳定」的核心原因。本文后面会单独用一节来展开这部分。1.2 为什么是 Playwright 而不是其他测试工具如果只是写一个快速冒烟脚本,很多工具都能做到。但当我们考虑的是长期维护、团队协作、CI 集成,几个现实问题就浮现出来了:关键诉求实际痛点Playwright 的做法等待机制页面渲染晚于断言执行内置自动等待,元素不可交互时会重试多页面场景后台系统经常新开标签页通过 context 管理多页面,切换很方便接口联调后端没就绪没法跑前端测试支持路由拦截和 mock 返回调试排查用例挂在远程服务器上无从下手失败自动截图、录视频、保留 trace并发生效用例多了以后不能太慢天然支持多 worker 并行执行这里面的自动等待机制是最关键的。Playwright 默认会等元素「可操作」才去点击,并且会持续重试到超时为止,而不是像老式工具那样简单地 sleep 几秒钟。这一下就减少了很多「测试不稳定」的抱怨。另外,Playwright 的 webServer 配置可以直接在跑测试前自动拉起 Next.js 开发服务器,这一点对本地开发和 CI 都特别友好。Next.js 冷启动有时候会比较慢,webServer 配置里设置好超时时间,就能避免因为服务器没起来导致测试误报。2. 环境准备与第一个测试用例落地2.1 安装和最小化配置新建一个 Next.js 项目后,我习惯把测试相关的东西都放在独立目录里,不跟业务代码混在一起。先安装官方测试库:npm init playwrightlatest这个命令会引导你完成基础安装,包括下载浏览器内核、生成默认配置文件。如果项目已经存在,直接装也没有问题:npm install -D playwright/test npx playwright install chromium安装完成后,项目根目录会生成一个 playwright.config.ts,我一般会直接改成下面这样。这个配置是从实际项目中提炼出来的最小可靠版本:import { defineConfig, devices } from playwright/test; export default defineConfig({ testDir: ./e2e, timeout: 30_000, fullyParallel: true, retries: 2, reporter: [[html, { open: never }]], use: { baseURL: http://localhost:3000, trace: on-first-retry, screenshot: only-on-failure, video: retain-on-failure, }, projects: [ { name: chromium, use: { ...devices[Desktop Chrome] }, }, ], webServer: { command: npm run dev, url: http://localhost:3000, reuseExistingServer: !process.env.CI, timeout: 120_000, }, });这里几个配置项是必须理解的,否则出了问题很难排查。fullyParallel: true表示如果没有特殊声明,所有测试文件里的用例都会并行执行。并行是一把双刃剑,后面我会专门说怎么避免用例之间互相干扰。retries: 2意味着用例失败后会自动重跑最多两次。对于偶发性的网络抖动、接口超时,这个配置非常实用。但如果你把重试当成遮羞布,用例本身不稳定却不去修,那跑一次测试的时间会成倍增加。webServer是 Playwright 里非常贴心的功能。它会在测试执行前自动执行npm run dev,然后持续访问http://localhost:3000,直到返回正常响应才会开始跑用例。reuseExistingServer: !process.env.CI的意思是本地开发时如果 3000 端口已经起了服务,就直接复用,不用重复启动;CI 环境则强制用全新进程,保证环境干净。2.2 写一条最基础的首页用例环境准备好之后,第一步不需要写复杂逻辑,先把「页面能不能打开、核心内容有没有渲染」这条链路验证通:import { test, expect } from playwright/test; test(首页能正常渲染并包含核心模块入口, async ({ page }) { await page.goto(/); await expect( page.getByRole(heading, { name: 数据看板 }) ).toBeVisible(); await expect(page.locator(.summary-card)).toHaveCount(4); });很多人会纳闷,为什么 Playwright 的断言前面要加await expect(...),而不是用传统框架里的expect(...).to...同步写法。原因是 Playwright 的断言会自动重试,它会反复检查条件是否满足,直到超时。加上await之后,整个测试可以安心等待页面异步加载完成。这里我故意用了.summary-card这个类选择器,但在真正的项目里我并不推荐这种写法。类名和样式强耦合,前端重构一不小心就把测试搞挂了。更稳的做法是用>e2e/ login.spec.ts order-list.spec.ts export-task.spec.ts common/ auth-setup.ts fixtures.ts utils/ mock-data.ts这样做的好处有三个:playwright.config.ts里只需要把testDir指向./e2e,不用额外过滤;测试环境和业务代码之间的依赖边界清晰;CI 缓存时可以针对这个目录单独优化。还有一个常被忽略的点:测试文件命名一定要统一用.spec.ts后缀。默认的 testMatch 就是匹配这个规则,如果混用.test.ts或乱七八糟的名字,很容易出现「命令跑了但一个用例都没执行」的诡异情况。3. 针对 Next.js 特性写测试的几种典型场景3.1 SSR 和 SSG 页面的断言时机Next.js 页面不像纯客户端渲染那样一打开就是空壳,有些内容在 HTML 里已经有了,有些要等接口返回才能渲染。我刚开始写测试时经常犯一个错:不管什么页面,都以为页面跳转完成就可以立刻断言。在 SSG 页面里,内容在构建时就固定了,goto完成后一般马上就能看到;在 SSR 页面里,HTML 经过服务端渲染返回,大部分首屏内容也会在goto之后马上出现,但交互逻辑和数据补全仍然可能是在客户端发起的请求,所以仍然不能掉以轻心。真正容易踩坑的是「服务端渲染的初始化数据」和「客户端后续更新的数据」同时存在的情况。画个简单场景:列表页首屏由服务端渲染出前 10 条数据,用户滚动到底会自动加载下一页。如果我想测「滚动加载第二页」,直接断言列表数量就不可靠,必须先确定第一屏渲染完成,再去触发滚动操作。我的写法是优先用「可见 可交互」作为稳定信号,不要在代码里 sleep。比如:await page.goto(/orders); await expect(page.getByText(订单号, { exact: true })).toBeVisible();如果页面某个模块是动态插入的,就要等它真实出现。toBeVisible()这种断言本身有自动重试机制,比硬编码等待可靠得多。3.2 拦截接口请求,制造稳定数据Next.js 后台系统的页面大多依赖接口数据。后端接口在开发环境往往不稳定,或者某些异常场景非常难造,这时候直接连真实接口跑测试就是一个灾难。Playwright 的page.route可以拦下网络请求并替换返回结果,这是我在 Next.js 项目里用得最多的功能之一。比如测一个「列表页接口报错时展示兜底状态」的用例:await page.route(**/api/orders, async route { await route.fulfill({ status: 500, contentType: application/json, body: JSON.stringify({ message: internal error }), }); }); await page.goto(/orders); await expect(page.getByText(数据加载失败)).toBeVisible();只要请求路径里包含**/api/orders,就会被拦截并返回指定的 500 状态。这样无论后端接口现在什么状态,前端都能按照预期逻辑跑通测试。不过我要提醒一下:mock 数据会导致测试结果和真实情况有偏差。我自己的做法是「关键链路尽量用真实的测试环境接口,异常链路和特殊数据才用 mock」。如果所有用例都 mock,测试套件等于在验证一套虚构数据下的逻辑,真实接口一旦出问题,测试照样全绿,那自动化测试的价值就大打折扣了。还有一类场景是「等待某个接口返回后再做断言」。这种时候用路由拦截还不够,因为你并不想替换接口数据,只是想等这个请求完成。Playwright 提供了waitForResponse,配合按钮触发请求非常好用:const pending page.waitForResponse( res res.url().includes(/api/orders) res.status() 200 ); await page.getByRole(button, { name: 刷新列表 }).click(); const response await pending; expect(response.ok()).toBeTruthy();这里要特别注意的是waitForResponse必须写在触发操作之前。如果先点了按钮再调用等待,很可能请求已经发出去了,监听还没挂上,结果白白丢了一次等待机会。3.3 表单和组件交互的写法后台管理系统永远绕不开表单。Playwright 对表单操作的支持很完整,fill、selectOption、check这些方法基本覆盖了常用输入场景。我写表单用例时最常用的基础套路是:test(创建订单流程可以走通, async ({ page }) { await page.goto(/orders/new); await page.getByLabel(客户名称).fill(某测试客户); await page.getByLabel(订单金额).fill(12800); await page.getByRole(button, { name: 提交 }).click(); await expect(page.getByText(创建成功)).toBeVisible(); });写这类用例的时候有三个容易忽略的细节:第一个是,getByLabel依赖label标签和表单控件的关联是否完整。如果你用的是自定义组件,比如Input封装后没传id,那么 getByLabel 可能定位不到。这种情况我建议在组件上补aria-label,或者直接用>test.describe.configure({ mode: serial }); test.describe(订单列表页相关链路, () { test(进入列表页, async ({ page }) { // 用例A }); test(点击查询按钮, async ({ page }) { // 用例B,依赖用例A留下的页面状态 }); });第三层,如果不同文件之间也有数据依赖,我会尽量通过接口 mock 或者独立的测试账号隔离,而不是依赖执行顺序。依赖顺序的用例一旦增多,整个套件会变得非常脆弱,任何一次并行调度变化都可能让用例失败。4.2 选择器策略:不要被 CSS 改版误伤我是从写 xpath 的时代过来的,那会儿定位元素是真的费劲。现在 Playwright 推荐的做法已经先进很多,我强烈建议多用getByRole、getByLabel、getByTitle这类基于可访问性的查询。我把选择器的优先级整理成一个表格,方便团队里新人在写用例时对照参考:可选方式示例使用建议可访问性角色getByRole(button, { name: 提交 })优先使用,语义稳定文本定位getByText(创建成功)适合断言文案表单标签getByLabel(客户名称)适合表单输入框>npx playwright install --with-deps chromium--with-deps会自动安装浏览器运行时需要的系统库,这比一条条手动补依赖可靠得多。如果 CI 镜像里已经有浏览器,也可以考虑缓存 Playwright 的浏览器包,加速构建。缓存这步属于锦上添花,但相信我,等到你发现每次 CI 都白白花两三分钟重新下载浏览器时,一定会想加缓存的。5.2 webServer 配置在 CI 里的注意事项前面提到webServer.command用的是npm run dev。在 CI 里,生产发布后跑测试时,我通常会改成npm run start,也就是用生产模式启动服务。原因很简单:dev 模式会包含热更新和额外编译开销,在 CI 这种一次性环境里完全没必要。但也别高兴太早,npm run build的过程必须作为独立的 CI 步骤先跑完。Playwright 的 webServer 只负责启动服务,不会帮你构建产物。如果项目还没 build 就开始start,端口永远起不来,最后只能等超时。我在一个项目里就翻过这个车。当时 CI 脚本只写了npx playwright test,结果本地明明没问题,到 CI 上却总是报webServer timed out。排查了半天才发现,CI 环境里根本没有生产构建产物。加上 build 步骤之后就稳定了。5.3 报告和测试产物的处理Playwright 默认生成 HTML 报告,放在playwright-report/目录下,失败截图和 trace 默认放在test-results/。这两个目录在 CI 里不应该被忽略,最好都保留下来,作为失败时的分析材料。很多 CI 平台支持把产物目录作为「测试报告」展示。如果我们用的是 HTML 报告,可以把playwright-report/上传上去,这样 CI 页面里直接能看到完整的测试摘要和失败现场。test-results/里的 trace 文件单独保留,遇到疑难问题再下载分析。还有一个小提醒:test-results/里只会有失败用例的截图和视频,如果用例一直失败,这个目录会越来越大。可以在 CI 配置里按日期清理,或者每次构建前删掉上次的数据,免得占满空间。6. 常见问题与排查技巧实录6.1 为什么用例总是超时超时是 Playwright 新手最容易遇到的问题。我归纳下来主要有三类原因:第一,页面里有大量的客户端异步渲染,元素实际存在但短时间内不可交互,自动等待一直在重试直到超时;第二,Next.js 页面切换时路由过渡动画太长,按钮明明在页面上,却被遮罩层挡住无法点击;第三,接口请求没有返回,页面一直在 loading 状态,断言的目标元素根本没出现。排查的时候,我第一件事不是看代码,而是直接看失败截图和 trace。Playwright 在失败时会记录当时页面的 DOM 快照,用 trace 能清楚看到元素在哪里、是什么状态。如果截图里按钮被遮罩挡住,那大概率是动画或弹窗的层级问题,解决思路是在操作前等待遮罩消失,或者用更精确的选择器绕过遮挡。6.2 元素明明看得到,点击却报错这种情况其实不是「看得到就能点」的问题。Playwright 的自动点击会检查元素是否稳定、是否被遮挡、是否可接收事件。如果元素只是渲染出来了,但被固定定位的弹窗背景盖住,或者还在动画过渡中,点击动作就会被判定为不可操作。我踩过最深的坑是表格行里的「操作按钮」,它本身很小,鼠标默认点击的是元素中心点,而中心点可能被别的元素盖住了一部分。解决办法一般是click({ force: true }),但我并不推荐把force当万能药。它会跳过可操作性检查,如果元素真的被遮挡或者事件被拦截,你点了也是白点,测试还照样报通过。更稳的做法是追查遮挡的来源。要看是不是 loading 遮罩没有消失、是不是 tooltip 残留。用 trace 快照检查页面状态,定位到真正原因,比绕过检查可靠得多。6.3 截图和视频占用了大量磁盘空间如果配置了全量截图或录制所有视频,跑几十条用例之后,test-results/目录会膨胀得非常快。我一开始图省事,把所有用例都开了video: on,结果发现一次全量回归跑下来能产生好几个 GB 的数据。处理办法很简单:只在失败时保留,并且尽量只在重试后保留。我的配置是video: retain-on-failure,这样平时不会录像,失败时才能看到现场。如果确实需要对特定用例全程录像,可以单独在测试里覆盖配置:test.use({ video: on });这样只有这个文件或这个 describe 块会录视频,其他用例不受影响。6.4 测试文件多了之后维护成本怎么降下来最后聊一个被很多人忽略的问题:维护成本。测试代码写出来之后不是一劳永逸的,Next.js 版本升级、UI 组件库升级、接口字段调整,都会让测试频繁失效。我这几轮项目下来,最大的体会是测试代码也是业务代码,得认真设计结构。我习惯把公共的登录、权限准备、mock 数据都抽到common/和utils/目录里,不让用例文件又臭又长。比如登录状态复用,我会把登录逻辑封装成一个 fixture,而不是每个用例都重复填表单。Playwright 的storageState机制可以保存登录后的状态,多个项目文件共享同一个已登录 context,能把大量重复的登录步骤省掉。我自己还有个习惯:每次升级 Next.js 版本后,第一时间先跑一遍冒烟用例,而不是等项目出问题再被动去查。自动化测试的价值不只是发版前跑一遍,它更像一个持续内置的体检师,版本升级、依赖变更、接口调整都能第一时间暴露影响范围。把底子打好、把用例写得稳定,这个体系才能真正省心,而不是变成另一个需要照顾的负担。