
我最初接触 Puppeteer不是因为它文档漂亮而是被逼到了墙角当时手里的后台管理系统回归测试纯手工跑一遍要四十分钟上线前一天改了个权限逻辑差点漏掉一个影响财务导出的严重问题。那之后我真正开始系统梳理无头浏览器测试怎么做才算“最佳实践”——不是能跑通就行而是进了 CI、跑了两个月、失败率还很低、每次失败都能快速定位到根因。这篇文章我会把这两年踩过的坑和沉淀下来的套路全部摊开讲包括选型依据、环境配置、等待策略、无头模式下的隐形差异、稳定化治理以及进阶玩法。适合正在做前端自动化、测试开发、或者刚接手 Puppeteer 项目的同学按篇章顺序读基本可以少走大半年的弯路。1. 先聊清楚Puppeteer 到底适合解决什么测试问题1.1 为什么我把目光从 WebDriver 转向了 CDP 协议很多人先接触的自动化工具是 Selenium因为 WebDriver 是个 W3C 标准天然支持多浏览器。我在 2019年那会儿也用它写过不少脚本但实际体验下来有几个很扎心的问题浏览器厂商对标准的实现不完全一致一个脚本在 Firefox 上跑通了换到 Chrome 上反而崩而且为了模拟“真实用户”你必须走完整的客户端点、装 driver、启动服务链路特别长。Selenium 本质上是站在浏览器外部发指令它的操作粒度停在“窗口、元素、键盘鼠标”这一层。Puppeteer 不一样。它是直接通过 Chrome DevTools ProtocolCDP和浏览器内部机制通信你可以理解成是从浏览器后门进去和 DevTools 用同一条通道操作页面。这不仅意味着你能点击、输入、断言还能拿到网络请求的完整日志、拦截和改写任意请求、监听性能指标、甚至直接修改浏览器的设备参数。也就是说你能做的不只是“测试用户操作”而是“测试页面在某种网络、性能、设备条件下的真实表现”。如果测试重点在 Web 应用本身的行为正确性、性能表现和接口联动Puppeteer 是比 Selenium 更贴近问题本质的选择。另一个被低估的点是安装体积和生态。npm 装 puppeteer 会自动下载一个对应版本的 Chrome for Testing这意味着你不需要在机器上单独维护浏览器版本。团队里不管谁拉代码跑起来的环境几乎一致。相比 WebDriver 那套还要维护 driver 和浏览器版本对齐Puppeteer 直接省掉了这一层心智负担。1.2 Puppeteer 的短板在哪里哪些项目不该硬上但如果你问我现在会不会把所有 E2E 都换成 Puppeteer答案是否定的。它在三个场景下并不是最优解第一跨浏览器兼容测试。Puppeteer 只支持 Chromium 系内核你要真刀真枪验证 Safari 和 Firefox 的行为还是得用 Playwright 或者 WebDriver。Puppeteer 官方文档也写得很坦白它主要面向 Chromium。如果你公司产品对多浏览器有硬性要求建议直接用 Playwright它的事件模型、断言 API 比 Puppeteer 更现代还解决了多浏览器一致性问题。第二纯接口测试。如果你的对象是 REST API 或者 GraphQL根本不需要一个真实浏览器。用 Supertest、Axios 或者直接 viatest 就能跑得又快又稳。把浏览器拉起来测接口纯粹是给 CI 增加不必要的时间。第三重型并发压测。Puppeteer 每个 page 实例都会占一块真实内存并不能像 JMeter 一样轻松挂上几百个虚拟用户。你做压力测试还是要回到专业压测工具上去。我倾向于这样划分边界Puppeteer 的舒适区是“有真实页面的端到端流程验证”尤其是登录态、权限链路、关键业务表单、数据看板渲染这类带 UI 状态流转的用例。弄清楚了边界后面的技术方案才不会被工具绑架。2. 落地第一版安装、启动参数与最小冒烟脚本2.1 安装 Puppeteer 时最容易掉进去的坑先说我踩过最无语的坑在公司内网执行npm install puppeteer下载 Chromium 那一步卡了二十分钟然后报错。因为默认情况下 Puppeteer 会从 Google 的 CDN 拉浏览器内网环境经常被拦。解决办法有两条路设置镜像环境变量PUPPETEER_DOWNLOAD_BASE_URLhttps://npmmirror.com/mirrors/chromium-browser-snapshots/然后再 npm install。用puppeteer-core它不自动下载浏览器需要你手动指定一个 Chrome/Chromium 可执行路径。我一般是本地开发用 puppeteer-core 指到系统已装的 ChromeCI 里用完整 puppeteer 包加镜像下载这样既能保证开发环境不重复下浏览器又能让流水线环境自包含。还有一个容易被忽略的时点Puppeteer 版本跳级比较大比如 v22 之后默认下载的是 Chrome for Testing而非以前的 Chromium。如果你锁了版本号建议团队统一用同一个 minor 版本我在一个项目里吃过两个同事 lock 版本不一致导致waitForSelector表现不同的亏。版本不一致带来的问题通常不是立即报错而是在等待策略、默认超时时长、window 尺寸默认值这些细节上出现软差异。别问我怎么知道的排查那种偶发失败是最耗人的。2.2 一份能进 CI 的 launch 配置长什么样很多教程里给的 launch 参数是能跑但不一定能承载 CI 场景。我最终沉淀下来一份相对可靠的启动配置你可以直接抄作业const browser await puppeteer.launch({ headless: new, args: [ --no-sandbox, --disable-setuid-sandbox, --disable-dev-shm-usage, --disable-gpu, --disable-extensions, --disable-background-timer-throttling, --disable-renderer-backgrounding ], defaultViewport: { width: 1440, height: 900 } });逐个说下为什么--no-sandbox在 Docker 容器里几乎是必须的因为很多 CI 镜像不允许创建 sandbox 进程。但注意生产环境跑这个参数需要权衡安全如果你在共用机器上跑建议还是用普通用户加 sandbox 的方式。--disable-dev-shm-usage是为了绕开容器里/dev/shm默认只有 64MB 的老问题不关掉的话页面数据大一点就会崩溃。--disable-gpu在服务端没有 GPU 的情况下能减少意外--disable-background-timer-throttling和--disable-renderer-backgrounding这两条是防止浏览器对后台标签页做节流否则页面在等待阶段可能因为定时器被饿死而迟迟不出现元素。defaultViewport也值得单独说。无头模式下默认视口是 800x600这会直接影响响应式布局里哪些元素可见、哪些懒加载图片被加载。比如你要断言页面底部某个统计数字视口太小它根本不会出现在 DOM 里然后脚本就失败在莫名其妙的地方。固定 1440x900 是多数后台系统的基准分辨率比每次手动page.setViewport干净得多。2.3 先写一个能证明流程跑通的冒烟脚本有了上面配置写一个最小冒烟脚本验证链路。这个脚本的目标很简单打开登录页输入账号密码看到 dashboard 出现然后截图。const puppeteer require(puppeteer); (async () { const browser await puppeteer.launch({ headless: new, args: [--no-sandbox, --disable-dev-shm-usage] }); const page await browser.newPage(); await page.goto(https://your-app.example.com/login, { waitUntil: networkidle0, timeout: 30000 }); await page.waitForSelector(#username, { visible: true, timeout: 10000 }); await page.type(#username, test_user); await page.type(#password, test_pass_123); await page.click(button[typesubmit]); await page.waitForSelector(.dashboard-container, { visible: true, timeout: 15000 }); await page.screenshot({ path: ./smoke-dashboard.png, fullPage: true }); console.log(冒烟通过); await browser.close(); })();这段代码的价值不只是“跑通”它包含了三个我在后文会反复强调的关键习惯显式等待可见元素、给主要操作设置合理超时、失败时保留截图。waitUntil: networkidle0在这里也有讲究它表示页面 500ms 内没有超过 0 个网络连接时认为加载完成。登录页这种轻量页面用它可以后面会讲为什么不是所有页面都应该用它。如果你第一次跑这个脚本就在page.goto卡住或者 timeout先不要怀疑代码按顺序检查三件事代理和网络能不能访问目标地址、浏览器能不能正常启动可以加headless: false看窗口、目标站是否有反自动化检测。这三件事排查完百分之九十的安装问题都能解决。3. 测试脚本的核心等待、定位与页面交互不让用例随机飘3.1 显式等待优先于一切定时器无头浏览器测试里最常见也最伤人的失败原因就是“元素还没出来脚本已经去找它了”。原因很简单JS 是异步的数据是请求回来的渲染是浏览器自己安排的三者的时间线没有一个是你写await page.click那一刻可以确定的。网上很多老教程会让你page.waitForTimeout(2000)我强烈不建议——固定 sleep 只是在赌一个特定环境下的规律换个网络慢的 CI 机器它就不成立了而且它让每个用例的执行时间变成了“取最大值”而不是“取实际需要值”。我的等待策略排序是这样的优先page.waitForSelector等待某个关键 UI 元素出现或可见。其次page.waitForFunction等待页面里的 JS 状态满足条件比如某个全局变量变成true。最后才是networkidle0/networkidle2做整页加载等待但只在首屏渲染场景使用。一个典型翻车现场是前端图表类页面接口返回了图表容器也渲染了但 echarts 的 canvas 还在动画过渡中。这时waitForSelector会直接通过截图里却只有半截饼图。这种情况要配合waitForFunction去判断 canvas 里是否已绘制内容或者干脆等待一个由前端埋点提供的“渲染完成”标志变量。写到这里你应该能理解为什么很多人说 E2E 工作量的百分之六十都花在等待策略上——这句话一点不夸张。3.2 元素定位的几个隐藏点CSS 选择器是最直接的定位方式但真实业务页面永远比文档示例脏。三个高频场景我单独说Shadow DOM。现代前端组件库比如 web components 封装的内核 UI经常把内部结构罩在 shadow root 里。普通$和$$根本穿不透。Puppeteer 对 pierce 选择器的支持也不是所有版本都一样最稳妥的方式是先拿到 shadow host再通过page.evaluateHandle进去操作const host await page.$(my-custom-component); const shadowRoot await host.evaluateHandle(el el.shadowRoot); const button await shadowRoot.asElement().$(.inner-button); await button.click();iframe。如果页面内嵌了第三方支付或者企业微信扫码登录内容在 iframe 里操作前必须先切 frame。用page.frames()找到目标 frame后续所有选择器都在 frame 对象上执行。新开标签页。点击“在新标签页打开详情”这类交互不会改变当前page对象要用browser.on(targetcreated)事件捕获新 pageconst [newPage] await Promise.all([ new Promise(resolve browser.once(targetcreated, target resolve(target.page()))), page.click(.open-new-tab) ]); await newPage.waitForSelector(.detail-title);这段逻辑同样实现了“点击动作和新标签监听同时进行”避免事件错过。文本定位是另一个容易踩坑的地方。不要依赖page.$eval去精确匹配带前缀空格的文本除非你百分百确定源码格式。更稳的姿势是page.evaluate结合Array.from(document.querySelectorAll(...)).find去匹配textContent.trim()值。3.3 把网络请求主动权拿回来真实测试环境通常不能依赖后端联调尤其跑 CI 的时候外部接口不稳定直接导致用例红而问题根本不在前端。我的做法是把页面依赖的接口全部“本地接管”。具体是开启请求拦截对匹配规则的请求直接返回预设响应await page.setRequestInterception(true); page.on(request, (req) { if (req.url().includes(/api/order/list)) { req.respond({ status: 200, contentType: application/json, body: JSON.stringify({ code: 0, data: { list: [{ id: 1, status: paid, amount: 99.9 }], total: 1 } }) }); } else if (req.resourceType() image) { req.abort(); } else { req.continue(); } });这样做的收益有两个一是用例完全不依赖后端环境二是测试速度暴涨因为拦截掉的图片、字体、统计脚本这些跟业务断言无关的资源不会再浪费带宽。我在一个报表项目里这么做了以后单个页面的平均加载时间从 6 秒降到 2 秒。注意req.respond里的contentType一定不能省略否则页面可能拿到的 body 是正常字符串但 MIME 不对导致前端解析失败你会误判成页面 bug。有时候我们希望保留真实接口但给某些请求增加延时模拟慢网络下的 loading 状态。这个放到第 6 章的弱网模拟里细说。4. 无头模式藏着的坑字体、内存、并发一项项拆4.1 截图字体缺失与 FOIT无头模式跑出来的截图第一眼看上去没问题放大看就会发现文字形状不对或者干脆缺字。这在中文系统下尤其明显系统没装对应字体或者页面引用了远程字体但加载太慢浏览器在无头模式下可能没有走完字体加载生命周期就开始绘制。常见表现是截图里标题用回了 fallback 字体视觉上跟有头模式完全不一致。解决办法是在关键截图动作前强制等待字体加载完成await page.evaluate(async () { await document.fonts.ready; });如果页面使用的是内联的font-face并且在 CSS 里配置了font-display: swap靠document.fonts.ready还不够你还需要主动触发所有字体文件的加载await page.evaluate(async () { await Promise.all([...document.fonts].map(font font.load())); });另一个方案是直接把字体文件打进系统不依赖远程。我在 Linux CI 镜像里安装过思源黑体的部分字重截图稳定性提升了一大截。毕竟 CI 机器上字体缺失是最让人绝望的——本地复现不了只有流水线上失败。4.2 长时间运行的内存增长Puppeteer 跑单条用例没问题跑整套回归的时候就会慢慢变胖。我见过一整个测试套件跑完进程占用到 4GB 内存的情况。原因并不复杂每个browser.newPage()都会创建独立的渲染进程如果用例里没有显式关闭页面浏览器会一直保留历史和资源缓存。正确的做法是每条用例结束前无论成功还是失败都执行page.close()。如果用的是单个 browser 实例跑完整套用例建议在 fixture 的 teardown 里统一清理afterEach(async () { if (global.__page__) { await global.__page__.close(); } });你还可以在每个用例之间执行await browser.pages().then(pages Promise.all(pages.map(p p.close())))把意外遗留的 tab 清掉。这样写有一点副作用所有页面的登录态也被清掉了。如果用例之间有登录态保持需求单独建一条登录用例或复用 session 的逻辑会更合适。另外新版 Puppeteer 的 headless 模式内存管理比旧版好但依然不是自动的。内存泄漏问题不能只靠“跑慢点”要主动设计 tab 生命周期。4.3 同一 host 并发连接限制这个是很多人没意识到的性能瓶颈Chrome 对同一个域名默认只允许建立 6 个 TCP 连接。无头浏览器跑一个富页面时静态资源、接口请求、埋点上报全指向同一个应用域名网络层就会出现排队。页面明明很快但 Puppeteer 脚本等它“加载完”却等半天。对策有几个方向如果只是测试业务逻辑用第 3 章的拦截思路把统计类、埋点类请求直接 abort如果是为了测性能尽量让静态资源走 CDN 域名或者本地静态服务器减小主域名的连接压力并行跑多个 page 时更不要开太多指向同域名的 tab否则互相排队所有用例一起变慢。实测经验是并发页面数控制在 4 个以内比较稳超过之后收益递减且失败率上升。4.4 有头模式和无头模式的差异核对清单不是所有 bug 都能在无头模式暴露。我把遇到过的情况列成一个清单遇到可疑失败时逐项核对视口尺寸无头模式默认 800x600必须显式设置。字体渲染字体缺失可能改变文字换行进而影响断言和截图对比。GPU 加速无头模式默认关闭 WebGL部分 3D 场景或 canvas 动画不可用。后台节流浏览器可能对后台 page 做 timer throttling影响setInterval类动画。弹窗和下载无头模式的文件下载需要额外处理page.waitForEvent(download)。用户代理标识无头浏览器的 UA 里带HeadlessChrome有些页面会针对它返回不同内容。遇到这些差异的时候我建议先临时用headless: false在有头模式跑一遍很多“无头专属失败”立刻原形毕露。这也是为什么前面配置里要支持开关切换——不要让 headless 变成板上钉钉它是默认值不是唯一值。5. 从“能跑”到“靠谱”稳定化治理与 CI 集成的工程细节5.1 重试策略怎么写才不滥用我见过两种极端一种是一次失败就崩整个流水线全部红另一种是任何用例失败都整体重试十次把问题完全掩盖住。这两种都不可取。重试的正确姿势是“针对已知不稳定项重试针对确定性 bug 不重试”。Jest 里可以用jest.retryTimes(n)给整个 suite 配置重试次数。我的经验是把重试次数压在 1 到 2 次之间且必须在测试报告里明确标记哪条用例是重试后通过的。如果某个用例持续在重试后稳定通过那它在告诉你等待条件写得不对或者依赖了外部不稳定资源如果重试后还是挂再找代码问题。还有个细节不要对单个it块无限重试这样失败信息会变得极其难定位。用customRetry这种包装函数只对特定脆弱操作做 try-catch 重试可以保留失败的原始栈信息。5.2 如何在 CI 容器里跑起来Docker 里跑 Puppeteer最省心的方式是直接用官方镜像node:18-slim然后补上需要的系统库。Chrome for Testing 依赖一堆动态库少一个都会在启动时爆 “error while loading shared libraries”。我当时的解决步骤是FROM node:18-slim RUN apt-get update \ apt-get install -y \ libgtk-3-0 \ libgbm1 \ libasound2 \ libx11-xcb1 \ libxcomposite1 \ libxdamage1 \ libxrandr2 \ fonts-liberation \ fonts-noto-cjk \ rm -rf /var/lib/apt/lists/* WORKDIR /app COPY package*.json ./ RUN npm ci COPY . .libgbm1和libasound2这两个尤其容易被遗漏。如果你在 CI 里报错说缺 libgbm别愣着直接往 apt-get 里加名字就行。字体方面fonts-noto-cjk装上以后中文截图基本不用再操心。跑测试的指令我建议不要直接jest而是先做一个无头启动检查node -e const prequire(puppeteer);p.launch({args:[--no-sandbox]}).then(b{console.log(ok);return b.close()})这一步能把环境问题挡在正式测试之前。5.3 报告和截图失败时有证据而非留白测试失败不可怕可怕的是失败了你不知道页面当时长什么样。所以我把失败截图的收集当作硬性要求。具体是在afterEach里判断当前测试是否失败失败就截图并附加测试名称afterEach(async function () { const currentTest this.currentTest; if (currentTest.state failed) { const name currentTest.title.replace(/\s/g, -); await page.screenshot({ path: ./reports/${name}-${Date.now()}.png, fullPage: true }); } });如果用的是 Jest可以通过 reporter 插件或者直接在 try-catch 里截图。截图之外还可以把page.on(console)收集的浏览器日志和page.on(pageerror)收集的页面异常一并输出。浏览器控制台里的红色报错信息经常比测试框架的断言错误快得多。报告层面我建议至少输出 JUnit 格式给流水线解析再配一张简单的 HTML 报告。Jest 生态里的jest-junit和jest-html-reporter都够用不用自己造轮子。5.4 测试数据的隔离与清理自动化测试一旦开始写数据就会遇到脏数据问题。比如你反复注册同一个用户名第二次跑就失败。我的经验是把“测试数据前缀化”和“清理钩子”作为标准配置。每个用例如果需要新建数据就在测试数据上打时间戳前缀const uniqueName auto_${Date.now()}_${Math.random().toString(36).slice(2)};这样即使清理逻辑遗漏了也不会污染下一次运行。更彻底的方案是接一个数据库清理接口在套件开始和结束时执行但要注意不能把清理接口暴露到生产环境通常只开放给测试环境。还有一类做法是依赖事务回滚如果你测试环境的接口层支持 mock 数据源直接把数据库连到内存库那就一劳永逸了。6. 再往前一步视觉回归、性能跟踪与弱网模拟6.1 用截图做像素级回归功能断言只能告诉你“元素在不在”不能告诉你“页面看起来对不对”。无头浏览器截图天然适合做视觉回归。思路是第一次跑生成基准截图后续跑时对同样位置的截图做像素对比。工具选择上pixelmatch搭配pngjs是个轻量方案。你可以把整个页面的截图与基准图逐像素比对设置一个阈值const { PNG } require(pngjs); const pixelmatch require(pixelmatch); const base PNG.sync.read(fs.readFileSync(base.png)); const current PNG.sync.read(fs.readFileSync(current.png)); const diff new PNG({ width: base.width, height: base.height }); const mismatch pixelmatch(base.data, current.data, diff.data, base.width, base.height, { threshold: 0.1 }); console.log(差异像素数, mismatch); if (mismatch 1000) throw new Error(视觉回归失败);这个方案对页面里动态区域比如当前时间、随机生成的图表颜色非常敏感所以一开始建议先限制在静态页面或者某个稳定模块上不要盲目全页面开启。6.2 Performance 追踪和关键指标采集Puppeteer 的性能追踪能力是它区别于普通 UI 测试工具的一个大优势。通过 Performance 面板协议可以采集页面加载全过程的性能指标const { performance } require(perf_hooks); await page.tracing.start({ path: trace.json, categories: [devtools.timeline] }); await page.goto(url, { waitUntil: networkidle0 }); await page.tracing.stop(); const metrics await page.evaluate(() { const nav performance.getEntriesByType(navigation)[0]; return { domContentLoaded: nav.domContentLoadedEventEnd, loadComplete: nav.loadEventEnd, firstPaint: performance.getEntriesByName(first-paint)[0]?.startTime }; });这些数据很适合做成断言比如“首页 load 事件必须小于 3 秒”。但要注意 CI 机器的性能本身不稳定指标阈值要留有余量更稳妥的做法是连续跑三次取中位数而不是一次定生死。6.3 弱网模拟与慢接口复现移动端 H5 项目的测试离不开弱网场景。很多人知道 Fiddler 可以做弱网模拟但那需要你在一台机器上配置代理而且没法对单个请求精细控制。Puppeteer 的page.emulateNetworkConditions可以直接在浏览器层面模拟网络特征const session await page.target().createCDPSession(); await session.send(Network.enable); await session.send(Network.emulateNetworkConditions, { offline: false, latency: 200, downloadThroughput: 500 * 1024 / 8, uploadThroughput: 250 * 1024 / 8 });这样模拟出来的 3G 网络非常贴合真实体验。配合第 3 章讲过的 request interception你还可以只对某一个 API 加延迟其他请求保持正常用来专门复现“接口慢导致 loading 一直转”的页面问题page.on(request, (req) { if (req.url().includes(/api/slow-endpoint)) { setTimeout(() req.continue(), 3000); } else { req.continue(); } });需要注意emulateNetworkConditions对已有的连接可能不会立刻生效最好在新开页面、尚未发起请求时设置。我在第一次跑的时候也踩过这个顺序的坑设置放得太晚导致前几个请求还是全速发的。弱网断言的核心不只是“慢下来”而是验证前端在这些条件下有没有正确的 loading、超时和错误提示。你可以在注入慢接口后断言“loading文案在等待期间可见”然后等 3 秒后断言“接口返回后页面内容更新”。这比单纯慢跑一遍有价值得多。根据我个人实际操作中的体会Puppeteer 测试的收益逻辑其实很朴素前期花在等待策略和网络治理上的时间越充分后期稳定性越高。真正省时间的不是脚本写得快而是失败时你能在三分钟内判断是环境问题、数据问题还是代码问题。我现在的项目里整套回归已经能稳定跑过两个月平时大部分“红色流水线”都来自外部的测试数据污染而不是前端 bug。如果只能给你一条建议我会说先把你最痛、最常回归的那条核心链路做成冒烟用例再逐步扩大覆盖——自动化是滚雪球不是一蹴而就。下一步我打算把这套体系跟录屏回放结合让失败现场还原得再生动一点等我把细节完善好了再写一篇续篇。