ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Puppeteer无头浏览器测试实战:从环境搭建到CI落地

2026/10/7 3:15:59 拓冰建站 浏览量
Puppeteer无头浏览器测试实战:从环境搭建到CI落地 前阵子帮朋友公司做管理后台的回归测试每次发版前都要手工点一遍新增菜单、改角色权限、再验证一遍旧流程半天就没了。后来我把这套验证逻辑全部迁到 Puppeteer 上用无头浏览器在后台自动跑完三十多个核心场景顺带把页面的性能指标和关键截图也一并收集了。这篇文章围绕 Puppeteer 无头浏览器测试的完整落地经验来写从环境搭建、核心场景实现、框架集成到 CI 适配把能抄作业的代码和踩过的坑一起放出来。适合刚开始接触前端自动化测试的开发者也适合正想把手工回归换成自动化回归的测试团队。1. 为什么无头浏览器测试要选 Puppeteer1.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 用无头模式跑本地排错时直接DEBUG1 npm run test:e2e就能打开有头模式。这个习惯救过我很多次因为很多诡异问题只有在亲眼看见浏览器行为时才能定位靠日志猜效率太低了。3. 核心测试场景的实现套路3.1 等待策略少用 sleep多用 waitFor无头浏览器测试最大的痛点不是语法而是时序。前端页面现在几乎全是异步渲染你请求完页面不代表 DOM 里已经有了目标元素。最常见的错误是刚goto完就急着去click(#login)结果元素还没渲染出来测试直接崩。正确的做法是用条件等待。Puppeteer 提供了几组非常智能的等待 APIawait 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-size1g。除此之外最小化的 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是操作不了的必须先切换到对应的 frameconst 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 让测试稳定的三个小原则经过这些项目的折腾我总结出三条让无头浏览器测试更稳定的经验。第一定位元素优先用>