1. 项目概述H5页面的自动化“体检医生”到底是什么先聊一个场景。接手过H5项目的朋友应该都有印象每次发版前产品验收要开着一堆真机挨个点开发自己在Chrome DevTools里不断切换设备模拟器测试同事对着用例清单一遍一遍刷新页面偶尔还要配合网络工具模拟2G、3G弱网环境。人一多、机器一多验收结果很难同步晚上十一点还在群里发截图追踪bug的体验做过一次就不想再碰第二回。痛点说白了就一句话H5页面跑在浏览器里但它的运行环境极度碎片化。屏幕尺寸从320px到430px甚至折叠屏都有系统浏览器内核各不相同还有断网、弱网、接口超时这些让人血压升高的边界场景。靠人肉刷新去覆盖这些组合既不体量也无法形成可信赖的回归依据。这也是我为什么建议把这个流程做成一套自动化“体检医生”每次代码变更后脚本自动把H5页面从功能、布局兼容、网络鲁棒性、接口状态、渲染性能到稳定性全部体检一遍输出一份结果报告开发只管等结果测试不用再盯着一台真机刷到手指发酸。这个组合的核心技术栈很简单TestNG负责测试框架的组织、分组、断言和数据驱动管理Playwright负责浏览器自动化把页面当成一个可以被脚本操作的“病人”。Playwright比较讨喜的地方是它对移动端H5的模拟能力强内置了常见设备的视口参数和User-Agent配置还能拦截请求、模拟网络状态写脚本时不用绕太多弯路。TestNG则在管理大量测试用例时更有条理TestMethod可以分组、可以控制执行顺序、可以设置重试还能配合监听器在用例失败时自动截图生成适合交付的报告。适合的人群我拆成三波一是维护H5页面的前端开发想给项目搭一套基础的自动化回归验证二是测试团队想做移动端H5的UI自动化和接口冒烟又不想引入太重的平台三是对现有项目有自动化需求但还没动手的技术负责人可以用这篇文章里的思路先搭个小成本原型看看价值。这套方案相比真机测试平台的优势在于轻量、免费、可控。跑在CI机器的无头浏览器里随时可以跑一轮全量体检也可以在PR阶段只跑冒烟分组。它能解决“这次改动到底有没有把页面搞挂”这个高频问题但不能替代真机上的手感交互测试和视觉走查这个边界需要先说明白。1.1 为什么是TestNG加Playwright而不是其他组合很多刚接触自动化的朋友会问我为什么不直接用JUnit或者为什么不用Selenium这里我必须说清楚技术选型背后各自的定位差异。TestNG和JUnit在功能上有重叠都能做断言、注解、执行控制、参数化和报告。但TestNG有一个对大型套件更友好的特性分组执行。我在做H5“体检”的时候体检项天然分成冒烟、兼容、鲁棒性、性能、回归这几类希望在一些场景下只跑“冒烟兼容”另一些场景全量跑。TestNG的group属性、XML套件文件的粒度配置几乎是为这个场景量身定做的。JUnit 5虽然也有标签机制但在套件配置的灵活性和失败重试的稳定性上TestNG的生态更成熟尤其是老项目沉淀下来的资料和踩坑经验更多。Playwright和Selenium的对比也类似。Selenium WebDriver的架构决定了它很多操作是先发命令、再等浏览器执行、再轮询状态测试脚本写出来节奏偏慢。Playwright的协议直接走调试通道很多操作是命令级收发的速度快一大截而且内置了自动等待机制。比如点击一个按钮Selenium经常要靠显式等待配合ExpectedConditions来规避元素没渲染完成的问题Playwright在点击前会自动等待元素可见、可交互代码量能省掉很大一块。再加上Playwright的context隔离机制每个测试用例都能开独立的全新会话互不污染这在对同一页面做不同类型体检时非常关键。1.2 一套“体检流程”的合理检测维度设计体检维度决定了这套自动化能发现哪些问题。我按自己实际用下来的经验把H5页面该查的东西归纳成八个大项。第一项是功能冒烟核心页面是否能正常打开、关键操作链路是否可走通。比如首页轮播能否滑动、登录按钮能否弹出表单、表单能否提交并跳转。这个维度不追求细节验证主要防大框架被改崩。第二项是布局兼容在不同视口宽度下检查页面是否出现横向滚动、元素被裁剪、文案重叠这类问题。常见手段是把常用手机尺寸配置成多组context检查document.scrollingElement.scrollWidth是否超过视口宽度。第三项是网络鲁棒性直接模拟断网、超时、资源加载失败看页面是否有兜底逻辑。H5页面的接口失败提示、缺省图、重试按钮都是体检重点。第四项是接口状态记录页面加载过程中发出的网络请求挑出状态码非200的请求以及耗时超过阈值的慢请求判断是否有接口级问题。第五项是性能指标重点采集首屏可交互时间、CLS累积布局偏移、FCP这类浏览器原生指标作为版本之间的对比数据。第六项是交互可靠性反复点击按钮、滑动页面、切换tab看页面是否出现僵尸按钮、弹窗卡死或JS报错。第七项是稳定性在页面上执行连续高频操作若干次模拟用户手速较快的情况看页面是否出现崩溃或白屏。第八项是环境差异用Playwright的chromium和webkit引擎分别跑一遍核心用例尽早暴露不同内核下的兼容性风险。这八个维度不是每次都必须全跑我一般把它们拆成“快速体检”和“全身体检”。快速体检只跑冒烟、布局、接口状态和性能适合PR阶段全身体检八个全跑适合发版前。这样既控制了CI资源消耗也能在关键时刻给出足够的信心。2. 环境搭建与基础框架从零构建可复用的体检工程这一章讲实操层面的工程搭建。我不建议在这个阶段引入太多抽象封装先让整套流程能跑起来跑完一轮能出报告后续再慢慢增加优雅度和扩展性。2.1 Maven依赖与项目结构规划我是用Maven搭建的Java工程本质上就是一个普通的Java项目加两个核心依赖TestNG和Playwright的Java绑定。Playwright的Java包会自动下载浏览器二进制文件到用户缓存目录所以不需要手动配置浏览器路径。考虑到项目中某些机器可能没有外网权限建议提前在CI机器上执行一次初始化或者使用镜像仓库预置浏览器二进制避免每次运行都重新下载。项目结构我习惯这样组织h5-health-checker ├── pom.xml ├── testng.xml ├── src/main/java │ └── com/example/h5check │ ├── core # 浏览器管理器、上下文工厂、等待策略 │ ├── scenarios # 体检场景执行器 │ └── reporter # 结果收集与报告输出 └── src/test/java └── com/example/h5check ├── smoke # 冒烟测试用例 ├── layout # 布局检查用例 ├── network # 网络鲁棒性用例 ├── perf # 性能指标用例 ├── stability # 稳定性用例 └── retry # TestNG重试监听器pom.xml里核心依赖大致是这样版本号以当前稳定版本为准dependency groupIdorg.testng/groupId artifactIdtestng/artifactId version7.10.2/version /dependency dependency groupIdcom.microsoft.playwright/groupId artifactIdplaywright/artifactId version1.45.0/version /dependency启动Playwright时最需要注意的是浏览器实例和Context的生命周期管理。这里我建议一个原则浏览器实例全局复用每个测试用例或者每个测试分组单开一个Context。原因在于浏览器启动和关闭是很重的操作一分钟内的测试用例反复启动浏览器会吞掉大量时间而Context却必须隔离因为不同用例的登录态、SessionStorage、Cookie不能互相污染。对应代码大致是public class BrowserManager { private static Playwright playwright; private static Browser browser; public static Playwright getPlaywright() { if (playwright null) { playwright Playwright.create(); } return playwright; } public static Browser getBrowser() { if (browser null) { Playwright pw getPlaywright(); BrowserType.LaunchOptions options new BrowserType.LaunchOptions() .setHeadless(true) .setArgs(Arrays.asList(--disable-dev-shm-usage)); browser pw.chromium().launch(options); } return browser; } public static BrowserContext newContext(String deviceName) { DeviceDescriptor device getPlaywright().devices().get(deviceName); if (device null) { device getPlaywright().devices().get(iPhone 13); } return getBrowser().newContext(new Browser.NewContextOptions() .setViewportSize(device.viewportSize().width, device.viewportSize().height) .setUserAgent(device.userAgent()) .setDeviceScaleFactor(device.deviceScaleFactor())); } }这里有个细节通过devices()拿到的DeviceDescriptor不仅仅包含视口尺寸还包含isMobile、hasTouch等移动端关键标记。真机上的H5页面和桌面浏览器最大差异并不只是窗口大小还有触摸事件、devicePixelRatio、CSS媒体查询的匹配。直接设置viewport和userAgent不如直接套用设备描述符来得全面。2.2 TestNG XML用例编排策略TestNG的XML文件是体检计划的总指挥。我的做法是把体检项全部注册到一个XML里再用分组控制执行范围!DOCTYPE suite SYSTEM https://testng.org/testng-1.0.dtd suite nameH5 体检套件 parallelclasses thread-count4 test name快速体检 groups run include namesmoke/ include namelayout/ include nameapi/ include nameperf/ /run /groups packages package namecom.example.h5check.*/ /packages /test test name全身体检 groups run include name.*/ /run /groups packages package namecom.example.h5check.*/ /packages /test /suite选用parallelclasses而不是parallelmethods是因为同一个类的测试方法往往共享初始化逻辑如果多个方法同时执行可能出现Context并发问题。而不同类之间并行则可以有效缩短整个套件的执行时间。这里的thread-count需要根据机器CPU核心数和浏览器进程限制做平衡我实际测下来4线程是比较稳妥的起点不需要一味调高。每个体检用例的TestMethod都要打上组别注解例如Test(groups {smoke, api}) public void verifyLoginFlow() { // 冒烟加接口状态的双重归类 }这样做的好处是同一段测试逻辑在“快速体检”和“全身体检”中都能被复用到不需要写两遍。TestNG的group支持正则表达式这也是为什么XML里include name.*/能直接表示全量分组。2.3 配置驱动的页面与账号管理页面URL、测试账号、超时时间这些信息不建议写死进代码里。我用一套简单的配置类管理public class EnvConfig { private static final Properties props new Properties(); static { try (InputStream is EnvConfig.class.getResourceAsStream(/env.properties)) { props.load(is); } catch (IOException e) { throw new RuntimeException(e); } } public static String baseUrl() { return props.getProperty(h5.baseUrl); } public static String stageUser() { return props.getProperty(h5.stageUser); } public static String stagePassword() { return props.getProperty(h5.stagePassword); } }env.properties示例h5.baseUrlhttps://staging.example.com/h5/ h5.stageUsertester h5.stagePasswordtest-pass h5.slowRequestThresholdMs2000配置分离的核心价值是让不同环境的体检互不干扰。我在公司内部同时跑过测试环境和预发环境的体检只是环境变量不同代码完全不用改。这里还有一个安全细节测试账号密码千万不能提交进公共仓库。即便项目是内部仓库我也建议用CI的secret机制注入系统属性或者用环境变量替换properties里的占位符避免一次误提交导致账号泄露。3. 核心诊断项实现八个维度的“体检项目”怎么做这一章是全文干货最集中的部分。我会按维度逐个拆解实现思路、关键代码片段和容易踩的坑。3.1 冒烟检查关键链路必须跑通冒烟检查不能贪多我一般只选页面最核心的几条路径。以最常见的电商H5为例我会选“首页加载完成并可见核心入口”、“搜索关键词并展示结果列表”、“搜索结果进入详情页”、“详情页触发加购按钮”、“购物车列表数量变化”这五条。具体实现里最先要处理的是“页面到底什么时候算加载完”。很多人直接用page.waitForLoadState(LoadState.NETWORKIDLE)但H5页面里经常有轮询接口、长连接或延迟加载资源NETWORKIDLE可能要等很久甚至不触发。我实践的可靠方案是等待某个可视区域的关键元素出现配合一个最长超时控制public void waitForVisible(Page page, String selector, int timeoutMs) { page.waitForSelector(selector, new Page.WaitForSelectorOptions() .setState(WaitForSelectorState.VISIBLE) .setTimeout(timeoutMs)); }这里selector的选择也有讲究不要用过于脆弱的CSS类名。H5项目里类名经过打包后经常动态变化一旦前端重构测试就挂。更稳的是使用带业务语义的data-testid属性如果没有就退而求其次用相对稳定的图片alt或按钮文本。我在推进自动化时会先和前端约定给关键节点打上稳定的data属性这对后续所有检查项都有好处。冒烟检查的执行节奏建议按用户真实操作的顺序走尽量模拟真实路径。比如搜索前先聚焦输入框再填值再按回车或者点击搜索按钮。事件触发的差异在H5里很常见有些组件监听input事件有些监听change事件fill方法处理input没问题但个别自定义组件需要逐字输入才触发。遇到这种坑可以把fill替换成pressSequentially以字母级别模拟键盘输入。3.2 布局兼容核心页面不能横向滚动H5页面最容易让前端和测试互相拉扯的bug就是布局溢出。我在体检方案中设计了一个通用检查项遍历指定页面在多个设备视口下检查页面是否存在横向滚动以及核心容器是否超出视口。实现这个检查的核心在于读取页面实际滚动宽度public void assertNoHorizontalOverflow(String deviceName, String pagePath) { try (BrowserContext context BrowserManager.newContext(deviceName)) { Page page context.newPage(); page.navigate(EnvConfig.baseUrl() pagePath); waitForVisible(page, #app, 10000); Integer scrollWidth (Integer) page.evaluate(document.documentElement.scrollWidth); Integer clientWidth (Integer) page.evaluate(document.documentElement.clientWidth); assertTrue(scrollWidth clientWidth 1, 页面出现横向滚动 pagePath scrollWidth scrollWidth clientWidth clientWidth); } }为什么用documentElement的scrollWidth和clientWidth而不是window.innerWidth因为documentElement.scrollWidth反映的是整个文档的实际渲染宽度clientWidth是可视区域的宽度。如果前者大于后者说明页面里某个元素撑破了视口用户可以向左右滑动这在移动端体验上是致命的。布局问题是“所见即所得”的因此截图和日志在这种检查里几乎是必备的。我在断言失败时会把整页截图和当前视口截图一起保存到报告目录方便开发直接看是哪一块溢出。截图的代码也很简单byte[] screenshot page.screenshot(new Page.ScreenshotOptions() .setFullPage(true) .setPath(Paths.get(target/layout-fail/ pagePath.replace(/, _) .png)));另外建议把font-size异常、文本溢出省略号缺失这类细节检查也纳入布局项不过优先级可以放低等基础横向滚动稳定了再叠加。3.3 网络鲁棒性断网和慢速网络下的兜底逻辑手机用户在地铁、电梯、地下车库的环境里网络质量是不稳定的。很多H5页面在Wi-Fi环境下表现不错可一进弱网就暴露问题接口一直转圈、错误弹窗无限循环、资源加载失败导致布局崩坏。自动化体检里模拟这种网络环境非常方便Playwright的context可以设置离线模式和额外的网络延迟。断网场景实现如下Test(groups {network}) public void verifyOfflineFallback() { try (BrowserContext context BrowserManager.newContext(iPhone 13)) { context.setOffline(true); Page page context.newPage(); page.navigate(EnvConfig.baseUrl() /home); // 等待页面上出现断网提示元素 boolean hasFallback page.locator([data-testidnetwork-error]) .waitFor(new Page.WaitForSelectorOptions() .setTimeout(5000)) ! null; assertTrue(hasFallback, 断网状态下页面没有出现降级提示); } }弱网模拟则通过设置额外的延迟和带宽限制来靠近真实场景。Playwright提供的Route接口可以给请求追加人为延迟比如让所有图片资源延迟2秒返回page.route(**/*, route - { String resourceType route.request().resourceType(); if (image.equals(resourceType) || stylesheet.equals(resourceType)) { route.continue_().thenComposeAsync(r - { return CompletableFuture.runAsync(() - { // 这里可以用Thread.sleep模拟延迟 }); }); } else { route.continue_(); } });说实话用route加延迟这个方案不够优雅Playwright官方更推荐用CDP的Network.emulateNetworkConditions来设置下行和上行带宽以及延迟。不过CDP协议依赖浏览器类型Chromium支持得最好WebKit引擎部分特性受限因此我在真实项目中还是优先用route方案因为它跨内核一致性好。延迟模拟需要稍微注意的一点是不要把所有请求都延迟应该只针对页面关键渲染路径上的图片、CSS和首屏接口避免整个页面加载时间长得失去判断意义。3.4 接口状态审计自动收集并分析页面发出的请求不少H5页面问题并不体现在UI崩溃上而是某个重要接口静默失败、某个请求耗时过长。体检方案里我会给每个Context挂一个request和response监听器把页面生命周期内所有关键请求记录下来用例结束后做统一分析。基础实现MapString, Response responses new ConcurrentHashMap(); page.onResponse(response - { if (response.url().contains(/api/)) { responses.put(response.url(), response); } }); // 执行完页面操作后统一检查 for (Map.EntryString, Response entry : responses.entrySet()) { Response resp entry.getValue(); if (resp.status() 400) { failures.add(接口返回异常: entry.getKey() - resp.status()); } }这里有一个经验别只关注非200状态码。很多接口即使返回200业务Code却是失败值例如登录接口HTTP 200但JSON里code1001表示密码错误。所以对接口的断言应该深入到响应体内容而不只是HTTP状态。Playwright的Response.text()可以拿到响应体结合解析JSON内容判断业务状态码String body resp.text(); JsonObject json JsonParser.parseString(body).getAsJsonObject(); int bizCode json.get(code).getAsInt(); if (bizCode ! 0) { failures.add(接口业务失败: entry.getKey() bizCode bizCode); }接口耗时统计也很简单记录每个接口从request到response的间隔超过阈值的标记为慢请求。阈值根据不同接口的预期有所不同首屏关键接口一般超过2秒就可以标记了非关键请求可以放宽到3秒。这里需要提醒一点在测试环境和联调环境里接口慢不代表生产慢需要结合请求的实际处理逻辑判断别把环境问题当成代码bug。3.5 性能指标采集用浏览器API衡量首屏真实体验H5性能体检我是靠浏览器的Performance Timeline接口来做的。Playwright允许在页面里执行JavaScript所以可以非常自然地获取关键性能指标MapString, Object perf (MapString, Object) page.evaluate( () { const nav performance.getEntriesByType(navigation)[0]; const paint performance.getEntriesByType(paint); const fcp paint.find(e e.name first-contentful-paint); const lcp new Promise(resolve { new PerformanceObserver(list { const entries list.getEntries(); resolve(entries[entries.length - 1].startTime); }).observe({type: largest-contentful-paint, buffered: true}); }); return { domContentLoaded: nav.domContentLoadedEventEnd, loadComplete: nav.loadEventEnd, fcp: fcp ? fcp.startTime : -1, }; }); double fcp (Double) perf.get(fcp); assertTrue(fcp 3000, 首屏绘制时间超过3秒当前 fcp);LCP因为可能是异步出现的我没法通过简单的同步evaluate获取所以上面的代码片段里我写了个半成品示意——真要拿LCP需要用Playwright的waitForFunction等待一个回调值。这个逻辑有点绕但核心思路是在页面里注册一个PerformanceObserver把LCP值暴露到全局变量然后在Node端等它出现。性能体检的价值不在于绝对数字的惊艳而在于横向对比。我在项目里把每次体检的性能数据写入一个JSON文件下次执行时读取上次数据做个简单对比一旦指标劣化超过20%就当做一个需要关注的性能回退。自动化体检的核心目标就是在改动被合并之前察觉性能变化而不是等到线上用户反馈页面慢了。3.6 交互可靠性高频点击能否暴露事件绑定问题很多H5页面的bug发生在用户“手太快”的场景快速连点提交按钮导致重复请求、频繁滑动导致列表加载抖动、弹窗未关闭就点击遮罩触发穿透。手工测试很难稳定复现这类问题自动化却可以用循环操作强行制造高频场景。一个典型的快速连点测试示例Test(groups {stability}) public void verifyRapidClickSubmit() { try (BrowserContext context BrowserManager.newContext(iPhone 13)) { Page page context.newPage(); page.navigate(EnvConfig.baseUrl() /form); waitForVisible(page, #submitBtn, 10000); // 快速连点 20 次 for (int i 0; i 20; i) { page.click(#submitBtn); } // 验证是否只发出了一个有效提交请求 long validRequests responses.stream() .filter(r - r.url().contains(/api/submit)) .filter(r - r.status() 200) .count(); assertTrue(validRequests 1, 快速连点导致重复提交请求数 validRequests); } }这个场景对前端button的防重复提交逻辑测试非常有效。很多实现里防抖只做了setTimeout式的延迟连点依然能穿透。跑完这个用例基本能知道组件的交互边界在哪也能推动前端把提交锁补齐。点击之后还有一个隐蔽问题按钮点击后是否真的有“受击感”。有些按钮绑定了事件但视觉上没有反馈点击前后className不变用户很容易误以为没点到。这种问题可以在点击后断言按钮的disabled属性或者loading文案出现boolean isLoading page.locator(#submitBtn).getAttribute(disabled) ! null || page.locator(#submitBtn).innerText().contains(提交中); assertTrue(isLoading, 点击提交后按钮未进入loading状态);3.7 稳定性压力连续操作后页面是否白屏稳定性体检的目标是发现内存泄漏、DOM节点堆积和渲染卡死。我通常的做法是让页面执行一组相对复杂的用户路径循环若干遍每遍都检查页面的关键节点是否还在、页面是否存在异常滚动高度增长。一个简易的压力脚本Test(groups {stability}) public void verifyLongSessionStability() { try (BrowserContext context BrowserManager.newContext(iPhone 13)) { Page page context.newPage(); page.navigate(EnvConfig.baseUrl() /feed); for (int i 0; i 30; i) { page.mouse().wheel(0, 1200); page.waitForTimeout(300); // 模拟随机点击信息流里的内容 ListElementHandle cards page.querySelectorAll([data-testidfeed-card]); if (!cards.isEmpty()) { int idx new Random().nextInt(cards.size()); cards.get(idx).click(); page.waitForTimeout(500); page.goBack(); } // 检查页面是否崩溃 if (page.isClosed()) { fail(页面在第 i 轮操作后崩溃关闭); } } // 检查页面是否出现白屏 int bodyTextLength (Integer) page.evaluate(document.body.innerText.length); assertTrue(bodyTextLength 0, 页面出现白屏); } }这个用例跑出来的问题通常比较真实信息流过深导致的OOM、路由反复切换后事件监听器堆积、按钮在特定状态后失去响应。不过稳定性用例的执行时间较长不适合放进每次PR的快速体检里更适合作为夜间定时任务跑。跑的时候也建议录音日志不然崩溃时拿不到精确的堆栈会很头疼。3.8 环境差异覆盖Chromium与WebKit双引擎并行H5页面的主流环境包括安卓的Chromium内核和iOS的WebKit内核两者在CSS兼容性、API支持程度上有细微差异。Playwright同时支持chromium和webkit所以我一般把核心用例分别跑一遍这两个引擎作为跨内核兼容性的基础保障。实现上BrowserContext的获取可以抽象成一个带引擎参数的工厂public static BrowserContext newContext(String engine, String deviceName) { Browser browser; switch (engine) { case webkit: browser getWebKitBrowser(); break; case chromium: default: browser getChromiumBrowser(); break; } DeviceDescriptor device getPlaywright().devices().get(deviceName); return browser.newContext(new Browser.NewContextOptions() .setViewportSize(device.viewportSize().width, device.viewportSize().height) .setUserAgent(device.userAgent()) .setIsMobile(device.isMobile()) .setHasTouch(device.hasTouch())); }这里要特别提醒一个坑尽管两个引擎对device的描述符一致但webview和浏览器环境的差异依然存在。比如部分CSS position: sticky的实现差异、部分抖音或地图SDK的兼容性差异等并没有被完全模拟。因此这个检查能防住的是“明显的不兼容”不能替代iOS Safari真机的人工验证。我一般不把这个检查当成通过/失败的标准而是作为一个提示如果双引擎体检中某项只有单引擎失败大概率可以定位到引擎兼容性问题值得人工再细查。另外一个容易被忽略的差异是WebKit对网络监听和CDP能力的支持不如Chromium全面所以涉及网络延迟模拟和接口拦截的用例如果要在WebKit上跑需要做好降级判断——要么跳过要么换一种实现方式。我在代码里通过一个capability标志控制比如context.browser().browserType().name()的返回值如果是webkit就不跑网络鲁棒性检查避免用例因为环境能力缺失而误报。4. 执行策略与报告输出让体检结果一目了然有了八个维度的体检用例接下来要解决的是执行效率、失败归因和报告可视化的问题。4.1 并行执行与浏览器实例复用策略并行执行的核心目标是在单位时间跑完更多用例。TestNG把并发粒度抽象成suite/test/class/method四级我通常不会用method级因为同一页面下多个方法同时跑容易互相干扰。浏览器实例复用能显著降低总时长。以前没有复用的时候八个用例每个都要启动一次浏览器进程光是启动开销就占掉一分钟。改成全局单浏览器实例、每个用例独立Context之后整个套件的执行时间大约缩短了40%。代价是并发线程数不能超过浏览器进程内部可管理的页面上限以及要留心全局实例一旦崩溃会让所有并行用例同时失败。为了防这个我在Context初始化失败时会尝试重新创建Browser一次通过重试掩盖偶发的浏览器进程异常。TestNG的数据提供者DataProvider在H5体检里也很好用特别是布局兼容检查。我的布局检查用例是个数据驱动场景同一个页面路径套用多个设备描述符每个设备跑一遍断言自然可以并行DataProvider(name devices, parallel true) public Object[][] devices() { return new Object[][] { {iPhone 13}, {Pixel 7}, {Galaxy S23}, {iPad Mini} }; } Test(groups {layout}, dataProvider devices) public void checkNoHorizontalOverflow(String deviceName) { // 每个设备名都会执行一次 }DataProvider提供的并行能力配合TestNG的线程数配置可以完全把兼容性检查跑成高度并行任务。前提是每个设备名对应的Context创建不共享任何可变状态这在我们的设计里是成立的。4.2 失败自动重试与截图留证自动化体检最影响可信度的就是“偶发失败”。网络抖动、第三方资源慢、定时任务抢资源都会让用例明明没有问题却挂了。完全不允许重试会让报告里出现一堆噪音但无限制重试又会掩盖真实缺陷。我的方案是允许每个用例最多重试2次并且只在重试仍然失败时报错。TestNG的重试机制需要自定义一个实现IRetryAnalyzer的类public class RetryAnalyzer implements IRetryAnalyzer { private int count 0; private static final int MAX_RETRY 2; Override public boolean retry(ITestResult result) { if (count MAX_RETRY) { count; return true; } return false; } }重试逻辑的核心坑在于重试时浏览器Context和页面对象必须重新创建不能复用失败现场。一次失败的用例可能已经让页面处于异常状态直接重跑只会叠加问题。我在用例的设计上严格要求用例方法自己负责Context创建和销毁这样重试时天然是一个全新会话。截图留证我通过TestNG监听器全局实现监听onTestFailure事件测试失败时调用当前线程持有的Page对象截图。要拿到这个Page我习惯用ThreadLocal保存当前Context和Pagepublic class PageThreadLocal { private static final ThreadLocalPage PAGE_HOLDER new ThreadLocal(); public static void set(Page page) { PAGE_HOLDER.set(page); } public static Page get() { return PAGE_HOLDER.get(); } }监听器里public class ReportListener implements ITestListener { Override public void onTestFailure(ITestResult result) { Page page PageThreadLocal.get(); if (page ! null) { try { page.screenshot(new Page.ScreenshotOptions() .setFullPage(true) .setPath(Paths.get(target/test-output/ result.getName() -fail.png))); } catch (Exception e) { System.out.println(截图失败: e.getMessage()); } } } }这里有个非常容易踩的坑Playwright的Page对象不是线程安全的ThreadLocal存Page必须在每个用例内部set用例结束后清掉否则并行执行时可能出现一个线程用了另一个线程的Page导致IllegalStateException。我的经验是set和clear放在try-finally里确保无论如何都能清理。4.3 结果汇总与可视化报告TestNG原生会生成一个HTML报告但样式比较朴素信息展示也不够直观。我一般再往ReportNG或者自建一个简单的Excel/JSON汇总层。考虑到团队里有些人喜欢在CI页面直接看测试结果有些人想拉数据做趋势分析我会同时输出两类报告一份是TestNG原生HTML加上截图索引一份是JSON汇总文件包含每个用例的执行状态、耗时、失败原因、截图路径。JSON汇总的生成我直接在监听器里维护一个全局结果列表套件跑完统一序列化输出public class JsonSummaryListener implements IExecutionListener { private ListCaseResult results new CopyOnWriteArrayList(); Override public void onExecutionFinish() { // 将results序列化为JSON } }CaseResult我设计成包含用例名、分组、引擎、设备名、状态、耗时、失败信息等字段。这套数据配合CI平台的面板可以自动画出最近一周的成功率曲线、耗时曲线。很多团队复盘问题时需要这种历史数据现成的TestNG报告看不到趋势这里自己造轮子是值得的。4.4 在CI流水线中接入体检流程体检的价值要体现在持续集成的环节里。我的接入策略分两层PR级和发版级。PR级跑的快速体检目标是阻止明显问题合入主干。这一轮只跑smoke、layout、api、perf四个分组并且使用无头浏览器的Chromium引擎。如果PR导致某个核心页面打不开、关键布局溢出或者接口大面积异常这条PR就会被标记失败。发版级跑全身体检八个维度全上并且开启重试。全身体检的时间相对较长所以我会把它挂在夜间定时任务上第二天早上一看报告就能知道当天代码的健康状况。如果全身体检失败发版流程就卡住必须有人工确认原因并解决后才放行。CI配置上有一个细节Playwright的浏览器二进制在CI容器里默认会装到缓存路径但容器每次可能丢失缓存所以需要在Dockerfile或CI步骤里提前执行一次playwright install把浏览器预置到镜像缓存层。否则每次流水线都现场下载等待时间会让人崩溃。另外一个建议是给无头浏览器配置--disable-dev-shm-usage参数避免CI环境/dev/shm过小导致浏览器崩溃。5. 常见问题与排查技巧实录这一章是我实际踩坑的复盘。如果有人照着这套方案搭完跑起来大概率会遇到下面这些无厘头的报错或者假失败提前知道能省下不少debug时间。5.1 浏览器启动失败和进程残留问题表现本地能跑CI上报Browser closed unexpectedly或者启动时报ECONNREFUSED。排查思路先看是不是/dev/shm空间不足这个在Docker容器里太常见了。解决方法就是给chromium启动参数加上--disable-dev-shm-usage。再看是不是并发创建的Browser数量过多导致系统文件句柄耗尽。我们线上跑多个套件并行的时候出现过一次Too many open files后来限制并发浏览器总数解决。还有一类进程残留问题用例中途被kill浏览器进程没被回收占着端口或锁文件。这个我通过在CI里加一条清理命令解决把所有playwright相关进程在流水线头部清一遍。看起来粗暴但确实有效。5.2 元素定位不稳时好时坏问题表现同一套用例今天跑过明天挂报错永远是element not found或timeout。这种问题八成是定位器太依赖DOM结构。比如某个按钮外层div的class在另一个分支里改了名字测试就抓瞎。解决方向是推动前端在关键节点加上以data-testid命名的稳定属性。如果前端暂不配合退而求其次用包含文本匹配比如page.locator(button:has-text(提交))。另一个常见原因是页面存在异步渲染元素在DOM里但不可见。Playwright的自动等待对可见性判断比较可靠但如果页面用visibility: hidden占位、等数据返回后再显示那等待selector可见可能不够还需要等待文本内容变化。我用过一个笨但稳的方案先记录当前innerText再等数据请求回调最后轮询直到innerText包含预期关键词。5.3 网络监听漏数据问题表现页面加载了很多接口但onResponse监听器只捕获到一部分有些关键接口始终没记录到。排查后一般有两个原因一个是监听器注册时间太晚页面在监听器挂上之前已经发出了部分请求另一个是Service Worker拦截或浏览器缓存命中导致接口没有真正走网络。解决方式是在Page创建后立刻挂监听而且使用context级监听而不是page级监听这样整条会话内所有帧的请求都跑不掉。另外给请求URL的匹配条件多留一些模糊匹配空间别用全等字符串。缓存命中的情况有时确实拿不到response这种时候如果必须验证接口行为可以用context.clearCookies()或者禁用缓存来强制重新请求不过要小心这会影响性能指标的真实性。我一般把性能采集和接口审计放在两个不同Context里跑避免互相干预。5.4 跨浏览器执行时的能力差异问题表现同样一套代码chromium完全正常切到webkit就报错。排查后通常发现是WebKit对某些API支持不全比如DeviceDescriptor里的某些能力字段在WebKit下返回空值。我的处理办法是在做内核差异化判断时先检查能力是否可用不可用就跳过该用例。从自动化可信度角度来说跳过比报错更好因为报错会误导团队以为产品有兼容性问题。另外一个跨内核差异是Playwright对WebKit的调试通道能力支持相对有限某些高级路由和网络模拟接口的行为和Chromium不完全一致。我在设计用例时会把高危依赖这些能力的用例标记为“仅Chromium执行”避免引擎差异造成的假阳性。这样处理下来双引擎跑通的结果可信度更高。5.5 超时时间的设定是一门玄学问题表现用例在本地跑只要3秒在CI上却频繁超时。超时时间不能一刀切。本地资源充足网络快2秒可能够用CI机器CPU波动大第三方资源拉取慢同一段等待可能要8秒。我推荐的做法是把常见操作等待从统一的默认值里拆出来关键流程用显式超时并稍微放宽比如首屏元素等待设置10秒按钮交互等待设置5秒。另外UI操作等待和网络等待的判定逻辑要分开等待元素可见是DOM层面的等待请求完成是网络层面的两个判定不要互相等待。5.6 测试数据污染问题表现用例第一次跑全绿第二次跑莫名其妙失败而且失败用例总是那几个涉及数据写入的场景。H5页面的状态很容易被上一次测试污染。比如购物车加购用例跑完车里的商品没有清空下一轮用例再跑断言就变成了数量不一致。解决手段无非几种用例间隔离数据、用接口造数、执行后清理。我在实际项目中采用的是“前置造数加后置清理”的规范。前置造数可以用页面操作也可以用接口调用后置清理要放在finally里。但同一套代码如果逻辑复杂清理很容易写漏。更稳的方案是每个用例都创建一个全新的测试账号账号级隔离数据跑完删账号牺牲一点账号资源但换来了彻底的隔离性。6. 实战过程与调优经验这一章我从一个真实项目的落地过程来复盘。接手一个电商H5项目时前端是三个团队并行开发的发布频率很高手工回归已经跟不上节奏。我花了大概一周时间把体检骨架搭起来前两周持续补用例和修稳定性一个月后这套自动化已经能稳定跑完全量并出报告。6.1 项目节奏安排第一周先把基础工程、浏览器管理、TestNG XML结构、配置加载跑通同时用冒烟分组覆盖首页、搜索、详情页三条主路径。这个阶段的产出是一个能跑通的最小闭环。第二周开始补布局、网络、接口审计和性能采集四个分组。这个阶段最麻烦的不是写代码而是和前端敲定稳定定位属性、统一各页面的错误提示文案。每天都有一小撮用例因为属性变更而挂我把问题汇总给团队后大家一起定了个公约凡是自动化要用的节点都加data-testid凡是加载失败场景都统一出error-container节点否则不做UI断言。第三四周开始压稳定性。这个阶段大多数时间花在调超时、过滤第三方请求、增加重试逻辑和截图留证上。到第四周末全量体检的通过率稳定在95%以上剩下的5%基本都是偶发超时和线上第三方资源抖动。6.2 数据驱动模式带来的维护收益布局检查用例是我维护成本最低的一组。理论上要覆盖的设备很多但实际跑下来我选定的四个设备描述符就覆盖了常见UI溢出问题。新增一个设备模型只需要在DataProvider里加一行不需要写新用例。稳定性用例的问题在于执行时间太长。30轮循环在本地都要跑将近10分钟CI上更久。后来我把循环轮数下调到了20轮时间控制在了7分钟以内。实测下来真正的白屏和JS异常在20轮内都能暴露继续堆轮数的边际收益很低。6.3 我踩过的三个最痛的坑第一坑Playwright的context在WebKit下的Cookie和登录态隔离行为与Chromium不一致。有的用例在Chromium里用登录态跑得很正常换到WebKit里登录态直接丢失导致后续操作全挂。排查了很久才发现WebKit的device描述符对Cookie持久化处理有差异最后方案是把自己设置的登录Cookie从代码里写到context里而不是依赖页面跳转流程自动生成。第二坑不要在一个用例里同时做很多类型的断言。最初我把功能冒烟和性能采集塞在同一个用例里串行执行结果性能指标经常被前面的操作干扰功能断言又因为性能采集的额外等待而变慢。后来彻底拆分开每个用例只专注一个维度代码好维护失败归因也清晰。第三坑并行执行时TestNG和ThreadLocal配合的隐藏问题。刚开始我没把Page存储做成ThreadLocal很多用例在并行模式下会莫名报“has been closed”错误。这是因为两个线程的Page实例被互相覆盖随后其中一个线程把另一个线程的页面关了。这个问题在串行模式下完全不会出现只有并行数量上来才开始爆发。排查过程很费劲最终方案就是前文说的ThreadLocal加try-finally清理。7. 这套方案能扩展到什么程度做到这一步基础体检已经能覆盖大部分H5场景了。想继续深挖的话有几个方向值得投入。第一个方向是接入移动真机云设备。Playwright跑的是浏览器模拟环境虽然视口和UA都贴近真机但一些真正依赖设备能力的场景比如摄像头、相册选择器、重力感应模拟环境测不了。如果需要这套能力可以保留现有脚本框架把执行引擎替换成对应的云测平台设备脚本层的断言和报告逻辑可以复用很多。第二个方向是生成用户旅程式报告。现在的体检数据是按用例组织起来的对开发来说已经够用但产品经理可能更想看“从首页到支付整个链路是否顺畅”。可以扩展出一个场景聚合层把属于同一用户旅程的多个用例结果聚合成一段带时间轴和截图的报告对跨团队复盘很有帮助。第三个方向是接口和UI的联合巡检。目前的API状态审计只验证了页面发出请求没有和接口测试用例合并。如果后端接口的健康检查也纳入同一个报告体系出现问题时能更快判断是前端渲染问题还是服务端数据问题。第四个方向是性能基线的持续监控。性能采集的JSON数据存下来之后可以接入简单的可视化面板绘制FCP、LCP、接口响应时间的日趋势。当趋势出现陡增即使没有触发通过阈值也能人工警惕。从我个人的实际体验来看这套自动化体检最大的价值并不是替代人工测试而是把重复性极高的回归工作自动化让团队把精力集中在真正需要人判断的交互体验和视觉走查上。项目运行半年后线上紧急修复导致的回归基本都由体检第一时间发现人力从每天晚上加班回归变成一个定时任务加一次人工复核这是最直观的收益。如果有团队正在被H5的碎片化环境折腾得焦头烂额不妨从最小闭环开始选两个最核心的页面搭好冒烟加布局检查跑通一个PR级流水线先把被动的“手动刷新”变成主动的“自动体检”剩下的事情随着问题暴露再慢慢加。自动化这套东西重点是持续演化不是一蹴而就。