用 jest 單元測試改善老舊的 Backbone.js 專案

江米小棗tonylua發表於2018-02-28

對於早期的前端 SPA 專案,Backbone.js + Require.js 是一種常見的技術組合,分別提供了基礎的 MVC 框架和模組化能力。

對於這樣的既有專案,在之前的文章中也進行過分析,常常面臨依賴不清、封裝混亂,以及缺乏測試等問題;對之進行維護和新需求開發時,結合其本身特點,在 TDD 的方式下進行漸進的改善,而非推倒重來,無疑是個可行的辦法。

本文將嘗試用一個重構例項來拋磚引玉,講解如何對其應用較新的 jest 測試框架,並用 ES6 class 等新手段升級 Backbone.View 檢視元件和改善頁面結構,希望能對類似專案的改善起到開啟思路的作用。

關於測試、重構的各種概念,不再重複介紹;請先參閱以下文章:

Backbone.js / Require.js 技術棧回顧

Require.js 模組化

用 jest 單元測試改善老舊的 Backbone.js 專案

首先說 Require.js,在沒有 webpack 的日子裡,這是最常見的模組化管理工具。

其本身可以提供 AMD 規範的 JS 模組,並提供了通過外掛載入文字模板等能力。

在實際的專案中,我們採用了 ES6 語法和 ESM 模組規範來編寫原始檔,並藉助 babel 將其轉譯為 UMD 模組;最後通過 Require.js 提供的優化工具 r.js 來打包,並由 Require.js 本身在瀏覽器裡實現模組的載入。

當然,採用 ES6語法 和 babel 並非一定必要,AMD 也是可以實現測試的。

關於模組化的發展可以參閱這篇文章 (https://mp.weixin.qq.com/s/WG_n9t4E4q0kBWczkSEdEA)

Backbone.js

用 jest 單元測試改善老舊的 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)。

配置必要的依賴和對映

//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 的支援

//.babelrc

{
  "env": {
    "test": {
      "presets": [
	    "es2015", "stage-1", "react"
	  ],
	  "plugins": []
    }
  }
}
複製程式碼
//jest.config.js

moduleNameMapper: {
	"unmountMixin": "react-unmount-listener-mixin",
	...
},
...
複製程式碼
  1. 根據目標專案的情況採用了 enzyme-adapter-react-13 做適配
  2. 用 cross-env 設定環境變數 test,從而配置出適用於 jest 的 .babelrc 檔案,且不影響生產環境
  3. 根據專案中的具體情況,按原來的規則做好元件名稱的對映

將單元測試加入到 build 任務

如果只寫好了測試,而單獨存在,只能用 npm test 執行的話,那就重蹈了原來的覆轍;這裡藉助 grunt-run 外掛將其加入已有的 grunt build 工作流:

// Gruntfile.js

build: function() {
	grunt.task.run([
		'run:test',
		'eslint',
		...
	]);
},

run: {
	test: {
		cmd: /^win/.test(process.platform) ? 'npm.cmd' : 'npm',
		args: ['test']
	}
},
複製程式碼

這樣在之後的 build 任務中,一旦有單元測試未通過,整個流程將停止執行。

測試 model 和 collection

一個 model 大概長這個樣子:

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

首先編寫一個假的全域性物件:

// __test__/fakeAppFacade.js

var facade = {
	ajaxPrefix: 'fakeAjax',
	...
};

window._appFacade = facade;
module.exports = facade;
複製程式碼

測試套件中,在 model 之前引入這個模組就可以了:

// __test__/models/CardBinding.spec.js

import fakeAppFacade from '../fakeAppFacade';
import Model from "models/CardBinding";
複製程式碼

用 sinon 攔截非同步請求

搞定了非同步請求的地址,自然要攔截真正的請求;

// 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 就可以很好的勝任這種暗度陳倉的工作:

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 的值;利用這些特性,我們可以做如下測試:

//model中

validate(attrs) {
	const re = new RegExp(attrs.field_username.pattern);
	if ( !re.test(attrs.field_username.value) ) {
		return attrs.field_username.invalid;
	}
},
...
複製程式碼
//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 寫法是這樣的:

const MyView = Backbone.View.extend({

	id: 'myView',
	urlBase: _appFacade.ajaxPrefix + '/info',

	events: {
		'click .submit': 'onSubmit'
	},

	render: function() {
		...
	},
		
	onSubmit: function () {
		...
	}

});
複製程式碼

採用 ES6 class 的寫法,則可能是:

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 引數。

解決的辦法是進行一定的外層封裝:

// components/Menu.js

import {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:

// components/MenuWithQRCode.js

import 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 元件:

const Menu1View = MenuWithQRCode({
	data: {
		styleName: "menu1",
		list: tdata.menu1,
	}
});
複製程式碼

再手動呼叫其 render() 方法並加入頁面檢視的 DOM 中:

this.$el.find('.menu1_wrapper').replaceWith(
	(new Menu1View).render().$el
);
複製程式碼

這樣就在很大程度上實現了 Backbone.View 元件的封裝和巢狀。

測試 Backbone.View 元件

比之於測試 react 還需要 enzyme 等的支援,測試 Backbone.View 其實要簡單許多,只需要獲取到其 $el 屬性,呼叫 jQuery 的慣有方法即可:

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 來做:

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 形式為:

import cardTmpl from 'text!templates/card.html';
複製程式碼

因為測試環境沒有 require.js 或者 webpack 的加持,我們只能想辦法將其劫持,並將正確的結果注入對應的測試模組中;

要實現這一目的,就要用到 jest.doMock() 方法,其缺點是用了這個就不能用 ES6 的 import 語法了,配置和使用簡要說明如下:

// jest.config.js

moduleNameMapper: {
	"text!templates/(.*)": "templates/$1",
	...
},
...
複製程式碼
// __test__/TmplImporter.js

const 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'});
		});
	})
}
複製程式碼
// __test__/components/CardFace/index.spec.js

const 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)

----------------------------------------

長按二維碼或搜尋 fewelife 關注我們哦

用 jest 單元測試改善老舊的 Backbone.js 專案

相關文章