- 有赞前端质量保障体系
前言 最近一年多一直在做前端的一些测试,从小程序到店铺装修,基本都是纯前端的工作,刚开始从后端测试转为前端测试的时候,对前端东西茫然无感,而且团队内没有人做过纯前端的测试工作,只能一边踩坑一边总结经验,然后将容易出现问题的点形成体系、不断总结摸索,最终形成了目前的一套前端测试解决方案。在此,将有赞的前端质量保障体系进行总结,希望和大家一起交流。 先来全局看下有赞前端的技术架构和针对每个不同的层次,主要做了哪些保障质量的事情: [图片] [图片] 有赞的 Node 技术架构分为业务层、基础框架层、通用组件和基础服务层,我们日常比较关注的是基础框架、通用组件和业务层代码。Node 业务层做了两件事情,一是提供页面渲染的 client 层,用于和 C 端用户交互,包括样式、行为 js 等;二是提供数据服务的 server 层,用于组装后台提供的各种接口,完成面向 C 端的接口封装。 对于每个不同的层,我们都做了一些事情来保障质量,包括: 针对整个业务层的 UI 自动化、核心接口|页面拨测; 针对 client 层的 sentry 报警; 针对 server 层的接口测试、业务报警; 针对基础框架和通用组件的单元测试; 针对通用组件变更的版本变更报警; 针对线上发布的流程规范、用例维护等。 下面就来分别讲一下这几个维度的质量保障工作。 一、UI 自动化 很多人会认为,UI 自动化维护成本高、性价比低,但是为什么在有赞的前端质量保证体系中放在了最前面呢? 前端重用户交互,单纯的接口测试、单元测试不能真实反映用户的操作路径,并且从以往的经验中总结得出,因为各种不可控因素导致的发布 A 功能而 B 功能无法使用,特别是核心简单场景的不可用时有出现,所以每次发布一个应用前,都会将此应用提供的核心功能执行一遍,那随着业务的不断积累,需要回归的测试场景也越来越多,导致回归的工作量巨大。为了降低人力成本,我们亟需通过自动化手段释放劳动力,所以将核心流程回归的 UI 自动化提到了最核心地位。 当然,UI 自动化的最大痛点确实是维护成本,为降低维护成本,我们将页面分为组件维度、页面维度,并提供统一的包来处理公用组件、特殊页面的通用逻辑,封装通用方法等,例如初始化浏览器信息、环境选择、登录、多网点切换、点击、输入、获取元素内容等等,业务回归用例只需要关注自己的用例操作步骤即可。 1、框架选择 – puppeteer[1],它是由 Chrome 维护的 Node 库,基于 DevTools 协议来驱动 chrome 或者 chromium 浏览器运行,支持 headless 和 non-headless 两种方式。官网提供了非常丰富的文档,简单易学。 UI 自动化框架有很多种,包括 selenium、phantom;对比后发现 puppeteer 比较轻量,只需要增加一个 npm 包即可使用;它是基于事件驱动的方式,比 selenium 的等待轮询更稳当、性能更佳;另外,它是 chrome 原生支持,能提供所有 chrome 支持的 api,同时我们的业务场景只需要覆盖 chrome,所以它是最好的选择。 – mocha[2] + mochawesome[3],mocha 是比较主流的测试框架,支持 beforeEach、before、afterEach、after 等钩子函数,assert 断言,测试套件,用例编排等。 mochawesome 是 mocha 测试框架的第三方插件,支持生成漂亮的 html/css 报告。 js 测试框架同样有很多可以选择,mocha、ava、Jtest 等等,选择 mocha 是因为它更灵活,很多配置可以结合第三方库,比如 report 就是结合了 mochawesome 来生成好看的 html 报告;断言可以用 powser-assert 替代。 2、脚本编写 封装基础库 封装 pc 端、h5 端浏览器的初始化过程 封装 pc 端、h5 端登录统一处理 封装页面模型和组件模型 封装上传组件、日期组件、select 组件等的统一操作方法 封装 input、click、hover、tap、scrollTo、hover、isElementShow、isElementExist、getElementVariable 等方法 提供根据 “html 标签>>页面文字” 形式获取页面元素及操作方法的统一支持 封装 baseTest,增加用例开始、结束后的统一操作 封装 assert,增加断言日志记录 业务用例 安装基础库 编排业务用例 3、执行逻辑 分环境执行 增加预上线环境代码变更触发、线上环境自动执行 监控源码变更 增加 gitlab webhook,监控开发源码合并 master 时自动在预上线环境执行 增加 gitlab webhook,监控测试用例变更时自动在生产环境执行 每日定时执行 增加 crontab,每日定时执行线上环境 [图片] [图片] [图片] [图片] 二、接口测试 接口测试主要针对于 Node 的 server 层,根据我们的开发规范,Node 不做复杂的业务逻辑,但是需要将服务化应用提供 dubbo 接口进行一次转换,或将多个 dubbo 接口组合起来,提供一个可供 h5/小程序渲染数据的 http 接口,转化过程就带来了各种数据的获取、组合、转换,形成了新的端到端接口。这个时候单单靠服务化接口的自动化已经不能保障对上层接口的全覆盖,所以我们针对 Node 接口也进行自动化测试。为了使用测试内部统一的测试框架,我们通过 java 去请求 Node 提供的 http 接口,那么当用例都写好之后,该如何评判接口测试的质量?是否完全覆盖了全部业务逻辑呢?此时就需要一个行之有效的方法来获取到测试的覆盖情况,以检查有哪些场景是接口测试中未覆盖的,做到更好的查漏补缺。 – istanbul[4] 是业界比较易用的 js 覆盖率工具,它利用模块加载的钩子计算语句、行、方法和分支覆盖率,以便在执行测试用例时透明的增加覆盖率。它支持所有类型的 js 覆盖率,包括单元测试、服务端功能测试以及浏览器测试。 但是,我们的接口用例写在 Java 代码中,通过 Http 请求的方式到达 Node 服务器,非 js 单测,也非浏览器功能测试,如何才能获取到 Node 接口的覆盖率呢? 解决办法是增加 cover 参数:–handle-sigint,通过增加 --handle-sigint 参数启动服务,当服务接收到一个 SIGINT 信号(linux 中 SIGINT 关联了 Ctrl+C),会通知 istanbul 生成覆盖率。这个命令非常适合我们,并且因此形成了我们接口覆盖率的一个模型: [代码]1. istanbule --handle-sigint 启动服务 2. 执行测试用例 3. 发送 SIGINT结束istanbule,得到覆盖率 [代码] 最终,解决了我们的 Node 接口覆盖率问题,并通过 jenkins 持续集成来自动构建 [图片] [图片] [图片] 当然,在获取覆盖率的时候有需求文件是不需要统计的,可以通过在根路径下增加 .istanbule.yml 文件的方式,来排除或者指定需要统计覆盖率的文件 [代码]verbose: false instrumentation: root: . extensions: - .js default-excludes: true excludes:['**/common/**','**/app/constants/**','**/lib/**'] embed-source: false variable: __coverage__ compact: true preserve-comments: false complete-copy: false save-baseline: false baseline-file: ./coverage/coverage-baseline.json include-all-sources: false include-pid: false es-modules: false reporting: print: summary reports: - lcov dir: ./coverage watermarks: statements: [50, 80] lines: [50, 80] functions: [50, 80] branches: [50, 80] report-config: clover: {file: clover.xml} cobertura: {file: cobertura-coverage.xml} json: {file: coverage-final.json} json-summary: {file: coverage-summary.json} lcovonly: {file: lcov.info} teamcity: {file: null, blockName: Code Coverage Summary} text: {file: null, maxCols: 0} text-lcov: {file: lcov.info} text-summary: {file: null} hooks: hook-run-in-context: false post-require-hook: null handle-sigint: false check: global: statements: 0 lines: 0 branches: 0 functions: 0 excludes: [] each: statements: 0 lines: 0 branches: 0 functions: 0 excludes: [] [代码] 三、单元测试 单元测试在测试分层中处于金字塔最底层的位置,单元测试做的比较到位的情况下,能过滤掉大部分的问题,并且提早发现 bug,也可以降低 bug 成本。推行一段时间的单测后发现,在有赞的 Node 框架中,业务层的 server 端只做接口组装,client 端面向浏览器,都不太适合做单元测试,所以我们只针对基础框架和通用组件进行单测,保障基础服务可以通过单测排除大部分的问题。比如基础框架中店铺通用信息服务,单测检查店铺信息获取;比如页面级商品组件,单测检查商品组件渲染的 html 是否和原来一致。 单测方案试行了两个框架: Jest[5] ava[6] 比较推荐的是 Jest 方案,它支持 Matchers 方式断言;支持 Snapshot Testing,可测试组件类代码渲染的 html 是否正确;支持多种 mock,包括 mock 方法实现、mock 定时器、mock 依赖的 module 等;支持 istanbule,可以方便的获取覆盖率。 总之,前端的单测方案也越来越成熟,需要前端开发人员更加关注 js 单测,将 bug 扼杀在摇篮中。 四、基础库变更报警 上面我们已经对基础服务和基础组件进行了单元测试,但是单测也不能完全保证基础库的变更完全没有问题,伴随着业务层引入新版本的基础库,bug 会进一步带入到业务层,最终影响 C 端用户的正常使用。那如何保障每次业务层引入新版本的基础库之后能做到全面的回归?如何让业务测试同学对基础库变更更加敏感呢?针对这种情况,我们着手做了一个基础库版本变更的小工具。实现思路如下: [代码]1. 对比一次 master 代码的提交或 merge 请求,判断 package.json 中是否有特定基础库版本变更 2. 将对应基础库的前后两个版本的代码对比发送到测试负责人 3. 根据 changelog 判断此次回归的用例范围 4. 增加 gitlab webhook,只有合并到合并发布分支或者 master 分支的代码才触发检查 [代码] 这个小工具的引入能及时通知测试人员针对什么需求改动了基础组件,以及这次基础组件的升级主要影响了哪些方面,这样能避免相对黑盒的测试。 第一版实现了最简功能,后续再深挖需求,可以做到前端代码变更的精准测试。 [图片] 五、sentry 报警 在刚接触前端测试的时候,js 的报错没有任何追踪,对于排查问题和定位问题有很大困扰。因此我们着手引入了 sentry 报警监控,用于监控线上环境 js 的运行情况。 – sentry[7] 是一款开源的错误追踪工具,它可以帮助开发者实时监控和修复崩溃。 开始我们接入的方式比较简单粗暴,直接全局接入,带来的问题是报警信息非常庞大,全局上报后 info、warn 信息都会打出来。 更改后,使用 sentry 的姿势是: sentry 的全局信息上报,并进行筛选 错误类型: TypeError 或者 ReferenceError 错误出现用户 > 1k 错误出现在 js 文件中 出现错误的店铺 > 2家 增加核心业务异常流程的主动上报 最终将筛选后的错误信息通过邮件的形式发送给告警接收人,在固定的时间集中修复。 [图片] [图片] 六、业务报警 除了 sentry 监控报警,Node 接口层的业务报警同样是必不可少的一部分,它能及时发现 Node 提供的接口中存在的业务异常。这部分是开发和运维同学做的,包括在 Node 框架底层接入日志系统;在业务层正确的上报错误级别、错误内容、错误堆栈信息;在日志系统增加合理的告警策略,超过阈值之后短信、电话告警,以便于及时发现问题、排查问题。 业务告警是最能快速反应生产环境问题的一环,如果某次发布之后发生告警,我们第一时间选择回滚,以保证线上的稳定性。 七、约定规范 除了上述的一些测试和告警手段之外,我们也做了一些流程规范、用例维护等基础建设,包括: 发布规范 多个日常分支合并发布 限制发布时间 规范发布流程 整理自测核心检查要点 基线用例库 不同业务 P0 核心用例定期更新 项目用例定期更新到业务回归用例库 线上问题场景及时更新到回归用例库 目前有赞的前端测试套路基本就是这样,当然有些平时的努力没有完全展开,例如接口测试中增加返回值结构体对比;增加线上接口或页面的拨测[8];给开发进行自测用例设计培训等等。也还有很多新功能探索中,如接入流量对比引擎,将线上流量导到预上线环境,在代码上线前进行对比测试;增加UI自动化的截图对比;探索小程序的UI自动化等等。 参考链接 [1] https://github.com/GoogleChrome/puppeteer [2] https://www.npmjs.com/package/mocha [3] https://www.npmjs.com/package/mochawesome [4] https://github.com/gotwarlost/istanbul [5] https://github.com/facebook/jest [6] https://github.com/avajs/ava [7] https://docs.sentry.io [8] https://tech.youzan.com/youzan-online-active-testing/
2019-04-25 - 一次在微信小程序里跑 h5 页面的尝试
前言 标题看起来有点唬人,在微信小程序里跑 h5 页面,不会又是说使用 web-view 组件来搞吧?确实,使用 web-view 组件可以达到跑 h5 页面的要求,但是 web-view 组件在使用上还是有一些限制:不支持个人类型与海外类型的小程序、不支持全屏、页面与小程序通信不方便、很多小程序接口无法直接调用等。 那么,还有其他的法子么?理论上来说还是有的。 运行环境 h5 页面是运行在 web 环境下,小程序本身也是基于 web 的,那为什么一直没有办法让 h5 在小程序里直接运行呢?原因在于小程序特有的运行环境。 以一个小程序的页面为例,通常一个小程序的页面至少包含三个文件:wxss 文件、 wxml 文件和 js 文件。其中 wxml 文件和 wxss 文件组成了页面的视图层,js 文件则属于页面的逻辑层,在小程序中,视图层和逻辑层是在不同的线程中执行的。小程序里所有页面的逻辑层都在一个 js 线程中运行,而视图层则分别在不同的 view 线程中。通常一个页面对应一个 view 线程,为了对性能的控制,不会允许用户无节制的启动 view 线程,所以也就有了页面栈数量的限制(目前最多允许打开十层页面)。 [图片] 在 view 线程中是有类似浏览器一样的环境,但是只有页面的视图层在上面跑,页面的渲染完全基于另一个 js 线程传输过来的数据。js 线程是一个纯净的 js 环境,那些你想要调的 document.getElementById、location.href 等 dom/bom 接口通通都没有,你只能在这里执行 js 代码,调用官方提供的接口,而页面的逻辑层就是在这样的线程中跑。这样问题就出来了,页面会渲染成什么样子,完全基于初始模板和数据,你想调接口来修改页面结构,门都没有~ 方案制定 小程序的运行环境如此特殊,以至于它的开发模式也比较另类,但是还是有很多人希望能够将开发过程大一统,一份代码各端运行。那么在限制如此之大的情况下,要怎么做的? 现在市面上有一些基于 react 或 vue 搞出来的工具,它们要求你使用 react 或者 vue 来写页面,由构建工具来编译到各个环境上可运行的目标代码,因为 react 和 vue 本身是基于数据来驱动的组件化框架,在一定程度上限制开发者直接调用 dom/bom 接口,所以可以比较方便的实现代码的编译转化。 PS:因为小程序本身的限制,react 和 vue 的小部分功能也是无法做到完全兼容的,不过大部分的实现都是 OK 的。 不过这不是我想要的,因为它们虽然实现得很漂亮,但是仍有不小的开发局限,你必须选择 vue、react 等框架的其中一种来使用。我想要的是更原生、更底层的大一统方案,底层到除了 vue、react 之外,你甚至可以在小程序中使用 jQuery。 目标太大会扯着蛋,至少得先有一个思路才行。 回到先前提到,小程序的逻辑层是在一个纯净的 js 线程中跑,那里没有 dom/bom 接口,只能运行页面逻辑层的代码,那么我们想办法在逻辑层建一棵 dom 树,把基本的 dom/bom 接口都模拟出来不就行了么? [图片] 乍一想好像可以,但是这里隐藏着一个问题:逻辑层中 dom 树的变更要如何转变成数据并更新到视图层呢? 这里很重要的一点:小程序提供了自定义组件,并且支持递归引用。也就是说,我们可以将 h5 中的 div、span、ul 等 dom 节点转成自定义组件,逻辑层里的每个 dom 节点都会对应一个视图层中的自定义组件实例,节点更新了,我们找到对应的自定义组件实例进行更新即可。 开始实施 方案很简单,但是实现起来的话细节和坑特别多,下面且听我挑几个关键的 trick 来介绍: dom 树的模拟 想要在小程序的逻辑层模拟出一个 dom 树,本质上和建立一棵虚拟树类似,因为它并不是真实的 dom 树。整个流程简单来说是这样的: [图片] 不管是页面中的静态 html 内容还是使用 innerHTML 等接口动态插入的 html 内容都可以走上面的流程来进行 dom 树的创建。dom 树创建比较简单,只是细节比较多,此处的问题是建立好的 dom 树要如何渲染到视图层呢?上面提到了一个关键点:将 dom 节点转成小程序的自定义组件。 此处再重复一次表强调:小程序的自定义组件支持递归引用! 什么叫递归引用?感觉用文字表达不如直接用代码说明来的快,我们先直接编写一个名为 element 的自定义组件: [代码]// element.js Component({ data: { children: ['div', 'span'], } }) [代码] [代码]<!-- element.wxml --> <block wx:for="{{children}}"> <div wx:if="{{item === 'div'}}"></div> <span wx:elif="{{item === 'span'}}"></span> </block> [代码] [代码]{ component: true, usingComponents: { "div": "./element", "span": "./element" } } [代码] 看代码应该很容易就能明白,所谓递归引用就是自己可以作为自己的一个子组件使用,这样可以很方便地将 dom 树给渲染在逻辑层,并且也不需要我们为每种节点标签给编写一个自定义组件,如上述例子,只需要编写一个 element 自定义组件基本可以覆盖所有标签。 同样,根据上述例子可以很明显的看出,每个节点只负责渲染自己的子节点,其他如孙子节点等后代节点一概不管,这样就避免了在更新时需要将整棵树结构传递给视图层的麻烦问题。 对于从逻辑层向视图层传递数据,小程序里有个数据包大小的限制,此处若同步整棵树结构,一来可能爆了这个限制,二来会传递很多无用数据,增大更新开销。 建立联系 创建好 dom 树后,下一步就是要想办法将小程序创建的自定义组件实例和我们创建的 dom 节点给一一联系起来。首先,我们得给每个 dom 节点生成一个节点 id 用于区分,根节点的节点 id 可以在构建过程中直接生成,假定根节点就是 body 节点,那么生成的页面的 wxml 和 json 中大概是这样的: [代码]<!-- page.wxml --> <body data-private-node-id="node-id-xxxxxx"></body> [代码] [代码]{ usingComponents: { "body": "../components/element", } } [代码] body 节点同样是用上面例子中的 element 自定义组件来渲染,修改下 element 自定义组件的 js 文件: [代码]// element.js Component({ data: { children: []. }, attached() { const nodeId = this.dataset.privateNodeId // 节点 id } }) [代码] 如上述代码所述,在自定义组件的 attached 生命周期中即可拿到节点 id,然后根据此节点 id 从 dom 树中找到对应的 dom 节点,两者之间即可建立关系。 找到根节点后,后续节点就好办了。根节点对应的自定义组件实例在和 dom 节点建立联系后,就可以通过 dom 节点拿到子节点列表,进而开始渲染子节点。 由上可知,每个节点只负责渲染自己的子节点,每个节点的渲染流程都和根节点一样: 拿到节点 id 和 dom 节点建立联系 通过 dom 节点拿到子节点列表 渲染子节点 根据这个逻辑修改一下上述例子中的 element 自定义组件的代码: [代码]// element.js Component({ data: { children: []. }, attached() { const nodeId = this.dataset.privateNodeId // 节点 id const domNode = getDomNode(nodeId) // 根据节点 id 找到 dom 节点 const children = domNode.children // 拿到子节点列表 // 渲染子节点 this.setData({ children, }) } }) [代码] [代码]<!-- element.wxml --> <block wx:for="{{children}}"> <div wx:if="{{item.tagName === 'DIV'}}" data-private-node-id="{{item.nodeId}}"></div> <span wx:elif="{{item.tagName === 'SPAN'}}" data-private-node-id="{{item.nodeId}}"></span> </block> [代码] 如此递归下去,即可将自定义组件实例和 dom 节点一一联系起来,同时将整棵 dom 树渲染出来。 渲染信息的同步 假若逻辑层想要获取视图层的渲染信息,比如调 [代码]getComputedStyle[代码] 接口获取节点样式、使用 [代码]clientWidth[代码] 或取节点宽度等,因为逻辑层本身只是模拟了 dom 树,并没有实现布局和渲染的模拟(理论上可以实现,只是开销巨大,而且很难与小程序本身环境的渲染结果对齐),所以这块实现起来很头疼。 目前的方案上使用小程序的 [代码]selectorQuery[代码] 接口来拉取渲染信息,因为此接口只能异步拉取,所以没法完整模拟渲染信息的即时同步。为了尽可能做到相对同步,在初始渲染完成后尝试拉取一次渲染信息,之后在每次触发节点更新后再异步拉取渲染信息,同时提供一个异步接口给某些需要即时拉取渲染信息的场景中使用。 全局对象的处理 上面提到小程序的逻辑层是跑在一个 js 线程中,这个 js 线程是一个纯净的 js 线程,别说那些 bom、dom 接口了,连一个正经的全局对象都没有。 小程序中其实有 global 对象,但是和我们熟悉的 nodejs 中的 global 对象实现不一样,所以此处按下不提。 做前端开发的同学们应该都知道,h5 环境中声明在全局的变量/函数会挂在 window 下,在页面的其他地方是可以使用或者是通过 [代码]window.xxx[代码] 的方式来访问的。在小程序的运行环境中这些是不存在的,那就意味着会有下述这些问题: [代码]// a.js function xxx() {} window.yyy = function() {} // b.js window.xxx() // 报 xxx 不存在 yyy() // 报 yyy 不存在 [代码] 这个问题很严重,要知道我们很多时候访问一些对象都会忽略掉 [代码]window.[代码] 这个前缀,比如使用 location 对象的时候。也就是说,必须对这些 js 做一遍后处理,强制将全局函数挂在 window 下,强制通过使用 [代码]window.xxx[代码] 的方式访问全局变量/函数。经过后处理后,上述例子会变成下面这样: [代码]// a.js function xxx() {} window.yyy = function() {} window.xxx = xxx // 强制挂在 window 下 // b.js window.xxx() // 正常执行 window.yyy() // 强制补充 window 前缀,正常执行 [代码] 嗯,完美解决! 其他 上面提到的是整体的实现框架,也简单的提到几个坑。当然,这只不过是冰山一角,事实上需要兼容的坑、无法按照标准实现的接口和完全无法实现的特性犹如满天星,数都数不清,幸运的是我们并不完全依赖到所有的接口,有些时候我们仅仅需要用到像 querySelector、事件系统、异步请求等接口就可以构造出大部分 h5 页面,而这些大多是能做到兼容的,所以对于某些不太复杂的场景来说还是可行的。 工具试用 按照上面的思路实现了一个差不多的构建工具,暂且命名为 [代码]h5-to-miniprogram[代码],只看名字就知道这是个想干嘛的工具,不过这终究是个玩票性质的尝试,有兴趣的同学也可以点击此处翻看源码~
2019-02-27