写点什么

QQ 音乐商业化 Web 团队:前端工程化实践总结(三)

  • 2019-10-31
  • 本文字数:7352 字

    阅读完需:约 24 分钟

QQ音乐商业化Web团队:前端工程化实践总结(三)

前端如何做单元测试?

测试环境


和后端不同,前端有运行环境的差异性,需要考虑兼容性,如何模拟浏览器环境,如何支持到 BOM API 的调用,这些都是需要考虑的。可以考虑以下几种测试环境的解决方案:



测试工具


测试框架就是运行测试用例的工具,常见的有 Macha、Jasmine、Jest、AVA 等等。


断言库主要提供语义化方法,用于对参与测试的值做各种各样的判断。这些语义化方法会返回测试的结果,要么成功、要么失败。Node 内置断言库 assert,常见的断言库还有有 chai.js、should.js。断言库可以支持不同的开发模式,比如 chai.js 就是一个 BDD/TDD 模式的断言库。


测试覆盖率工具是用于统计测试用例对代码的测试情况,生成相应的报表,如 Istanbul(Jest 内置集成)。


Karma 是一个测试平台,可以在多种真实浏览器(e.g Chrome Firefox Safari IE 等等)中运行 JavaScript 代码,可以和很多测试框架集成,比如 Mocha、Jasmine 等等,还可以使用 Istanbul 自动生成覆盖率报告。


CI/CD


首先先看一张图片,来理解 Agile(敏捷开发)、CI(持续集成),CD(持续交付/部署)和 DevOps(开发运维一体化)涵盖的生命周期范围。CI/CD 并不等同于 DevOps,它们只是 DevOps 的部分流程中的一种解决方案。


DevOps 是 Development 和 Operations 的组合,是一种方法论,是一组过程、方法与系统的统称,用于促进应用开发、应用运维和质量保障(QA)部门之间的沟通、协作与整合。以期打破传统开发和运营之间的壁垒和鸿沟。



各个术语涵盖的生命周期范围


持续集成(Continuous Integration)中开发人员需要频繁地向主干提交代码,这些新提交的代码在最终合并到主干前,需要经过编译和自动化测试(通常是单元测试)进行验证。


CI 的好处在于可以防止分支偏离主干太久,这种持续集成可以实现产品快速迭代,但是由于要频繁集成,所以需要支持自动化构建、代码检查和测试,实现这些自动化流程是 CI 的核心。



持续集成


持续交付(Continuous Delivery)指的是,频繁地将软件的新版本,交付给质量团队或者用户,以供评审。如果评审通过,代码就进入生产阶段。


CD 是 CI 的下一步,它的目标是拥有一个可随时部署到生产环境的代码库。



持续交付


持续部署是持续交付的延伸,实现自动将应用发布到生产环境。



持续部署


公司内部常用的解决方案有:蓝盾 DevOps 平台 、orange-ci、QCI,各花入各眼,详情可以阅读这篇文章 CI 工具哪家强。


这些 CI 平台是怎样将 git 仓库中的代码变动和自动化构建流程相关联起来的呢?答案就是 Webhook,它与异步编程中“订阅-发布模型”非常类似,一端触发事件,一端监听执行。


在 web 开发过程中的 Webhook,是一种通过通常的 callback,去增加或者改变 web page 或者 web app 行为的方法。这些 callback 可以由第三方用户和开发者维持当前,修改,管理,而这些使用者与网站或者应用的原始开发没有关联。Webhook 这个词是由 Jeff Lindsay 在 2007 年在计算机科学 hook 项目第一次提出的。


  • Webhooks 是”user-defined HTTP 回调”。它们通常由一些事件触发,这里可以查看 GitHub 上面支持的 Event 类型,比如 git push、fork 等等,也就是说这些代码托管平台首先要支持 Webhook 的功能。

  • 当事件发生时,源网站可以发起一个 HTTP 请求到 Webhook 配置的 URL。通常这里配置的 URL 指向某个 CI 系统,这意味着当 git 仓库中“订阅”的事件发生时,CI 系统可以收到通知。

  • CI 系统在收到通知后就可以触发 build 等流程。


CI 自动化构建只是应用 Webhook 的一个案例,Webhook 的应用远不止这些,由于 webhook 使用 HTTP 协议,因此可以直接被集成到 web service,有时会被用来构建消息队列服务,例如一些 RESTful 的例子:IronMQ 和 RestMS。

我们的项目构建现状

介绍完了前端工程化的一些概念和技术后,下面结合我们团队中的具体项目具体分析。

1.现状分析

这是目前团队移动端基础库的项目结构:主要有 9 个模块,其中 3 个 UI 组件依赖框架。



基础库项目结构


  • 模块化


我们团队在移动端基础库的开发中,最初采用的是 IIFE 模式。从严格意义上来说,这并不是一种标准的模块化方式,只是通过闭包实现了私有数据,将数据和行为封装到一个函数内部, 通过给全局对象 window.M 添加属性来向外暴露接口,我们无法确认每个模块间的依赖关系,模块合并时还要关注依赖顺序。在新的方案中,我们引入了 ES6 的模块化标准来解决这个问题。


  • 重复开发,复制粘贴


由于业务特点,对于一些快速上线的活动页使用 Zepto 库,而对常驻页面进行了技术升级,社交团队使用了 Preact 框架,这导致基础库的开发有了两个版本,分别在不同的代码仓库维护,但实际上二者 90%+的代码都是一样的,仅仅是三个 UI 组件不同。在基于 TSW 的同构直出项目中,有些基础库方法又要在 node 端执行,这个时候也是复制粘贴了一份 m.js 放到了该项目目录中。在新的方案中,我们使用差异化的构建在一份代码仓库中分别构建出多个版本。


  • 组件 css 的问题


对于组件的样式,我们是有专门的重构组进行开发维护的,他们遵循 BEM 规范,开发组件的时候当字符串引入:


var css ='.qui_dialog__mask{position:fixed;top:0;left:0;bottom:0;right:0;}...';appendToHead(css);
复制代码


这种模式对 CSS 的开发维护很不友好,虽然我们不需要关注样式的细节,但还是每次要把重构发给我们的.css 文件中的样式 copy 出来。新方案中,我们引入 CSS module 的方案。

2.技术选型

主流构建工具


构建工具的选择,主要对比了 Webpack4、Rollupjs 和 Parcel,因为基础库的构建文件只有 js,而且从构建体积来说,rollupjs 是有绝对优势的,所以选择了 rollupjs。



主流构建工具对比


CSS 模块化


由于 CSS in JS 需要引入额外的依赖,在对比了 CSS Module 和 CSS in JS 后,我们选择 CSS Module 的方案。



CSS 模块化方案对比


单元测试框架


单元测试框架我们选择了 Jest,主要是因为开箱即用,不需要再引入断言库,生态也很好,较多用于 React 项目,而且组内的 UI 自动化测试系统是支持 Jest 的,这篇文章 Migrating from Mocha to Jest 中介绍了 Airbnb 的尝试。



单元测试框架对比


Lint 方案


由于接入了 CI 系统进行 lint 自动化检查,为了减少“无效”的 commit,我们选择了 husky+lint-staged 来进行本地代码提交前的 lint。



Lint 方案


工作流和 CI?


各种工作流中,首先需要在各自的开发分支进行开发测试,然后将代码合并到追踪生成环境的长期分支进行持续地发布部署,这意味着对这个长期分支要有完善的自动化测试能力,因为谁也不能保证 merge 的代码就一定不会有问题,目前新的方案引入了单元测试,对 UI 组件引入了基于 puppeteer 的截图测试,但一些功能缺乏在更多设备、更多平台上的自动化验证,因此我们认为在自动化测试方面的建设还不是非常完善,所以新方案接入了 CI,但是对发布外链基础库 music.js 这种会直接影响到全量业务的并没有接入,还是使用 ARS 发布,除非紧急 bug,其他的代码更改会在测试环境验证一段时间(一般 2-3 天)后才会发布外网。

我们的工程化实践

1.构建方案

新旧方案对比


首先可以看一下新旧构建方案的对比,在新方案中推广使用 ES6,增加了对代码质量的控制:代码检查+单元测试,并接入了 CI 系统。



新旧方案对比


打包方案


这是我们整体的打包方案,核心是一份源码开发维护,通过构建工具的差异化配置实现多种版本的构建。



打包方案


开发流程


这是整体的开发流程,本地开发使用 package.json 管理项目依赖,规范代码格式,接入单元测试;提交之前 git hook 设置保证代码检查和测试通过后才能提交成功;使用 QCI 自动进行项目的构建、检查和测试,通过后将 JSDOC 文档推送到文档服务器,并发布 npm 包,外链 js 还是使用 ars 发布。



开发流程

2.UI 组件开发和文档

我们选择 react-styleguide 作为 UI 组件开发调试工具以及文档生成器,这是一个组件的 MD 文件示例:

组件式引入

  • 可以提前插入 dom 结构,如果浮层中有图片的话会先加载;

  • 属性中的 visible 控制组件是否可见。


import Button from '../../basic/Button/Button'import QMDialog from './QMDialog';
class QMDialogExample extends React.Component { constructor(props) { super(props); this.state = {visible1: false} }

render() { const {visible1} = this.state; return (
<div> <Button onClick={() => { this.setState({ visible1: true }) }}>基本使用</Button> <Button onClick={() => { this.setState({ visible2: true }) }}>带头图的浮层</Button> <Button onClick={() => { this.setState({ visible3: true }) }}>传入一个react节点</Button>
<QMDialog visible={visible1} title="QQ音乐" message="这是一段描述" btn={'我知道了'} handleTap={index => { if(index === -1) { this.setState({ visible1: false }) } else { console.log('我知道了按钮被点击,index=', index) } }} /> </div>
) }}<QMDialogExample />
复制代码


react-styleguide 会根据组件的源码和这个 md 文件生成文档和 demo,开发调试阶段支持 webpack 配置 HMR,非常方便。



demo 文档截图

3.Jest 单元测试

Jest 可以设置全局的 Setup,会在所有 test 执行之前运行,也可以设置全局 Teardown,会在所有 test 执行完毕之后运行,比如这里就可以设置一些测试需要的 Global 对象、运行环境等等。describe 可以将测试用例进行分组,beforeEach、afterEach、beforeAll、afterAll 这些方法可以定义在测试用例之前或者之后运行的方法。


测试方案


根据上面介绍的打包方案和业务特点,基础库需要分别运行在 node 端和浏览器端,因此需要考虑到不同运行环境下的测试结果。


浏览器端


  • npm 命令

  • jest --coverage --config ./config/jest/music.jest.config.js

  • 设置–coverage 生成测试覆盖率。

  • 配置文件(music.jest.config.js):

  • 基于 jsdom 设置全局环境:jest-environment-jsdom-fourteen,提供浏览器端 BOM 对象。

  • 设置 cookie 操作权限的 domain:testURL: “https://y.qq.com/m/demo.html”,仅可以操作此域名下的 cookie。


module.exports = {  clearMocks: true,  coverageDirectory: "jest-coverage/coverage-music-node",  preset: null,  rootDir: '../../',  testEnvironment: "jest-environment-jsdom-fourteen",  testMatch: [    "**/tests/music-node/**/*.test.[jt]s?(x)",  ],  testURL: "https://y.qq.com/m/demo.html",  transformIgnorePatterns: []};
复制代码


Node 端


node 端和浏览器端的不同在于运行环境 testEnvironment 不同,jest 提供 jest-environment-node,我们为 node 端单独配置了 music-node.jest.config.js。


UI 组件


Jest 支持对 React App 的测试,可以采用截图测试(Snapshot Testing)、模拟 DOM 操作(DOM Testing)等方法详见文档。在组件文档和 demo 这一章节中我们已经有了组件示例,并构建了文档页,可以直接接入团队的自动化测试系统,结合使用 puppeteer 进行截图对比。


下面是对 QMDialog 组件的测试用例,首先准备一张基准图片,然后写测试流程:打开页面——点击按钮触发组件——截图对比。screeshotDiff 方法的实现参考了这篇 KM 文件通过 puppeteer 实现页面监控,图片 diff 核心算法由 pixelmatch 库实现。


const iPhone = devices['iPhone 6'];await page.emulate(iPhone);
await log("进入页面");await page.goto('http://[host]/reactui/index.html#/QMDialog', { waitUntil: 'load'});
await timeout(3000);let dom = await page.$('#QMPreload-container .rsg--preview-35 .button');
await dom.click();
await timeout(200)let diff = await screenshotDiff({ img: 'https://y.gtimg.cn/music/common/upload/t_cm3_photo_publish/1677163.png'});
if (diff > 10) { fail(); return;}
success();
复制代码


这是一次测试运行结果,从左到右依次是:基准图、测试截图、diff 结果图,screeshotDiff 根据第三张图片返回差异点的占比,由于 QMPreload 组件的特点,加载进度受网络影响,设置阈值为 10%,即只要差异率在 10%以内就可以认为是正常的。



QMPreload 测试结果


和上面 QMPreload 不同,对 QMDialog 组件的判断则是需要差异值为 0,如下面第三张图所示,没有差异点。



QMDialog 测试结果


mock


这是我们参照官网的文档接入的 mock 示例,这里需要注意__mock__的目录结构,详见文档。


.├── config├── src│   ├── music│   │   ├── utils│   │   │   ├── __mock__│   │   │      └── loadUrl.js│   │   └── loadUrl.js├── node_modules├── ...└── tests
复制代码


loadURL 方法用来动态加载 js,使用 jest.fn().mockImplementation 对 loadUrl 进行 mock,并 mock 了 window.pgvMain 和 window.pgvSendClick。


export const loadUrl = jest.fn().mockImplementation((url, callback) => {    if (/ping.js/.test(url)) {        let pvCount = 0;        window.pgvMain = jest.fn().mockImplementation( (p1, p2) => {            expect(p1).toBe('');            expect(p2.virtualDomain).toBe('y.qq.com');            if (pvCount === 1) {                expect(p2.ADTAG).toBe('all');            }            pvCount++;        })        window.pgvSendClick = jest.fn().mockImplementation( (p) => {            expect(p.hottag).toEqual(expect.stringContaining('.android'));        });    }    callback();});
export default loadUrl;
复制代码


因为使用了 ES module 的 import,需要 jest.mock 对整个模块进行 mock。对于 mock 的函数才能调用 toHaveBeenCalledTimes 的断言。


import tj from '../../src/music/tj';import loadUrl from '../../src/music/utils/loadUrl'
jest.mock('../../src/music/utils/loadUrl');
describe('【tj.js】点击上报', () => { test('tj.pv tj.sendClick', () => { expect(typeof window.pgvMain).toBe('undefined'); expect(loadUrl).toHaveBeenCalledTimes(0); tj.pv(); expect(loadUrl).toHaveBeenCalledTimes(1); expect(typeof window.pgvMain).toBe('function'); expect(window.pgvMain).toHaveBeenCalledTimes(1); tj.sendClick(); tj.sendClick('tjtag.click'); window.tj_param = { ADTAG: 'all' } tj.pv(); expect(loadUrl).toHaveBeenCalledTimes(1); expect(window.pgvSendClick).toHaveBeenCalledTimes(1); });})
复制代码


测试覆盖率


这是某一次的测试报告,上面有每个模块详细的测试覆盖率。为了便于对各个模块灵活处理,我们将每个函数细分拆成一个文件,如下面的 src/music/type 目录下的各个文件。



测试覆盖率-1



测试覆盖率-2



测试覆盖率-3


通过单元测试发现的代码 bug


这些都是我们通过单元测试发现的之前一些函数的 bug,仅举例一部分:


4.一些 Tips

声明 pkg.module


声明 pkg.module 可以让构建工具利用到 ES Moudle 的很多特性来提高打包性能,比如利用 Tree Shaking 的机制减少文件体积,这篇文章 package.json 中的 Module 字段是干嘛的有详细介绍。


sideEffects


Tree Shaking 可以在构建的时候去除冗余代码,减少打包体积,但这是一个非常危险的行为,在 webpack4 中,可以在 package.json 中明确声明该包/模块是否包含 sideEffects(副作用),从而指导 webpack4 作出正确的行为。如果在 package.json 中设置了 sideEffects: false,webpack4 会将 import {a} from 'moduleName’转换为 import a from ‘moduleName/a’,从而自动修剪掉不必要的 import,作用机制同 babel-plugin-import。这个功能亲测是很有效的


对于 rollupjs 来说,有时候 Tree Shaking 并不有效,这是官网的一段解释,大意就是静态代码分析很难,为了安全 rollupjs 可能会无法应用 Tree Shaking,这个时候建议最好还是明确 import 的 PATH,这里可以结合适应上面的 babel-plugin-import 插件。


Tree-Shaking Doesn't Seem to Be Working


插件


  • @babel/plugin-transform-runtime


这个插件可以避免每一个 js 文件分别引入胶水代码,而是整个构建文件引入一份胶水代码,减少代码体积。


  • eslint-friendly-formatter


对 eslint 的错误输出进行格式化,方便查看和定位问题。


  • babel-plugin-transform-react-remove-prop-types


由于运行时的性能原因,RN 已经在 production 模式下移除了 PropTypes,我们引入这个 babel 插件在生产模式中移除组件属性的类型校验相关的代码。


—noConflict


在将外链 js 用 rollupjs 构建成 umd 规范的时候,我们设置了–noConflict,可以解决全局变量 M 冲突的问题,类似于 jQuery.noConflict()。


(function (global, factory) {    typeof exports === 'object' && typeof module !== 'undefined' ?        factory(exports) :        typeof define === 'function' && define.amd ?            define(['exports'], factory) :            (global = global || self, (function () {                var current = global.M;                var exports = global.M = {};                factory(exports);                exports.noConflict = function () {                    global.M = current;                    return exports;                };            }()));
复制代码


本文转载自公众号云加社区(ID:QcloudCommunity)。


原文链接:


https://mp.weixin.qq.com/s/INlxjk4DnBFZynmbUkYGJA


2019-10-31 16:372134

评论

发布
暂无评论
发现更多内容
QQ音乐商业化Web团队:前端工程化实践总结(三)_行业深度_sara_InfoQ精选文章