前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >用 jest 单元测试改善老旧的 Backbone.js 项目

用 jest 单元测试改善老旧的 Backbone.js 项目

作者头像
江米小枣
发布2020-06-16 17:09:09
3.4K0
发布2020-06-16 17:09:09
举报
文章被收录于专栏:云前端云前端

对于早期的前端 SPA 项目,Backbone.js + Require.js 是一种常见的技术组合,分别提供了基础的 MVC 框架和模块化能力。

对于这样的既有项目,在之前的文章中也进行过分析,常常面临依赖不清、封装混乱,以及缺乏测试等问题;对之进行维护和新需求开发时,结合其本身特点,在 TDD 的方式下进行渐进的改善,而非推倒重来,无疑是个可行的办法。

本文将尝试用一个重构实例来抛砖引玉,讲解如何对其应用较新的 jest 测试框架,并用 ES6 class 等新手段升级 Backbone.View 视图组件和改善页面结构,希望能对类似项目的改善起到开启思路的作用。

Backbone.js / Require.js 技术栈回顾

Require.js 模块化

首先说 Require.js,在没有 webpack 的日子里,这是最常见的模块化管理工具。

其本身可以提供 AMD 规范的 JS 模块,并提供了通过插件加载文本模板等能力。

在实际的项目中,我们采用了 ES6 语法和 ESM 模块规范来编写源文件,并借助 babel 将其转译为 UMD 模块;最后通过 Require.js 提供的优化工具 r.js 来打包,并由 Require.js 本身在浏览器里实现模块的加载。

当然,采用 ES6语法 和 babel 并非一定必要,AMD 也是可以实现测试的。

Backbone.js

不同于提供整套方案的 Angular 的是, Backbone.js 提供了一个非常基础和自由的 MVC 框架结构,不仅可以用多种方式组织项目,也可以自由替换其中的某一部分。

其主要功能模块包括:

  • Events:提供一系列事件的绑定和触发等功能
  • Model: 对数据或状态的转化、校验、计算派生值、提供访问控制等,也负责数据的远程同步等,并有事件触发机制;作用类似于 MobX
  • Collection: Model 的集合
  • Router: 提供了 SPA 的前端路由功能,支持 hashChange 和 pushState 两种方式
  • Sync: 一些远程请求的方法
  • View: 可以拼装模板数据、绑定事件等的视图组件

在我们的实际项目中,视图层同时支持了 Backbone.View 和早期的 react@13,这也正体现了其灵活之处。

通常的 Backbone 项目也可以忽略文中涉及 react 的部分。

升级测试框架

和之前文章中的例子相同,本次依然采用 Jest 作为测试框架。

原有用例

早期的项目中其实是有一些单元测试代码的,主要是用 Jasmine 对部分 model/collection 进行了测试。由于 Jest 内置了 Jasmine2,所以这部分的语法问题不大,基本可以无痛迁移。

早先测试的主要问题在于:

  • 一是没有整合到工作流中,采用单独的网页作为载体,久而久之就会遗忘这个步骤,用例可能失效,新加入的团队成员也不会注意到这项工作的存在
  • 二是当时对 model/collection 的单元测试并不严谨,依赖了提供 mock 数据的 php 服务器环境
  • 三是由于视图层没有很好的组件化,从而缺乏对视图组件的测试

jest for Backbone 的实践

jest 是比较新的测试框架,默认零配置,但也提供了灵活的适配方法,可以适应各种项目,包括 Backbone.js 的情况。

这位 @captbaritone 小哥提供了一个很好的讲解视频 (需要科学上网 https://www.youtube.com/watch?v=BwzjVNTxnUY&t=15s),并且配上了实例代码(https://github.com/captbaritone/tdd-jest-backbone)。

配置必要的依赖和映射

代码语言:javascript
复制
//package.json"scripts": {
   "tdd": "cross-env NODE_ENV=test jest --watch",
   "test": "cross-env NODE_ENV=test jest",
   ...
},
"devDependencies": {
   "babel-cli": "^6.0.0",
   "babel-core": "^6.26.0",
   "babel-eslint": "^6.1.2",
   "babel-jest": "^22.1.0",
   "babel-preset-es2015": "^6.24.1",
   "babel-preset-react": "^6.24.1",
   "babel-preset-stage-1": "^6.24.1",
   "cross-env": "^5.1.3",
   "enzyme": "^3.3.0",
   "enzyme-adapter-react-13": "^1.0.3",
   "jest": "^22.1.4",
   "jquery": "^3.1.1",
   "regenerator-runtime": "^0.11.1",
   "sinon": "^4.2.2",
   "grunt-run": "^0.8.0",
   ...
},
  1. 配置两种 npm script,分别用于开发时实时运行测试和 build 时运行测试
  2. 目标项目中,其实是用 babel 5 做的 ES6 转译;但是由于之前的源代码已经全部采用了 ES6 语法开发(部分初始 AMD 代码也做过自动转化),所以我们完全可以在测试时采用较新的 babel 6

加入对老版本 react 的支持

代码语言:javascript
复制
//.babelrc{
 "env": {
   "test": {
     "presets": [
       "es2015", "stage-1", "react"
     ],
     "plugins": []
   }
 }
}
代码语言:javascript
复制
//jest.config.jsmoduleNameMapper: {
   "unmountMixin": "react-unmount-listener-mixin",
   ...
},
...
  1. 根据目标项目的情况采用了 enzyme-adapter-react-13 做适配
  2. 用 cross-env 设置环境变量 test,从而配置出适用于 jest 的 .babelrc 文件,且不影响生产环境
  3. 根据项目中的具体情况,按原来的规则做好组件名称的映射

将单元测试加入到 build 任务

如果只写好了测试,而单独存在,只能用 npm test 执行的话,那就重蹈了原来的覆辙;这里借助 grunt-run 插件将其加入已有的 grunt build 工作流:

代码语言:javascript
复制
// Gruntfile.jsbuild: function() {
   grunt.task.run([
       'run:test',
       'eslint',
       ...
   ]);
},run: {
   test: {
       cmd: /^win/.test(process.platform) ? 'npm.cmd' : 'npm',
       args: ['test']
   }
},

这样在之后的 build 任务中,一旦有单元测试未通过,整个流程将停止执行。

测试 model 和 collection

一个 model 大概长这个样子:

代码语言:javascript
复制
import Backbone from 'backbone';
   
const CardBinding = Backbone.Model.extend({
   urlRoot: _appFacade.ajaxPrefix + '/card/binding',  
   
   defaults: {
       identity: null,
       password: null
   },
   
   validate: function(attrs){
       if ( !attrs.identity ) {
           return CardBinding.ERR_NO_IDENTITY;
       }
       if ( !/^\d{6}$/.test(attrs.password) ) {
           return CardBinding.ERR_WRONG_PASSWORD;
       }
   }
});
   
CardBinding.ERR_NO_IDENTITY = 'err_no_identity';
CardBinding.ERR_WRONG_PASSWORD = 'err_wrong_password';
   
export default CardBinding;

在测试中注入全局 url 前缀

可以发现 model 中依赖了以个全局变量中的属性 _appFacade.ajaxPrefix

首先编写一个假的全局对象:

代码语言:javascript
复制
// __test__/fakeAppFacade.jsvar facade = {
   ajaxPrefix: 'fakeAjax',
   ...
};window._appFacade = facade;
module.exports = facade;

测试套件中,在 model 之前引入这个模块就可以了:

代码语言:javascript
复制
// __test__/models/CardBinding.spec.jsimport fakeAppFacade from '../fakeAppFacade';
import Model from "models/CardBinding";

用 sinon 拦截异步请求

搞定了异步请求的地址,自然要拦截真正的请求;

代码语言:javascript
复制
// backbone.js// Set the default implementation of `Backbone.ajax` to proxy through to `$`.
// Override this if you'd like to use a different library.
Backbone.ajax = function() {
   return Backbone.$.ajax.apply(Backbone.$, arguments);
};
...

Backbone 中的请求,包括 Backbone.sync / model.fetch() 等, 本质上还是调用的 jQuery 中的 $.ajax 方法(默认情况下),也就是传统的 xhr 方式,使用 sinon 就可以很好的胜任这种暗度陈仓的工作:

代码语言:javascript
复制
it('should fetch from server', function(){
   //模拟的返回数据
   const server = sinon.createFakeServer();
   server.respondImmediately = true;
   server.respondWith(
       "GET",
       `${fakeAppFacade.ajaxPrefix}/card`,
       [
           200,
           {"Content-Type": "application/json"},
           JSON.stringify({
               errcode: 0,
               errmsg: 'ok',
               result: {
                   "docTitle": "i am a member card",
                   "card": {
                       "id": 123
                   }
               }
           })
       ]
   );   model = new Model(mockData);
   model.fetch();
   
   expect(model.get('docTitle')).toEqual("i am a member card");
   expect(model.get('card')).not.toBeNull();
   expect(model.get('card').id).toEqual(123);   //恢复请求原状
   server.restore();
});

校验操作的测试

调用 Backbone.Model 实例的 isValid() 方法,会得到数据是否有效的布尔值结果,同时触发内部的 validate() 方法,并更新其 validationError 的值;利用这些特性,我们可以做如下测试:

代码语言:javascript
复制
//model中validate(attrs) {
   const re = new RegExp(attrs.field_username.pattern);
   if ( !re.test(attrs.field_username.value) ) {
       return attrs.field_username.invalid;
   }
},
...
代码语言:javascript
复制
//spec中it('should validate username', function(){
   let mock1 = {
       field_username: {
           pattern: '^[\u4E00-\u9FA5a-zA-Z0-9_-]{2,}$',
           invalid: '请正确填写姓名'
       },
       field_birth: {}
   };   model = new Model(mock1);   model.set({
       'field_username': Object.assign(
           mock1.field_username,
           {value: '尉迟恭hello123'}
       )
   });
   expect(model.isValid()).toBeTruthy(); //trigger model.validate()
   expect(model.validationError).toBeFalsy();   model.set({
       'field_username': Object.assign(
           mock1.field_username,
           {value: '尉'}
       )
   });
   expect(model.isValid()).toBeFalsy();
   expect(model.validationError).toEqual('请正确填写姓名');   model.set({
       'field_username': Object.assign(
           mock1.field_username,
           {value: '尉~22'}
       )
   });
   expect(model.isValid()).toBeFalsy();
   expect(model.validationError).toEqual('请正确填写姓名');
});

collection 的测试和 model 相比并无特别,不再赘述

view 之必然的 testable 组件化

开篇提到过,项目中以前的过时测试用例中,是缺少 view 视图层部分的。

这一方面是囿于当时测试意识的不足,更主要的原因是没能很好解决组件化的问题。

要对 view 进行测试,就得将其拆分重构为功能明确、便于复用的各种小型组件。

Backbone.View 的 ES6 class 进化

首先进行的,是类似于 React.createClass 向 class extends Component 的进化,Backbone.View 也是可以华丽转身的。

传统的 view 写法是这样的:

代码语言:javascript
复制
const MyView = Backbone.View.extend({   id: 'myView',
   urlBase: _appFacade.ajaxPrefix + '/info',   events: {
       'click .submit': 'onSubmit'
   },   render: function() {
       ...
   },
       
   onSubmit: function () {
       ...
   }});

采用 ES6 class 的写法,则可能是:

代码语言:javascript
复制
class MyView extends Backbone.View {
   get className() {
       return 'myComp';
   }
   get events() {
       return {
           "click .multi": "onShowShops"
       };
   }
   render() {
       const html = _.template(tmpl)(data);
       this.$el.html(html);
       return this;
   }
   onShowShops(e) {
       let cityId = e.currentTarget.id;
       if (cityId){
           ...
       }
   }
}

组件的提取

目标项目的很多页面,没有合理的封装出子组件,而仅仅是把需要复用部分的 html 提取成模板,在本页面“拼装”多个子模板,或和其他页面复用。这部分归因于 Backbone 的“过分自由”,官网或者当时的通用实践中并未给出很好的组件化方案,只是停留在用依赖的 underscore 实现 _.template() 的阶段。

这其实和早期微信小程序面临的困境是一样的:由于缺乏组件化方法,只能在 js/wxml/wxss 几个层面分别封装模块;而直到 2017 年底(1.6.3 版本),小程序才有了自己的 component 组件化方案。

另一个难点在于,Backbone.View 的 constructor / initialize “构造函数”中,并不能接受自定义的 props 参数。

解决的办法是进行一定的外层封装:

代码语言:javascript
复制
// components/Menu.jsimport {View} from 'backbone';
...const Menu = ({data})=>{
   class ViewClass extends View {
       get className() {
           return 'menu_component';
       }
       render() {
           const html = template(tmpl)(data);
           this.$el.html(html);
           return this;
       }
   }
   return ViewClass;
};

也可以“继承”一个 View:

代码语言:javascript
复制
// components/MenuWithQRCode.jsimport Menu from './Menu';
...const onQRCode = (e)=>{
   ...
};const MenuWithQRCode = ({data})=>{
   const MenuView = Menu({data});
   class QRMenuView extends MenuView {
       get events() {
           return {
               "click #qrcode": onQRCode
           }
       }
   }
   return QRMenuView;
};

在页面中使用时,先传参获取到真正的 Backbone.View 组件:

代码语言:javascript
复制
const Menu1View = MenuWithQRCode({
   data: {
       styleName: "menu1",
       list: tdata.menu1,
   }
});

再手动调用其 render() 方法并加入页面视图的 DOM 中:

代码语言:javascript
复制
this.$el.find('.menu1_wrapper').replaceWith(
   (new Menu1View).render().$el
);

这样就在很大程度上实现了 Backbone.View 组件的封装和嵌套。

测试 Backbone.View 组件

比之于测试 react 还需要 enzyme 等的支持,测试 Backbone.View 其实要简单许多,只需要获取到其 $el 属性,调用 jQuery 的惯有方法即可:

代码语言:javascript
复制
it("应该在不显示门店时渲染为空", function() {
   const ViewClass1 = CardShops({});
   const comp1 = (new ViewClass1).render();
   
   expect(comp1.$el.find('.single').length).toEqual(0);
   expect(comp1.$el.find('.multi').length).toEqual(0);
});

对方法调用的测试

自然还是用 sinon 来做:

代码语言:javascript
复制
it('应正确响应事件回调并加载子模板', function() {   //模拟的返回数据
   const server = sinon.createFakeServer();
   server.respondImmediately = true; //立即返回
   server.respondWith(
       "GET",
       `${fakeAppFacade.ajaxPrefix}/privilege/222`,
       [
           200,
           {"Content-Type": "application/json"},
           JSON.stringify({
               errcode: 0,
               errmsg: 'ok',
               result: {
                   ...
               }
           })
       ]
   );   const spy = sinon.spy();
   const spy2 = sinon.spy();   const ViewClass1 = CardPrivileges({
       data:{
           title: "商家优惠活动",
           list: [{
               "id": 111,
               "title": 'VIP会员专享8折优惠',
               "icon": 'assets_icon_card_vip'
           },{
               "id": 222,
               "title": '开卡送可乐一听开卡送可乐一听哈',
               "icon": 'assets_icon_card_priv1'
           },{
               "id": 333,
               "title": '满200送50元代金券',
               "icon": 'assets_icon_card_priv2',
               "hasNew": true
           }]
       },
       privOpenHandler: spy,
       detailLoadedHandler: spy2,
       responseHandler: (data,callback)=>callback(data)
   });
   
   const comp = (new ViewClass1).render();   //模拟点击第二个,期望得到用例上方的假数据
   comp.$el.find('.privileges>li:nth-of-type(2)>a').click();   expect(spy.callCount).toEqual(1);
   expect(spy2.callCount).toEqual(1);
   
   expect(comp.$el.find('.privileges>li:nth-of-type(2)').hasClass('opened')).toBeTruthy();
   expect(comp.$el.find('.privileges>li:nth-of-type(2) .cont_common').length).toEqual(1);
   expect(comp.$el.find('.cont_common li:nth-of-type(3)').html()).toEqual("有效期截至2014-09-20");   server.restore();
});

处理用 require.js 的 text 插件引入的模板

Backbone.js + Require.js 在测试中的一个小问题是:页面或组件中一般会用 text.js 组件引入模板,其 ES6 形式为:

代码语言:javascript
复制
import cardTmpl from 'text!templates/card.html';

因为测试环境没有 require.js 或者 webpack 的加持,我们只能想办法将其劫持,并将正确的结果注入对应的测试模块中;

要实现这一目的,就要用到 jest.doMock() 方法,其缺点是用了这个就不能用 ES6 的 import 语法了,配置和使用简要说明如下:

代码语言:javascript
复制
// jest.config.jsmoduleNameMapper: {
   "text!templates/(.*)": "templates/$1",
   ...
},
...
代码语言:javascript
复制
// __test__/TmplImporter.jsconst fs = require('fs');
const path = require('path');export default {
   import: tmplArrs=>tmplArrs.forEach(tmpl=>{
       jest.doMock(tmpl, ()=>{
           const filepath = path.resolve(__dirname, '../src/', tmpl);
           return fs.readFileSync(filepath, {encoding: 'utf8'});
       });
   })
}
代码语言:javascript
复制
// __test__/components/CardFace/index.spec.jsconst tmplImporter = require('../../TmplImporter').default;
tmplImporter.import([
   'templates/card/card.html',
   // 可以有多个,但凡该测试套件中用到的都写上
]);// 因为无法用 ES6 import,注意写上 .default
const CardFace = require('components/CardFace/index').default;

总结

  • jest 灵活的配置能力,使其能方便的应用于各种类型既有项目的 TDD 开发和重构
  • 之前的其他测试框架下的用例,可以快速迁移到 jest 中
  • Backbone.View 视图组件在经过 ES6 升级和合理封装后,可以明显改善页面的整洁度,并顺利应用于单元测试
  • 可以用 sinon.createFakeServer() 拦截 Backbone.Model 中的异步请求
  • 原来用 Require.js 下的 text.js 组件引入的模板,也可以用 jest.doMock() 很好的支持
  • 将单元测试任务加入原有的 build 工作流,可以保证相关代码之后的持续有效

(end)

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2018-03-07,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 云前端 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • Backbone.js / Require.js 技术栈回顾
    • Require.js 模块化
      • Backbone.js
      • 升级测试框架
        • 原有用例
          • jest for Backbone 的实践
            • 配置必要的依赖和映射
              • 加入对老版本 react 的支持
                • 将单元测试加入到 build 任务
                • 测试 model 和 collection
                  • 在测试中注入全局 url 前缀
                    • 用 sinon 拦截异步请求
                      • 校验操作的测试
                      • view 之必然的 testable 组件化
                        • Backbone.View 的 ES6 class 进化
                          • 组件的提取
                          • 测试 Backbone.View 组件
                            • 对方法调用的测试
                              • 处理用 require.js 的 text 插件引入的模板
                              • 总结
                              相关产品与服务
                              云开发 CloudBase
                              云开发(Tencent CloudBase,TCB)是腾讯云提供的云原生一体化开发环境和工具平台,为200万+企业和开发者提供高可用、自动弹性扩缩的后端云服务,可用于云端一体化开发多种端应用(小程序、公众号、Web 应用等),避免了应用开发过程中繁琐的服务器搭建及运维,开发者可以专注于业务逻辑的实现,开发门槛更低,效率更高。
                              领券
                              问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档