☰
Puppeteer无头浏览器测试实战:从环境搭建到CI落地
2026/10/7 3:15:55 网站建设 项目流程

前阵子帮朋友公司做管理后台的回归测试,每次发版前都要手工点一遍,新增菜单、改角色权限、再验证一遍旧流程,半天就没了。后来我把这套验证逻辑全部迁到 Puppeteer 上,用无头浏览器在后台自动跑完三十多个核心场景,顺带把页面的性能指标和关键截图也一并收集了。这篇文章围绕 Puppeteer 无头浏览器测试的完整落地经验来写,从环境搭建、核心场景实现、框架集成到 CI 适配,把能抄作业的代码和踩过的坑一起放出来。适合刚开始接触前端自动化测试的开发者,也适合正想把手工回归换成自动化回归的测试团队。

1. 为什么无头浏览器测试要选 Puppeteer

1.1 从手工回归到自动化测试的契机

很多团队对 E2E 测试望而却步,是因为早期 Selenium 时代留下的印象太差了:环境要装 JDK、要下载 WebDriver 驱动、还要维护一个 selenium-server,跑起来又慢又不稳定。我最初也一直用"接口测试 + 关键页面冒烟"来糊弄,直到某次线上出了一个大 bug——列表页在特定角色权限下竟然渲染崩溃,而接口测试完全发现不了。那一刻我才意识到,凡是涉及真实浏览器渲染、异步加载、用户交互的回归验证,必须要有一层真刀真枪的浏览器级测试兜底。

Puppeteer 正是目前上手成本最低的方案之一。它是一个 Node.js 库,通过 Chrome DevTools Protocol 直接控制 Chromium 浏览器,不需要额外的驱动服务,装完就能跑。所谓无头浏览器,就是没有窗口界面的浏览器内核,它依然会完整执行 JavaScript、加载 CSS、渲染 DOM,和用户真实打开浏览器看到的结果几乎一致。区别只是你"看不见"而已,但该发生的渲染错误、资源加载失败、交互逻辑缺陷,一样都不会少。

1.2 Puppeteer 对比 Selenium、Playwright:选型逻辑

光说"上手成本低"还不够,我整理了一张对比表,方便你看完知道什么时候该选谁:

工具控制方式支持语言多浏览器典型优势
Selenium WebDriverWebDriver Wire ProtocolJava/Python/JS 等Chrome/Firefox/Safari/Edge语言生态老,跨浏览器能力强
PuppeteerChrome DevTools Protocol仅 Node.js主打 Chromium,也支持新 Edge轻量直接,资源拦截和 CDP 能力强
PlaywrightCDP + 自有协议JS/Java/Python/.NETChromium/Firefox/WebKit自动等待做得好,跨浏览器体验统一

我的选型逻辑很简单:如果项目明确要求覆盖 Firefox 和 Safari,直接上 Playwright;如果团队以 JavaScript 为主、只针对 Chromium 内核做验证,或者你需要在测试里深度干预网络请求、资源加载、浏览器底层行为,Puppeteer 会更顺手。我在实际项目中用 Puppeteer 还有一个私心——它的 API 风格对我的直觉很友好,page.goto、page.click、waitForSelector每个方法干什么都一目了然,团队成员上手速度明显比当年学 Selenium 时快。

2. 环境准备与启动参数选型

2.1 安装 puppeteer 的两种方式

Puppeteer 的安装有个容易踩坑的点:npm install puppeteer默认会自动下载一个对应版本的 Chromium 浏览器,整个包体积加浏览器大概一百多 MB。第一次装的时候如果网速不理想会等得比较久,在团队里还容易因为某个人安装失败导致环境不一致。

npm install puppeteer

另一种方式是安装轻量版的puppeteer-core,它不会下载浏览器,需要你自己准备一个 Chrome 或 Chromium,再通过executablePath指定路径。这个方案非常适合 CI 环境里已经预装好浏览器的场景,也能避免每次升级都重新下载一个大文件。我个人的习惯是:本地开发装完整版puppeteer图省事,CI 环境用puppeteer-core配合系统里已经装好的 Chromium,镜像构建速度能快不少。

2.2 启动浏览器的核心参数解读

启动浏览器是整个自动化测试的地基,参数选得对,后面能省掉大量头疼的问题。我整理了一份高频使用的启动配置:

const puppeteer = require('puppeteer'); async function createBrowser() { return puppeteer.launch({ headless: true, // 新版 Chromium 的无头模式 executablePath: process.env.CHROME_PATH, // puppeteer-core 必填 defaultViewport: { width: 1440, height: 900 }, // 模拟桌面分辨率 slowMo: process.env.DEBUG ? 50 : 0, // 调试时放慢操作 args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', ], }); }

这几个参数里,--no-sandbox和--disable-dev-shm-usage是 CI 和 Docker 环境里的救命稻草。前者是因为以 root 用户身份运行时 Chrome 的沙箱机制会直接报错拒绝启动,后者是因为很多 Docker 容器的/dev/shm只有 64MB,浏览器跑几个页面就容易崩溃。本地开发其实不需要加,但加上也无妨,能保证同一套代码在任何环境都跑得起来。

defaultViewport是一个非常容易被忽视的细节。默认 Puppeteer 会把视口设成 800x600,很多页面在这个尺寸下会出现响应式布局,导致你点击的按钮被折叠进菜单里,测试莫名其妙就失败了。我一般都会主动设成 1440x900 或者null,后者表示使用浏览器窗口原始尺寸,做整页截图时尤其有用。

2.3 调试时用有头模式,别硬猜

自动化脚本刚写出来的时候,最忌讳的是一失败就改代码盲猜。Puppeteer 支持headless: false的有头模式,跑的时候会真的弹出一个浏览器窗口,你能亲眼看到每一步操作发生了什么。配合slowMo参数让每一步操作延迟几十毫秒,交互过程看得清清楚楚。

我通常是先写一个环境变量开关来切换,这样平时 CI 用无头模式跑,本地排错时直接DEBUG=1 npm run test:e2e就能打开有头模式。这个习惯救过我很多次,因为很多诡异问题只有在"亲眼看见浏览器行为"时才能定位,靠日志猜效率太低了。

3. 核心测试场景的实现套路

3.1 等待策略:少用 sleep,多用 waitFor

无头浏览器测试最大的痛点不是语法,而是时序。前端页面现在几乎全是异步渲染,你请求完页面不代表 DOM 里已经有了目标元素。最常见的错误是刚goto完就急着去click('#login'),结果元素还没渲染出来,测试直接崩。

正确的做法是用条件等待。Puppeteer 提供了几组非常智能的等待 API:

await page.goto('https://your-site.com/login', { waitUntil: 'networkidle2', // 等待网络基本空闲 timeout: 15000, }); await page.waitForSelector('#login-btn', { visible: true, timeout: 10000, }); // 等某个自定义条件成立:用户名加载出来 await page.waitForFunction( () => document.querySelector('.user-name')?.textContent.includes('admin'), { timeout: 10000 } );

waitUntil的几个取值值得理解一下:load只在load事件触发后返回,domcontentloaded更快但对动态内容不可靠,networkidle0要求 500ms 内没有任何网络请求,networkidle2则允许最多 2 个连接,对带有轮询功能的页面更友好。很多测试喜欢无脑用networkidle0,但遇到有实时通知轮询的后台系统,页面永远不会"完全空闲",用networkidle2或者直接等元素才是正解。

3.2 表单流程测试:一个完整的登录回归脚本

拿登录场景举例,一套完整的流程至少要覆盖输入、点击、跳转、断言四个环节。下面这段是可以在你的项目里直接改改就能用的模板:

const puppeteer = require('puppeteer'); (async () => { const browser = await puppeteer.launch({ headless: true }); const page = await browser.newPage(); await page.goto('http://localhost:8080/login', { waitUntil: 'networkidle2' }); await page.type('#username', 'admin'); await page.type('#password', '123456'); await page.click('#submit'); await page.waitForSelector('.dashboard', { timeout: 10000 }); const userName = await page.$eval('.user-name', el => el.textContent.trim()); if (!userName.includes('admin')) { throw new Error(`登录后用户名断言失败: ${userName}`); } const token = await page.evaluate(() => localStorage.getItem('token')); if (!token) { throw new Error('登录后未写入 token'); } await page.screenshot({ path: 'reports/login-success.png' }); await browser.close(); })();

这个脚本里我故意加了 localStorage 的 token 断言,想提醒一点:E2E 测试不要只验证"页面能打开",要验证业务状态真的发生了变化。只断言 UI 元素存在是很容易被假象骗过的,比如登录失败后同一个页面依然渲染了.user-name,但你根本不知道是哪次操作写的。

3.3 接口拦截与 Mock:把测试从看服务器脸色中解放出来

无头浏览器测试最烦人的一点是受环境影响:后端联调环境不稳定、第三方接口超时、风控系统偶尔拦截请求,都能让测试"偶尔绿偶尔红"。Puppeteer 的请求拦截能力可以很好地解决这类问题,它让我能把网络层"冻住",完全模拟我想要的场景。

await page.setRequestInterception(true); page.on('request', request => { if (request.url().includes('/api/user/list')) { // 直接返回自定义假数据 request.respond({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: [{ id: 1, name: '测试用户' }] }), }); } else if (request.url().includes('/analytics')) { // 干掉统计脚本,加快测试速度 request.abort(); } else { request.continue(); } });

这个能力最典型的场景是验证前端对接口异常的处理。我可以让一个接口返回 500、超时、返回空数组,然后断言页面是否渲染了对应的错误提示或空状态。这种用例如果依赖真实后端来模拟,几乎不可能稳定复现,而在 Puppeteer 里只是改一行request.respond的事。测试的本质是控制变量,把外部依赖全部 mock 掉之后,你的测试才真正在测"前端自己的逻辑"。

3.4 截图与视觉回归:从全屏截图到像素级对比

截图是无头浏览器测试里性价比最高的功能,既能当失败现场的留证,也能做轻量级的视觉回归。基础用法很简单:

// 整页截图 await page.screenshot({ path: 'reports/full-page.png', fullPage: true, type: 'jpeg', quality: 70, }); // 截某个元素 const header = await page.$('.header'); await header.screenshot({ path: 'reports/header.png' });

真正的视觉回归需要在"截图"之上加一层对比逻辑。我通常的做法是:在代码库里维护一个baseline目录存放基准截图,测试运行时重新截取当前版本,然后用pixelmatch这类库做像素级 diff,差异超过阈值就判定失败。这里有两个非常现实的问题:一是动态区域(比如当前时间、验证码、随机商品价格)会导致每次截图都不一样,必须在对比前用固定颜色遮盖掉这些区域;二是 CSS 字体渲染在不同操作系统上有细微差异,所以基线截图最好在 CI 容器里生成,和测试运行环境保持一致。

3.5 性能与体验数据采集:不止功能,还有指标

Puppeteer 还有一个我特别常用的场景——顺手采集页面性能数据。既然浏览器都跑起来了,不把性能指标拿回来等于浪费资源。最简单的做法是通过 Performance API 拿到关键节点耗时:

const timing = await page.evaluate(() => { const nav = performance.getEntriesByType('navigation')[0]; return { domContentLoaded: nav.domContentLoadedEventEnd, load: nav.loadEventEnd, fcp: performance.getEntriesByName('first-contentful-paint')[0]?.startTime || 0, }; });

更进一步,我还会用page.tracing开启浏览器 trace,录制页面加载全过程的网络瀑布流和主线程执行情况。测试失败或者页面性能严重劣化时,这份 trace 文件可以直接拖到浏览器 DevTools 的性能面板里分析,定位是哪个脚本阻塞了渲染、哪个请求在拖慢首屏。这配合无头浏览器的自动化能力,相当于给每次发版都做了一次免费的性能巡检。

4. 与测试框架集成及 CI 落地

4.1 Jest + jest-puppeteer 快速集成

裸写 Puppeteer 脚本在用例少的时候还行,用例一多就需要断言库、测试报告、前后置钩子这些基础设施。我的标配是 Jest + jest-puppeteer,它把浏览器的启动和关闭封装成了全局变量,测试文件里直接用page和browser就行。

先装依赖:

npm install jest jest-puppeteer puppeteer -D

配置文件jest-puppeteer.config.js用来统一管理浏览器参数:

module.exports = { launch: { headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'], }, server: { command: 'npm run dev -- --port 8080', port: 8080, usedPortAction: 'ignore', }, };

server这个配置很实用,它能在跑测试前自动帮你启动本地开发服务器,测试跑完再关掉,省得每次手动起服务。有了它之后,测试文件写起来就非常简洁了:

describe('登录流程', () => { beforeAll(async () => { await page.goto('http://localhost:8080/login'); }); test('输入正确账号密码可以登录', async () => { await page.type('#username', 'admin'); await page.type('#password', '123456'); await page.click('#submit'); await page.waitForSelector('.dashboard', { timeout: 10000 }); await expect(page).toMatchElement('.user-name', { text: 'admin' }); }, 20000); afterAll(async () => { await browser.close(); }); });

4.2 并行策略与资源控制

用例多了之后,串行执行的耗时是完全不能接受的。Jest 天然支持多 worker 并行,但无头浏览器不是普通单元测试,每个 worker 都会拉起一个 Chromium 进程,四五个 worker 就能把一台 8 核开发机的 CPU 吃满。我建议并行度控制在maxWorkers: 2或3,给浏览器渲染留够资源。

并行执行有两个必须注意的坑:第一,测试用例之间不能共享状态,每个用例都要有自己独立的账号、独立的测试数据,否则两个 worker 同时操作同一条数据,必然有一个失败;第二,如果被测服务有登录限流或者接口频率限制,并行会导致服务端拒绝请求,这种情况要么把限流放开,要么改成串行。我在项目里吃过这个亏,登录接口有防刷策略,8 个 worker 一启动,一半用例都报了 429。

4.3 Docker 环境运行的两大经典报错

CI 环境里跑 Puppeteer,我遇到最多的就是下面这两个错误,基本是每个入坑的人都会碰到的:

  • Error: No usable sandbox!——容器里默认以 root 身份运行,Chromium 的沙箱起不来。解法是启动参数加--no-sandbox。
  • [ERROR:shared_memory_deposit.cc] The /dev/shm device is not large enough——Docker 默认共享内存只有 64MB。解法是启动参数加--disable-dev-shm-usage,或者构建容器时指定--shm-size=1g。

除此之外,最小化的 Linux 容器里还缺一堆 Chromium 运行所需的系统库。我的Dockerfile里一般会显式装这些:

RUN apt-get update && apt-get install -y --no-install-recommends \ libnss3 \ libatk-bridge2.0-0 \ libxkbcommon0 \ libgbm1 \ libasound2 \ fonts-noto-cjk \ && rm -rf /var/lib/apt/lists/*

里面fonts-noto-cjk是给中文字体用的,不装的话截图里的汉字会全部变成方块,视觉对比直接失去意义。

4.4 CI 中的不稳定因素应对

E2E 测试天然比单元测试容易抖动,CI 上偶发失败如果不去处理,团队很快就会对测试结果失去信任。我整理了几条非常务实的策略:

  • 失败现场留证据:在afterEach里判断用例是否失败,失败就截图并存一份页面 HTML,方便在 CI 上看不到浏览器的情况下还原现场。
  • 允许有限重试:对于网络抖动或偶发的资源加载超时,可以给用例配一到两次重试机会。Jest 新版里可以在额定的 helper 上配置jasmine.retryTimes,或者用社区的jest-circus重测能力。但要给重试定个上限,连续重试两次还挂的用例必须修,而不是无限重试掩盖问题。
  • 关键用例做标记:把下单、支付、权限变更这类高风险流程标为critical,即使部分外挂用例挂了,只要核心流程是绿的,发版依然可以被放行。

5. 常见问题与排查技巧实录

5.1 问题速查表

这一节把我在实际项目里被问到最多的问题整理成一张速查表,按"现象-原因-处理思路"的格式排列,你可以先收藏,遇到问题再回来对号入座:

常见现象可能原因优先排查动作
Timeout exceeded while waiting for selector元素未渲染、网络太慢、等待条件用错先用有头模式观察;把waitUntil换成load或networkidle2;确认元素是否在 iframe 里
No usable sandbox!容器内以 root 运行启动参数加--no-sandbox
页面访问/dev/shm不足崩溃Docker 共享内存太小加--disable-dev-shm-usage或增大--shm-size
点击元素没反应元素被遮罩层挡住或尚不可见先waitForSelector(..., { visible: true });检查是否有弹窗遮罩;用elementHandle.click()替代坐标点击
测试串行不稳定的用例并行就挂共享数据被并发修改每个 worker 分配独立账号/独立数据;必要时串行执行
页面下载文件无法获取无头模式默认不处理下载行为使用 CDP 的Page.setDownloadBehavior设置下载路径

5.2 深入聊几个高频坑

先说点击没反应的问题。这个问题看起来是 Puppeteer 的 bug,实际操作里十有八九是页面上有透明遮罩层。现在很多弹窗组件和 loading 组件在隐藏时不会立刻从 DOM 移除,而是保留一个pointer-events: none的占位层,点击事件就落到了这个占位层上。我的排查手法是点击前先执行一段document.elementFromPoint,看看目标坐标上到底是谁在接收事件,一下子就能定位到是哪个元素挡住了。

再说 iframe 内元素的处理。后台系统里经常嵌套第三方页面,跨域的 iframe 用普通page.click是操作不了的,必须先切换到对应的 frame:

const frame = page.frames().find(f => f.url().includes('third-party')); await frame.waitForSelector('.submit-btn'); await frame.click('.submit-btn');

登录态复用也是一个高频问题。一套完整流程如果每次都从登录开始,几十个用例光登录就要耗掉一大半时间。我的做法是在测试前统一执行一次登录,拿到 token 后通过page.evaluate直接写入 localStorage,或者用page.setCookie把认证 Cookie 注入到新页面,这样后续用例打开页面时就已经是登录状态了。

5.3 让测试稳定的三个小原则

经过这些项目的折腾,我总结出三条让无头浏览器测试更稳定的经验。

第一,定位元素优先用>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询