ThinkChat🤖让你学习和工作更高效,注册即送10W Token,即刻开启你的AI之旅 广告
  最近在研究[Web自动化测试](https://www.cnblogs.com/strick/p/16892143.html),之前做了些实践,但效果并不理想。   对于 QA 来说,公司的网页交互并不多,用手点点也能满足。对于前端来说,如果要做成自动化,就得维护一堆的脚本。   当然,这些脚本也可以 QA 来维护,但前提是得让他们觉得做这件事的 ROI 很高,依目前的情况看,好像不高。   所以在想,做一个平台,在这个平台中可以保存些数据,并且在旁边提供个小窗口,呈现要测试的 H5 网页,如下图所示(画图工具是[excalidraw](https://excalidraw.com/))。   在修改相关数据后,可以直接看到网页的变化。 :-: ![](https://img.kancloud.cn/8b/23/8b235013653afbe5ed7b39d4bfffb12c_1101x938.png =400x)   QA 或前端可以不用再写脚本代码,就能实现自动化测试。   目前想到两块,第一块是拦截请求,mock 响应;第二块是记录页面行为,然后自动回放,最后截图,和上一次的截图做对比分析,看是否相同。 ## 一、拦截请求   拦截请求就是将响应 mock 成自己想要的数据,然后查看页面的呈现。   这样就能模拟各种场景,毕竟测试环境的业务数据肯定不能满足所有场景,所以需要自己造。   有了平台后,就能将造的数据保存在数据库中,可随时调取查看页面呈现。 **1)拦截**   现在就要实现拦截,我首先想到的就是注入脚本,然后在 XMLHttpRequest 或 fetch() 埋入拦截代码。   以 XMLHttpRequest 为例,在 monitorXHR() 函数中就可以让请求转发到代理处。 ~~~ var _XMLHttpRequest = window.XMLHttpRequest; // 保存原生的XMLHttpRequest // 覆盖XMLHttpRequest window.XMLHttpRequest = function (flags) { var req = new _XMLHttpRequest(flags); // 调用原生的XMLHttpRequest monitorXHR(req); // 埋入我们的“间谍” return req; }; ~~~   例如将所有的请求都 post 到 test/proxy 接口,这是一个 Node 接口,代码如下。   代码比较简单,没有考虑各种请求,例如自定义的 header、cookie 等。因为没有经过实践,只是展示下思路,所以肯定存在着 BUG。   思路就是将整理好的请求地址、参数等信息转发过来后,先从数据库中查看是否有指定的 mock 数据。   如果有就直接返回,若没有,就再去请求原接口。 ~~~ router.post("/test/proxy", async (ctx) => { const { id, method, url, params } = ctx.request.body; // 通过ID查找存储在 MongoDB 中的拦截记录 const row = await services.app.getOne(id); if (row) { ctx.body = row.response; return; } // 没有拦截就请求原接口 const { data } = await axios[method](url, params); ctx.body = data; }); ~~~   理论上,是完成了拦截,但是现在还有个很重要的问题,那就是 XMLHttpRequest 或 fetch() 那段间谍脚本该怎么注入。 **2)注入脚本**   暂时想到了三个方法,第一个是通过控制 iframe 在页面中注入脚本。   因为那张 H5 示例页面,可以放到 iframe 中呈现,所以这种注入方式理论上可行。   只需要读取 HTMLIFrameElement 中的[contentDocument](https://developer.mozilla.org/en-US/docs/Web/API/HTMLIFrameElement/contentDocument)属性就能得到页面中的 document。 ~~~ document.getElementById('inner').contentDocument.body.innerHTML ~~~   但是 iframe 有个同源限制,必须是同源的才能通过脚本读取到 contentDocument。   况且注入的时机也比较讲究,必须在发起请求之前,改写 XMLHttpRequest 或 fetch(),若用 JavaScript 添加 script 元素,恐怕不够及时。   那么第二个方法,就是在构建的时候将脚本注入,当然,在上线后,这些脚本都是要去除掉的,仅限测试的时候使用。   不过这种方法不够自动化,需要研发配合,像我们这种小公司,就那么几个项目,倒也问题不大。   第三个方法是用无头浏览器(例如[puppeteer](https://pptr.dev/api/))将脚本注入(如下所示),然后再把新的页面结构作为响应返回。 ~~~ await page.evaluate(async () => { const img = new Image(); img.src = "xxx.png"; document.body.appendChild(img); }); // 获取 HTML 结构 const html = await page.content(); ~~~   但有个地方要注意,输出页面结构的域名要和之前相同(需要运维配合),否则那些脚本很有可能因为跨域而无法执行了。 ## 二、记录页面行为   网页就是一棵 DOM 树,要记录页面行为,其实就是记录发生动作的 DOM 元素以及相关的动作参数。   脚本注入的方式可以参考上面的 3 种方法,平台的布局也与上面的类似,只是表单中的参数可能略有不同。 **1)保存 DOM 元素**   DOM 元素是不能直接 JSON 序列化的,所以需要将其映射成一个指定结构的对象,如下所示。 ~~~ { "type": "scrollTo", "rect": { "top": 470, "left": 8, "width": 359, "height": 400 }, "scroll": { "top": 189.5, "left": 0 }, "tag": "div" } ~~~   tag 是元素类型,例如 div、button、window 等;type 是事件类型,例如点击、滚动等;rect 是坐标和尺寸,scroll 是滚动距离。   这种结构就可以顺利存储到数据库中了。 **2)监控行为**   目前实验,就只监控了点击和滚动两种行为。   为 body 元素绑定 click 事件,采用捕获的事件传播方式。 ~~~ /** * 监控 body 内的点击行为 */ document.body.addEventListener('click', (e) => { behaviors.push({ type: 'click', rect: offsetRect(e.target), tag: e.target.tagName.toLowerCase() }); }, true); ~~~   rect 的尺寸和坐标本来是通过[getBoundingClientRect()](https://developer.mozilla.org/zh-CN/docs/Web/API/Element/getBoundingClientRect)获取的,但是该方法参照的是视口的左上角,也就是说会随着滚动而改变坐标。 :-: ![](https://img.kancloud.cn/08/f7/08f7f10f7fcf5ad72fe28ff7ed1068b3_1466x1099.png =500x)   所以就换了一种能更加精确获取坐标的方法,如下所示,nodeMap 是一个 Map 数据结构,key 可以是一个元素对象,用于缓存计算过的元素坐标。 ~~~ // 元素缓存 const nodeMap = new Map(); /** * 读取元素真实的坐标 */ function offsetRect(node) { // 从缓存中读取node信息 const exist = nodeMap.get(node); if(exist) { return exist; } let top = 0, left = 0; const width = node.offsetWidth const height = node.offsetHeight; while (node) { top += node.offsetTop; left += node.offsetLeft; node = node.offsetParent; } const rect = { top, left, width, height }; nodeMap.set(node, rect); // 缓存node信息 return rect; } ~~~   下面是对滚动的监控代码,throttle() 是一个节流函数,不节流会影响滚动的性能。   在 startScroll() 函数中会计算滚动条距离顶部和左边的距离,window 和元素读取的属性略有不同。 ~~~ /** * 节流 */ function throttle(fn, wait) { let start = 0; return (e) => { const now = +new Date(); if (now - start > wait) { fn(e); start = now; } }; } /** * 对滚动节流 */ const startScroll = throttle((e) => { const target = e.target; let tag, rect, scroll; if(target.defaultView === window) { tag = 'window'; scroll = { top: window.pageYOffset, left: window.pageXOffset }; }else { tag = target.tagName.toLowerCase(); scroll = { top: target.scrollTop, left: target.scrollLeft }; rect = offsetRect(target); } behaviors.push({ type: 'scrollTo', rect, scroll, tag }); }, 100); /** * 监控页面的滚动行为 */ window.addEventListener('scroll', (e) => { startScroll(e); }, true); ~~~ **3)还原**   在得到数据结构后,就得让其还原,呈现完成一系列动作后的页面。   我写的算法比较简单,还有很大的优化空间。目前就是遍历存储的行为数组,然后深度优先搜索 body 内的所有子元素。   当坐标和尺寸满足条件时,返回元素。不过这种方式非常依赖这两个参数,因此只要结构发生变化,那么动作就无法完成。 ~~~ function revert(behaviors) { let isFind = false; // 深度优先遍历 const dfs = (node, target) => { if (!node) return; const rect = offsetRect(node); const tag = node.tagName.toLowerCase(); // console.log(node, rect, target) // 根据坐标定位元素 if (target.tag === tag && target.rect.top === rect.top && target.rect.left === rect.left && target.rect.width === rect.width && target.rect.height === rect.height) { target.node = node; //记录元素 isFind = true; return; } node.children && Array.from(node.children).forEach((value) => { if (isFind) { return; } dfs(value, target); }); }; behaviors.forEach(item => { isFind = false; // window对象单独处理 if(item.tag === 'window') { item.node = window; }else { dfs(document.body, item); } const { node } = item; // 没有找到符合要求的元素 if(!node) return; switch(item.type) { case 'scrollTo': // 滚动 node.scrollTo({ ...item.scroll, behavior: 'smooth' }); break; default: // 其他事件 node[item.type](); break; } }); } ~~~   scrollTo() 是一个滚动的方法,smooth 是一种平滑选项,奇怪的是,当我去掉此选项时,滚动就无法完成了。 **4)截图**   本来是计划用脚本来实现截图的,可选的库是[dom-to-image](https://github.com/tsayen/dom-to-image)和[html2canvas](https://html2canvas.hertzen.com/)。   但是测试下来得到的截图结果都不是很理想,于是就仍然采用 puppeteer 来实现截图。   先将行为脚本注入,然后等几秒,最后再截图。这种截图得到的结果比较准确,但就是执行过程有点慢,经常需要十几秒甚至更长。 ~~~ await page.evaluate(async () => { const scrpt = document.createElement("script"); scrpt.src = "xx.js"; document.body.appendChild(scrpt); }); await page.waitForTimeout(2000); await page.screenshot({ path: `xx/1.png`, type: "png" }); ~~~   两张截图的对比可以通过[pixelmatch](https://github.com/mapbox/pixelmatch)完成,下面是官方提供的 node.js 使用示例,[pngjs](https://github.com/lukeapage/pngjs)是一个 png 图像编解码器。 ~~~ const fs = require('fs'); const PNG = require('pngjs').PNG; const pixelmatch = require('pixelmatch'); const img1 = PNG.sync.read(fs.readFileSync('img1.png')); const img2 = PNG.sync.read(fs.readFileSync('img2.png')); const {width, height} = img1; const diff = new PNG({width, height}); pixelmatch(img1.data, img2.data, diff.data, width, height, {threshold: 0.1}); fs.writeFileSync('diff.png', PNG.sync.write(diff)); ~~~ ***** > 原文出处: [博客园-Node.js躬行记](https://www.cnblogs.com/strick/category/1688575.html) [知乎专栏-Node.js躬行记](https://zhuanlan.zhihu.com/pwnode) 已建立一个微信前端交流群,如要进群,请先加微信号freedom20180706或扫描下面的二维码,请求中需注明“看云加群”,在通过请求后就会把你拉进来。还搜集整理了一套[面试资料](https://github.com/pwstrick/daily),欢迎浏览。 ![](https://box.kancloud.cn/2e1f8ecf9512ecdd2fcaae8250e7d48a_430x430.jpg =200x200) 推荐一款前端监控脚本:[shin-monitor](https://github.com/pwstrick/shin-monitor),不仅能监控前端的错误、通信、打印等行为,还能计算各类性能参数,包括 FMP、LCP、FP 等。