
我最初接触 Puppeteer是因为要做一个多平台登录流程的回归测试。当时团队里已经有人用 Selenium 在维护一套 Java 版的端到端用例但每次跑完一轮脑子都是麻的——Chrome 弹窗乱跳、等待时间靠 sleep 硬撑、CI 上动不动就超时。换到 Puppeteer 之后我才真正体会到无头浏览器测试这几个字的分量。它没有界面占资源、启动快、API 设计贴近前端直觉一套浏览器自动化脚本几分钟就能写出来跑起来也远比想象中稳定。这篇文章就是冲着最佳实践四个字来的。我会把 Puppeteer 做端到端测试的完整思路、核心 API 的选型依据、真实项目的实操细节、以及我踩过的坑全部串起来讲一遍。适合两类人一类是刚接触无头浏览器测试、想用 Puppeteer 搭建第一条自动化用例的新手另一类是在 CI 里跑 Puppeteer 但频繁遇到失败、超时、进程残留想系统排查的工程师。无论哪一类你都能在这篇文章里找到可以直接抄走的配置和代码。1. Puppeteer 无头浏览器测试的首要问题为什么是它1.1 它解决的痛点先聊一个最基本的问题为什么非要引入一个浏览器来做测试因为前端项目里很多问题光靠单测和接口测试是抓不住的。比如登录成功后跳转到了哪个路由、某个按钮在用户点完之后有没有正确变成 loading 状态、弹窗组件在特定交互顺序下会不会错位、埋点事件有没有真的发出去。这类用户视角的问题只有让真实浏览器去执行一遍页面代码才能验证。而无头浏览器是把原本需要弹出窗口的 Chrome 变成后台运行的进程内存占用更小、执行效率更高、适合批量并发。Puppeteer 之所以是这类测试里的首选靠的是三个核心能力直接控制真实 Chromium 内核它操作的是 ChromiumChrome 的开源版页面里跑的就是标准浏览器环境不存在模拟器失真问题。事件驱动 可等待机制不用 sleep 硬等可以编程式地等待某个元素出现、某个请求返回、某个函数执行完毕。截屏、追踪、性能分析开箱即用测试挂了你不能只给我一条报错截图和历史记录是快速定位问题的基础设施。1.2 适合做什么不适合做什么我在团队里接手这套技术之后给 Puppeteer 划定了一个非常明确的职责边界这能帮你省掉很多后续纠结适合核心用户流程的回归测试——登录、下单、支付、导出这类高价值路径。重复性 UI 验证——布局、权限控制、状态切换。截图对比与视觉回归——虽然它不是专门的像素对比工具但配合pixelmatch之类的库可以快速实现。数据采集与页面扫描——批量抓取页面数据、DOM 结构体检、性能指标采样。不适合大规模接口测试——那是 Postman、Jest、pytest 的领域。单元测试——Vitest、Jest 跑纯函数和组件快得多。需要多浏览器兼容矩阵的场景——那需要 Playwright 或者接 BrowserStack 云测。这个边界想清楚了后续写用例的方向就不会跑偏。2. 环境搭建与核心 API 的实践理解2.1 安装与启动参数先讲安装。Puppeteer 在 npm 包安装时会默认下载对应版本的 Chromium这一步如果你的网络环境对外访问不畅很容易失败。建议安装时设置镜像变量npm install puppeteer --save-dev如果你的服务器拉不动默认下载源可以这样指定镜像PUPPETEER_DOWNLOAD_BASE_URLhttps://npmmirror.com/mirrors/puppeteer npm install puppeteer --save-dev注这是基于常用加速镜像的操作方式实际地址以团队内网允许的源为准。启动参数是整个实践中最需要注意的入口。我列举一份我线上 CI 中使用的启动配置const browser await puppeteer.launch({ headless: true, args: [ --no-sandbox, --disable-setuid-sandbox, --disable-dev-shm-usage, --disable-gpu, --window-size1280,960 ], defaultViewport: { width: 1280, height: 960 }, timeout: 30000 });这里每个参数都有明确目的--no-sandbox和--disable-setuid-sandbox在 Linux 服务器和大多数容器环境中Chrome 沙箱依赖系统内核能力权限不足会直接启动失败。必须在受控环境中使用别照搬到不安全场景。--disable-dev-shm-usage容器默认/dev/shm只有 64MB大型页面渲染很容易耗尽共享内存加上这个参数改为使用临时目录。--disable-gpu在无显卡的机器上避免 GPU 进程报错。--window-size与defaultViewport两者要尽量保持一致。否则可能出现窗口 800px视口 1280px导致的截图异常。2.2 等待策略选型Puppeteer 测试最核心的理念是能等待就别计时。我曾接手过一版用setTimeout(5s)等元素出现的代码本地跑没问题上 CI 之后就变得时灵时不灵最后把全部setTimeout换成显式等待才稳定下来。三类等待机制的使用时机场景推荐方式为什么页面跳转page.goto(url, { waitUntil: networkidle0 })网络空闲后再继续减少外部请求干扰元素渲染page.waitForSelector(#submit, { visible: true })明确告诉浏览器我看到这个按钮才算完成动态计算条件page.waitForFunction(window.appReady true)适用于框架初始化、自研状态变化在实际过程中waitUntil的四个值需要按原理选。load等window.load事件domcontentloaded等 DOM 解析完networkidle0表示至少 500ms 内没有新的网络连接networkidle2允许少量长期连接存在。对于绝大多数 SPA 页面签名部署和异步接口返回的节奏不一用networkidle0会偏保守但更稳定用domcontentloaded速度快但容易遗漏动态组件。我在页面内部组件相对固定、外部资源较少时会倾向networkidle2。2.3 选择器策略选择器是 Puppeteer 脚本里最容易被写坏的部分。在真实项目中DOM 结构隔三岔五就会变动className 被压缩、层级变深CSS 选择器说失效就失效。我的实践原则是优先使用>await page.click([data-testidlogin-submit]);好处显而易见这个属性是给测试专用的前端重构不影响它只要不去手动删除就行。如果在改代码时随手改了这个属性测试用例没有对应更新就立刻报错这属于良性的、可追踪的故障。3. 实操一个登录场景的端到端测试全流程3.1 从用例设计开始写代码我会用团队真实使用的一套场景来做演示验证用户在输入正确账号密码后能否成功跳转到工作台首页并且顶部显示用户名。这个用例覆盖了输入、点击、路由跳转、DOM 断言四个关键环节。先设计两条用例正确账号密码 → 登录成功 → URL 变为/dashboard→ 用户名文本匹配。错误密码 → 登录失败 → 显示错误提示。用例不是为了把代码写完而是为了形成可以被回归的稳定行为。第二条用例有一个容易被忽略的检查项错误提示的可见性与文案准确性。在 Puppeteer 里visible: true这一选项必须一起用否则即使元素存在于 DOM用户其实看不见断言仍然会通过。3.2 完整脚本实现下面是一段完整的、可以直接跑通的测试代码用的是 Jest 作为测试框架const puppeteer require(puppeteer); describe(Login Page E2E, () { let browser; let page; beforeAll(async () { browser await puppeteer.launch({ headless: true, args: [--no-sandbox, --disable-dev-shm-usage] }); }); afterAll(async () { await browser.close(); }); beforeEach(async () { page await browser.newPage(); await page.setViewport({ width: 1280, height: 960 }); }); afterEach(async () { await page.close(); }); test(should redirect to dashboard on valid login, async () { await page.goto(http://localhost:3000/login, { waitUntil: networkidle2 }); await page.type([data-testidusername], tester01, { delay: 30 }); await page.type([data-testidpassword], secret123, { delay: 30 }); await Promise.all([ page.waitForNavigation({ waitUntil: networkidle0 }), page.click([data-testidlogin-submit]) ]); expect(page.url()).toContain(/dashboard); const displayedName await page.$eval( [data-testiddashboard-user-name], (el) el.textContent.trim() ); expect(displayedName).toBe(tester01); }); });这里有一个反复验证过的细节page.click和page.waitForNavigation需要放在Promise.all中因为点击之后页面才会开始导航如果先点击再写等待极端情况下导航已经完成等待永远不触发。反过来直接await page.click再await page.waitForNavigation可能出现导航在 click 返回之前就已经发生的竞态。3.3 断言失败时的现场保留忘掉截图就相当于测试挂了之后你只剩下一行报错。我的脚本里通常会封装一个失败截图逻辑afterEach(async () { if (testStatus.failed) { const screenshotPath ./screenshots/${Date.now()}-failure.png; await page.screenshot({ path: screenshotPath, fullPage: true }); console.error(Screenshot saved: ${screenshotPath}); } await page.close(); });实际操作中我发现有时候元素没出现、页面报错这些是你眼里能看到的问题。但最坑的一类现场是页面看起来“完全正常”但断言失败。这时候只在失败时截图不够应该在每个关键操作之后用page.screenshot生成关键步骤快照或者用page.accessibility转储无障碍树帮助判断元素是否处在可见区域。在 CI 环境下把这些文件归档成 build artifact才能让不熟悉 Puppeteer 的同事也快速定位问题。3.4 接口模拟与网络状态控制测试登录你不想让用例依赖后端真实账号体系。Puppeteer 里可以通过page.setRequestInterception和page.route拦截请求并返回 mock 数据。await page.setRequestInterception(true); page.on(request, (request) { if (request.url().includes(/api/login)) { request.respond({ status: 200, contentType: application/json, body: JSON.stringify({ token: mock-token, user: { name: tester01 } }) }); } else { request.continue(); } });使用这个方案之后测试的稳定性提升了一个量级。前端代码照常发请求但控制系统直接接管了响应不用再去数据库绑固定账号CI 跑多少遍都不会因为第三方服务波动而挂掉。唯一需要强调的是mock 响应要尽量贴近真实接口的返回结构。字段多一个少一个都没关系但如果返回结构差异过大等于测试的不是真实代码路径而是你自己造的假路径。4. 常见问题与排查技巧实录4.1 元素选择失败这是出镜率最高的问题。头部提示找不到选择器实际打开页面一看元素明明就在那里。排查思路分三步用page.waitForSelector(selector, { timeout: 10000 })替换直接page.$eval确认是否只是渲染时机问题。打开headless: false让浏览器窗口可视化运行观察脚本执行轨迹。检查选择器是否因为若多个同名词元素而导致匹配到了隐藏元素。这里我特别想分享一个经验class 选择器命名如果是构建工具压缩过的比如长链式 classname那么直接用page.$(.btn)大概率会失败。如果你无法控制前端代码改测试属性一个可行的替代方案是用 XPath 定位文本内容const [target] await page.$x(//button[contains(.,登 录)]);请注意文本中可能存在的空格XPath 里用contains(., 登 录)不一定能匹配但这个思路在绝大多数静态管理后台里是可靠的。建议用更宽松的normalize-space()处理或者干脆先获得多个候选元素再过滤。4.2 超时与资源加载不稳定waitUntil: networkidle0在某些项目里会导致 30 秒超时因为页面上有长时间轮询、WebSocket 推送网络永远不会空闲。这种情况下有两个选择把waitUntil降级为domcontentloaded后面继续等待某个关键选择器或接口返回。使用networkidle2它允许少量长连接存在不会因为一条 WebSocket 就无限等待。防止外网资源拖慢页面也是一个常用技巧。通过请求拦截把统计脚本、字体、外链图片全部拦截掉测试速度能快 30% 以上page.on(request, (request) { if ([image, stylesheet, font].includes(request.resourceType())) { request.abort(); } else { request.continue(); } });4.3 Linux 与容器环境的坑在 CI 服务器上跑 Puppeteer最常见的问题就是浏览器启动时报错。除了配置里已经提到的--no-sandbox、--disable-dev-shm-usage还有一个低频但很有迷惑性的问题是缺少系统依赖库。Puppeteer 官方文档给出了一串apt-get install依赖实际操作中如果直接用npx puppeteer browsers install chrome然后在容器里跑会碰到缺libnss3、libatk-bridge2.0-0之类的问题。最省心的做法是直接使用官方提供的 Docker 镜像构建测试环境或者在基础镜像中安装完整依赖列表。这是我踩过最多次数的暗坑环境问题一旦搞不定别的再完美也白搭。另一个坑是中文字体缺失。无头模式下截图里中文全变成方框不是代码问题是容器没装字体。安装方式很简单apt-get install -y fonts-noto-cjk安装后重新启动 Chromium 进程即可。4.4 内存泄漏与进程残留跑完测试后如果发现服务器上一堆 Chromium 僵尸进程基本可以认定是browser.close()没被调用或者脚本在异常路径里提前退出了。正确做法是把关闭放入finallylet browser; try { browser await puppeteer.launch({ headless: true }); // 测试逻辑 } finally { if (browser) { await browser.close(); } }如果是同时开多个页面跑并发务必要维护每个 page 的引用并及时page.close()。每个 tab 都是一个独立进程图层、内存、渲染资源完全分离开几十个页面不关内存必然会爆掉。我在项目里通常用--max-old-space-size4096兜底但真正解决还是靠严格的资源治理。4.5 无头模式下的时间、动画与差异问题无头浏览器虽然走的是同样内核但运行速度和动画表现与有头模式有差异。我遇到过一个非常典型的场景页面上有个入场动画播放 2 秒后才绑定事件。有头模式下肉眼可以看到动画但脚本执行快往往动画还没播完就已经去点击按钮了。这会导致点击没生效但又不报错。解决方案有两个通过page.emulateMediaFeatures设置prefers-reduced-motion: reduce期望前端代码针对无障碍模式关闭动画。在测试环境里通过全局注入强制把动画时长设为 0。await page.addStyleTag({ content: *, *::before, *::after { animation-duration: 0.001s !important; transition-duration: 0.001s !important; } });注意这个操作属于测试环境专用补丁别用在线上页面。还有一项很容易被忽略的差异是Date.now()。在某些场景下无头浏览器启动会非常快页面里用于签名的时间戳与 CI 机器系统时间差时如果前端做了服务器时间校验可能导致请求被拒。真正的解决方式是前端用服务端下发的时间而不是你给浏览器nock一个假时间。不要试图在大规模页面环境下篡改系统时间会引发更多问题。5. 工程化落地与测试框架整合5.1 并行执行策略单线程跑端到端用例在用例量上来之后会非常痛苦十几个测试依次跑可能要十分钟以上。Puppeteer 并行只需要控制好两层资源第一层是浏览器实例数量。每 launch 一个 browser就会启动一个完整的 Chromium 实例内存消耗较大。通常控制在 CPU 核心数的一半到三分之一比较安全。第二层是页面数量。在一个浏览器实例内打开多个 page各页面相互独立但共享同一个浏览器进程。用 1 个浏览器、5 个 page 跑 5 条用例效率非常高。Jest 配置里通过test.concurrent就能支持并发测试。让我提醒一句并发跑用例时每个用例都要使用独立的 page不能共享同一个 page否则 DOM 操作互相干扰定位出来的问题会让人崩溃。共享 cookie 和登录态可以但 DOM 操作绝对要隔离。如果不在乎登录态隔离可以考虑先在一个 page 里完成登录把 cookie 拿到再给其他并行用例的 page 设置这些 cookie。这能节省大量登录时间。5.2 测试报告与失败归因测试报告不只是给别人看的东西更是你自己排查问题的工具。我在实践里沉淀出一个报告归档的基本结构test-output/ ├── screenshots/ │ ├── 20240110-001-failure.png │ └── 20240110-001-step-login.png ├── traces/ │ └── 20240110-001.zip └── html/ └── index.htmlPuppeteer 自带的page.tracing.start能记录整个加载与交互过程的 trace 文件在 Chrome DevTools 里可以逐帧回放。排查为什么某一步耗时 5 秒这种性能型问题时trace 比截图有用得多await page.tracing.start({ path: trace.json, screenshots: true }); // 执行操作 await page.tracing.stop();此外把每次浏览器 console 里出现的 error 级日志收集起来也很有价值。很多页面错误并不会让测试挂掉但会污染用户体验通过监听 console 事件可以提前发现这些隐患。5.3 与 pytest、Appium 等测试体系的分工我之前看到热搜词里有人把 Puppeteer 和 pytest、Appium 放在一起比较。这三套工具面向的层次完全不同弄清楚了不混用才能选对工具/框架定位使用场景pytest后端接口、单元测试、数据校验逻辑正确性验证速度快Puppeteer浏览器端到端、视觉回归、性能采样验证浏览器交互与真实环境反馈Appium移动端 App 自动化原生/跨平台移动应用在一个成熟项目里三层测试应该是金字塔形底层大量接口测试和单测兜底中间是 Puppeteer 编写的核心流程端到端最上层是极少量的手工回归与移动端专项。Puppeteer 不需要越俎代庖去测接口pytest 也代替不了浏览器确认点击事件是否真的绑定到了按钮上。5.4 防止用例被无效变更击穿这是工程化里最隐蔽但最伤人的问题。团队里前端频繁改动页面结构但不改逻辑结果端到端测试隔三岔五就在选择器上报错。你让前端加一个>for i in $(seq 1 10); do npx jest login.test.js; done失败的规律和频率会告诉你问题到底出在时序、环境还是脚本本身。如果十遍全过基本就是 CI 机器资源不足或依赖服务不稳定了。记得在脚本开头加日志输出把第几轮循环打到终端这条看起来很笨的办法在过去一年帮我解决了好几个最棘手的偶发问题。