首页 / 资讯中心 / 文章详情

Playwright接口Mock实战:从路由拦截到稳定前端自动化测试

Playwright接口Mock实战:从路由拦截到稳定前端自动化测试 ★ FEATURED ARTICLE
做前端自动化测试这些年最让人头疼的从来不是定位不到元素而是接口数据不受控。后端接口没写好、测试环境数据被别人改了、第三方支付回调迟迟不来、接口返回超时导致用例直接失败——这些场景单独拎出来每一个都够写一篇吐槽帖。所以我一直很关注浏览器自动化工具对接口Mock的支持力度。Playwright的Mock APIs能力我实际用下来的感受是把“接口层”的控制权彻底交还给了测试工程师既能做请求拦截也能做响应伪造还能按需放行灵活度相当高。这篇文章我会从“为什么要Mock”讲起把Playwright路由拦截的核心机制、常见Mock场景的完整写法、以及我在真实项目里踩过的坑和排查思路一次性讲清楚。适合刚接触Playwright、想在测试里做接口模拟的读者也适合已经写了几个用例但总觉得Mock逻辑不够优雅的同行。1. Mock APIs到底是什么以及为什么它值得花时间搞明白1.1 前端自动化测试的典型困局假设你要做一个登录流程的自动化用例。页面打开、输入用户名密码、点击登录正常情况下前端会调用一个/api/login接口。如果这个接口在测试环境挂了或者返回了一个500你的用例就挂了。但问题在于这次失败并不是你的登录功能有Bug而是后端环境不稳定导致的。再换一个更麻烦的场景你的页面里有一个“订单详情”模块接口要1秒后才返回数据前端在等待期间会展示一个Loading动画。你想断言Loading动画的显示逻辑对不对但真实接口偶尔返回很快偶尔返回很慢根本没法稳定复现。你只能在测试里硬等或者靠运气。还有第三方场景。页面接入了微信支付你要测支付成功后的回调跳转逻辑。但真实支付网关在测试环境里根本调不通你总不能每次手动去造一笔真实支付。这时候如果能在浏览器层面把支付回调接口“截胡”掉强制返回成功状态整个支付流程的UI验证就能自动化跑起来。这些问题总结起来就一句话前端用例的稳定性被后端接口的可用性和数据内容绑架了。Mock APIs要解决的就是把这种绑架解除掉——我不管你后端现在返什么我只关心前端在给定接口响应下是否做了正确的事情。1.2 Mock APIs能覆盖的测试场景我在项目里主要用Playwright的Mock能力解决下面这几类问题第一类是接口数据定制。测试环境里某些数据很难造比如“余额为0的账户”“订单已取消的状态”“会员等级V8的用户”。通过Mock直接在路由层返回一段写死的JSON就能让前端展示出对应状态从而验证UI展示是否符合预期。第二类是异常场景模拟。真实后端很少会主动返回500、502或者超时但前端必须有对应的错误处理逻辑。Mock可以把接口强制改写为指定状态码也可以让请求一直挂起不返回模拟超时场景。第三类是时序控制。接口什么时候返回、返回顺序如何直接决定了页面Loading状态和竞态条件能否被验证。Mock可以加延迟也可以控制多个请求的返回顺序这事儿在真实环境里几乎不可能稳定做到。第四类是环境解耦。在持续集成流水线里跑UI用例时如果后端正在发版或者数据被污染整个测试套件都会跟着废掉。把关键接口Mock掉之后用例只验证前端行为不再依赖后端环境是否健康稳定性会提高一大截。1.3 为什么选Playwright而不是其他Mock方案之前我试过几种常见的接口Mock方案各有各的局限。一种是启动一个独立的Mock服务比如用Node写个Express服务让前端在测试时把API请求地址指到Mock服务上。这个方案可行但要改前端环境配置还要维护一套Mock接口代码工作量大而且改地址这个过程本身就涉及构建链路的变更容易引入额外变量。另一种是用抓包工具改响应。这种方式适合手工调试但在自动化脚本里不太好集成每个用例都要去配置对应的拦截规则维护成本高。Playwright的方案是把Mock能力直接做进了浏览器自动化运行时。你在测试代码里调用page.route()注册一个拦截器浏览器发出的请求在到达服务器之前就会被Playwright捕获你可以选择继续转发、直接返回自定义响应、或者修改请求后放行。整个过程完全在测试框架内部完成不需要启动任何额外服务也不需要改前端配置。每一条用例都可以独立注册自己的拦截规则用例跑完自动清理互不影响。这种“进程内拦截”的体验和以前那一堆Mock工具比起来真的是降维打击。2. 核心机制拆解page.route是如何做到“截胡”的2.1 路由拦截的底层逻辑理解Mock APIs绕不开page.route()这个方法。它的工作方式可以类比成在公司前台设置了一个快递收发室所有寄到公司的快递HTTP请求都必须先经过这个收发室你可以选择签收后直接换一个包裹给收件人返回Mock响应也可以选择原样送进去继续请求服务器还可以拆开包裹改一下内容再送进去修改请求后放行。代码上的基本形态是这样await page.route(**/api/user/**, async (route) { await route.fulfill({ status: 200, contentType: application/json, body: JSON.stringify({ name: test_user, level: V8 }), }); });page.route()接收两个参数。第一个参数是URL匹配规则第二个参数是处理函数。当页面发出的请求URL匹配到规则时处理函数就会被调用。route.fulfill()就是直接伪造响应返回给页面请求压根不会打到真实服务器上。还有一个route.continue()方法含义是放行请求让请求按原样发到服务器。还有route.abort()直接中断请求模拟网络失败场景。这三个方法fulfill、continue、abort就是Mock APIs的全部核心动词其他所有操作都是从这三个方法衍生出来的。2.2 URL匹配规则是怎么写的page.route()的匹配规则支持几种写法我分别说一下。第一种是通配符模式。**匹配任意字符*匹配除了斜杠以外的任意字符。比如**/api/user/**能匹配任何域名下以/api/user/开头的URL。注意这里是一个*还是两个**的差异很容易被忽略**/api可以匹配https://a.com/api也能匹配https://a.com/v1/api因为双星号能跨越路径层级。而单星号*/api在这种场景下就匹配不到深层路径。第二种是正则表达式。如果URL带查询参数且规则比较复杂建议直接用正则例如await page.route(/\/api\/products\?id\d/, async (route) { await route.fulfill({ body: {items: []} }); });第三种是函数。你可以传一个接收URL对象并返回布尔值的函数这种写法最灵活适合需要按请求头、请求方法等条件判断的场景。举个例子await page.route((url) { return url.pathname.startsWith(/api/order) url.searchParams.get(type) detail; }, handler);实际项目中我强烈建议先想清楚自己要拦截的是“一类请求”还是“某一个具体请求”。一类请求就写在路径前缀上具体请求就带上id参数。规则写得太宽会误伤其他请求写得太窄又会导致拦截不生效这个平衡需要在写用例时想清楚。2.3 请求改写和响应伪造的完整参数route.fulfill()可以让你完整控制HTTP响应的各个组成部分。除了最常用的status、contentType、body之外还有headers参数可以自定义响应头还有path参数可以直接指定一个本地文件作为响应体。我个人最常用到path的场景是Mock图片资源一篇博客文章里的配图地址可以直接指向测试目录下的静态图片。如果你不想完全伪造一个响应而是想“修改真实响应”那就需要先转发请求拿到真实结果再改动内容。Playwright在route.fetch()之后可以拿到真实服务器的响应此时再调用fulfill返回修改后的数据await page.route(**/api/list, async (route) { const response await route.fetch(); const json await response.json(); json.items json.items.filter(item item.status active); await route.fulfill({ response, json, }); });这里有个细节fulfill的response参数传原始响应对象然后配合json参数传修改后的数据Playwright会保留状态码和响应头只替换响应体。这种“先请求再做修改”的模式在实际项目中非常有用等于把Mock的粒度控制到了字段级别。2.4 并发请求的处理逻辑页面往往同时发出多个请求page.route注册的拦截器对每一个匹配的请求都会触发一遍处理函数。处理函数内如果执行了route.fetch()这个fetch也是按照当前匹配规则走的——需要注意如果你在route.fetch()之后又fulfill了而这个fulfill的URL恰好又匹配了另外一条route规则可能会造成递归或者循环。我在早期就踩过这个坑后来养成了一个习惯对于会被fetch的规则尽量在URL匹配条件上把范围收窄避免和另一个全局规则冲突。再有一个点Playwright的拦截默认是异步并发的多个请求的处理函数之间互不等待。如果你希望某个请求处理完成后再放行下一个需要在测试代码里用Promise或者队列做协调Playwright本身不会替你排队。3. 常见Mock场景的完整实现从固定数据到复杂业务流3.1 模拟接口返回固定数据这是最基础的用法页面加载时请求用户信息直接返回固定JSONtest(用户信息展示, async ({ page }) { await page.route(**/api/user/profile, async (route) { await route.fulfill({ status: 200, contentType: application/json, body: JSON.stringify({ id: 1001, name: 测试用户, balance: 0, membership: v8, }), }); }); await page.goto(https://example.com/user); await expect(page.locator(.member-badge)).toHaveText(V8 会员); });需要注意body里传JSON时要保证是合法字符串别把JSON.stringify省了。传对象直接塞进去Playwright会把它按字符串处理接口返回的content-type如果是application/json前端res.json()解析时大概率报错。3.2 模拟接口延迟与超时超时场景的模拟办法是让请求一直不返回。Playwright的page.route处理函数里不让它调用fulfill请求就会一直挂着。配合测试用例的waitForTimeout或者直接断言Loading状态出现就能验证超时逻辑。示例test(接口超时展示错误提示, async ({ page }) { await page.route(**/api/report/**, async () { // 故意不调用 resolve模拟挂起 await new Promise(() {}); }); await page.goto(https://example.com/report); await expect(page.locator(.loading)).toBeVisible(); });不过这个写法要小心挂起的请求如果没在用例结束后被清理会占用连接直到页面关闭。Playwright的page.close会自动释放但为了保险我通常会在用例结尾用page.unroute移除未完成的规则。模拟延迟则简单得多在fulfill前await new Promise(resolve setTimeout(resolve, 2000))即可。注意一点延迟时间是真实等待用例的执行时间也会相应变长别每个用例都来个5秒大延迟全套cases跑下来的时间会非常感人。3.3 模拟异常状态码与网络断开状态码异常是Mock场景里最容易写的一段await page.route(**/api/submit, async (route) { await route.fulfill({ status: 500, contentType: application/json, body: JSON.stringify({ message: server error }), }); });除了500还可以模拟429限流、403权限不足、401未登录。模拟网络断开用route.abort()。这里有个常见误区abort会让请求直接失败前端如果做了fetch异常捕获会走catch逻辑但如果前端恰好没有做异常处理那页面可能什么都不展示断言之前要想清楚你测的是“前端错误提示”还是“前端无响应时的表现”两种场景断言方式差别很大。3.4 修改请求的URL、请求头和请求体有时候你不想完全拦截而是想改掉请求里的某些内容再放行。route.continue()支持传入改写参数await page.route(**/api/login, async (route) { await route.continue({ headers: { ...route.request().headers(), Authorization: Bearer mock-token, }, postData: JSON.stringify({ username: mock_user, password: mock_pass }), }); });这个典型的应用场景是页面里写死的请求参数不符合测试环境要求或者需要给所有请求统一附加一个调试Header。route.continue()会自动发请求到真实服务器你只需要覆盖想改的字段。注意postData只对POST、PUT这类有请求体的方法有效GET请求传postData会被忽略。3.5 用HAR文件做离线回放Playwright支持把真实的网络请求录制为HAR文件然后通过page.routeFromHAR()在测试中回放。拿HAR做Mock的好处是数据完全来自真实环境字段齐全不用手写JSON适合接口返回结构特别复杂的场景。test(使用HAR回放订单列表, async ({ page }) { await page.routeFromHAR(./hars/orders.har, { url: **/api/orders/**, update: false, }); await page.goto(https://example.com/orders); await expect(page.locator(.order-item)).toHaveCount(3); });录制HAR文件可以用Playwright自带的命令npx playwright open --save-harorders.har --save-har-glob**/api/orders/** https://example.com/orders--save-har-glob用来过滤指定域名和路径避免把无意义的静态资源也录进去。update: true模式则是先真实请求一次并更新HAR内容适合接口结构变化后快速刷新Mock数据。3.6 结合Fixtures组织可复用的Mock逻辑项目里的用例多了以后每个用例都手写一个page.route太啰嗦。我习惯把常用的Mock封装成自定义Fixture。比如做一个“已登录会员”的Fixture在任何用例里只要声明它就自动完成登录接口和用户信息接口的Mockconst test base.extend({ memberPage: async ({ page }, use) { await page.route(**/api/user/**, async (route) { await route.fulfill({ status: 200, json: { name: member_user, level: 3 }, }); }); await page.goto(https://example.com/member-center); await use(page); }, });这样写的好处是Mock逻辑和用例断言逻辑分离开用例里只关注页面行为。团队协作时新人只需要看Fixture的名字就知道这条用例是哪种用户场景不需要逐行扒代码。4. 常见问题与排查技巧实录4.1 路由规则不生效请求还是打到了真实服务器这种情况我遇到得最多。原因基本逃不出三个第一URL匹配规则写错了。特别是通配符的语义理解偏差。建议在还没写完整用例前先用一行日志验证规则是否命中await page.route(**/api/**, async (route) { console.log(route hit:, route.request().url()); // ... });看到日志说明规则没问题看不到就要改匹配规则。第二page.route的注册时机太晚。页面在路由注册之前已经把请求发出去了自然拦截不到。解决方案是确保注册发生在page.goto或者任何可能触发请求的操作之前。第三请求是由Service Worker发出的。page.route默认拦截的是页面主文档和子资源请求部分Service Worker发起的请求需要额外配置serviceWorkers: block或者用context.route来做。这个问题比较隐蔽遇到请求就是拦不到的时候留意一下页面里有没有Service Worker。4.2 多个路由规则同时匹配时谁说了算同一URL如果注册了多条route规则Playwright按注册顺序依次匹配先注册的规则优先生效。处理函数内部你调用了route.continue()那这个请求会被后续的规则继续处理如果你直接fulfill了后面的规则就没机会了。这个机制带来的一个实际问题全局通用规则和用例级规则混在一起的时候很难判断最终效果。我的习惯是全局规则选择不处理而是通过continue或者unroute快速跳出来具体用例里再去注册精确规则。比如test(覆盖全局Mock规则, async ({ page }) { // 先移除全局规则 await page.unroute(**/api/user/**); // 再注册本用例专属规则 await page.route(**/api/user/**, handler); });4.3 页面里用了WebSocketMock APIs管不到page.route的主要能力在HTTP/HTTPS请求上WebSocket的拦截标注是实验性的生产项目里我不建议依赖。遇到页面必须用WebSocket通信的场景我的方案是尽量把WebSocket的功能通过注入脚本的方式绕开或者在测试环境里准备一个WebSocket Server替身。这条线别硬碰实测下来稳定性确实不如HTTP路由拦截。4.4 Mock数据和真实数据结构不一致导致用例误判有个很典型的坑Mock JSON里少了某个前端要用的字段前端代码可能直接抛异常或者渲染空页面。你以为是前端有问题实际上只是Mock数据不完整。我吃过一次大亏某条用例断言了商品列表里的一个价格字段结果一夜之间用例全挂了查了半天是Mock的JSON少了一层priceData嵌套。解决办法Mock数据尽量从真实接口的响应结构里复制别凭印象手写嵌套。可以先用page.on(response)把真实接口的返回打印出来照着结构改。HAR回放的方案在这个场景下就特别有优势因为字段不会缺。4.5 用例结束后的“劫持残留”如果一条用例注册了page.route但没清理下一条用例可能依然被影响。用Playwright Test的话每个test默认会有新的page实例一般不会串。但如果你在page级复用了浏览器上下文或者自己管理page生命周期就一定要在用例结束后unroute。也可以在afterEach里加一个兜底:test.afterEach(async ({ page }) { await page.unroute(**/*); });这个兜底能确保任何用例里残留的规则都被清掉值得每个项目都配上。4.6 排查工具与调试技巧排查Mock问题时我通常先开Playwright的调试模式npx playwright test --debug调试模式下可以逐步查看每个请求的处理过程。还有一路更直接的思路在page.route处理函数里加上日志再用page.on(request/response)对比真实请求的进出情况。两条日志一比对基本能定位是请求没被拦截、还是拦截了但响应格式不对。再分享一个小技巧测试期间把所有被Mock的接口名和状态码打印出来整理成一份清单排错时一目了然。我常写一个辅助函数function logMock(label, url, status) { console.log([MOCK:${label}] ${url} - ${status}); }只要在每次fulfill或abort时调用一下整个用例的Mock行为在终端里就是可见的。这对排查复杂业务流程帮助极大。5. 一些我在实战中的额外建议5.1 Mock粒度不要一刀切有一种错误倾向是一上来就把所有接口全Mock掉。这会导致前端和真实后端的契约覆盖完全消失很多接口字段变化的问题反而测不出来。我的做法是分层管理核心业务链路里的非关键接口大胆Mock用来稳定用例但是那些和本期需求强相关的接口尽量走真实请求确保前后端联调问题能被自动化用例兜住。举个实际例子测试一个商品下单流程库存扣减接口如果每次都依赖真实后端库存数据很容易越测越少。这时果断Mock掉但下单事务接口本身走真实逻辑这样既稳定又能验证核心链路。5.2 Mock数据单独建文件维护把Mock JSON以单独文件形式存在fixtures/mock-data目录下测试代码里引用路径加载。尤其是那些字段较多、嵌套较深的数据结构千万别散落在用例里否则光是改一个字段就要翻半天代码。const userProfileMock JSON.parse( fs.readFileSync(./fixtures/mock-data/user-profile.json, utf8) );5.3 留一条“Mock全局开关”项目规模大了以后同一个用例可能要跑两遍一遍全Mock、一遍走真实接口。我习惯在配置文件里加一个环境变量控制是否启用Mock拦截。比如const enableMock process.env.MOCK_ENABLED true; if (enableMock) { await page.route(**/api/**, mockHandler); }这个开关在CI流水线里尤其好用——日常定时任务跑全Mock模式追求稳定性发版前回归任务跑真实接口模式追求覆盖度。5.4 注意Mock对性能分析的影响如果你要做性能相关的测试比如页面加载耗时、接口响应时间统计Mock会直接把数据污染掉因为mock响应的网络传输时间本身很短。跑性能类用例时务必关闭所有无关Mock只保留必要的登录态模拟否则统计报表完全不可信。Playwright的Mock APIs功能单独拿出来讲能写的操作细节其实还有很多。这份工具用得好自动化测试的稳定性能上一个台阶团队对自动化用例的信任度也会大幅提升。我在实际使用中最深的体感是真正困难的不是API怎么调用而是想清楚每一条用例到底需不需要Mock、需要Mock到哪一层。带着这个判断去写用例才不会让Mock反而变成掩盖真实问题的帮凶。
阅读完成 · 觉得有帮助?
咨询建站