Improve the old Backbone.js project with jest unit testing

Improve the old Backbone.js project with jest unit testing

For the early front-end SPA projects, Backbone.js + Require.js is a common technology combination, which provides a basic MVC framework and modular capabilities respectively.

For such existing projects, they have also been analyzed in previous articles, and they often face problems such as unclear dependencies, confusing packaging, and lack of testing. When maintaining them and developing new requirements, they are combined with their own characteristics. It is undoubtedly a feasible way to make gradual improvement under the method instead of overthrowing and starting over.

In this article, I will try to use a refactoring example to start a discussion, explain how to apply the newer jest test framework to it, and use new methods such as ES6 class to upgrade the Backbone.View view components and improve the page structure, hoping to start the improvement of similar projects The role of ideas.

The various concepts of testing and refactoring will not be repeated; please refer to the following articles first:

Backbone.js/Require.js technology stack review

Require.js modularity

First of all, Require.js, in the days without webpack, this is the most common modular management tool.

It itself can provide AMD standard JS modules, and provides the ability to load text templates through plugins.

In the actual project, we used the ES6 syntax and ESM module specifications written in the source file, and with the babel be translated into UMD module; and finally by Require.js optimization tools provided r.jsto package, the browser itself by Require.js The loading of modules is implemented in the device.

Of course, the use of ES6 syntax and babel is not necessary, AMD can also achieve testing.

For the development of modularity, please refer to this article (https://mp.weixin.qq.com/s/WG_n9t4E4q0kBWczkSEdEA)

Backbone.js

Unlike Angular, which provides a complete set of solutions, Backbone.js provides a very basic and free MVC framework structure, which not only can organize projects in a variety of ways, but also freely replace certain parts of them.

Its main functional modules include:

  • Events: Provides functions such as binding and triggering a series of events
  • Model: Transform, verify, calculate derived values, provide access control, etc. to data or state, and also be responsible for remote synchronization of data, etc., and have an event trigger mechanism; its role is similar to MobX
  • Collection: Model collection
  • Router: Provides the front-end routing function of SPA, supports two methods, hashChange and pushState
  • Sync: Some remote request methods
  • View: View components that can assemble template data, bind events, etc.

In our actual project, the view layer supports both Backbone.View and the early react@13, which also reflects its flexibility.

The usual Backbone project can also ignore the part of the article that involves react.

Upgrade test framework

Same as the example in the previous article, this time Jest is still used as the testing framework.

Original Use Case

In the early projects, there were actually some unit test codes, mainly using Jasmine to test part of the model/collection. Since Jest has built-in Jasmine2, there are no big grammatical problems in this part, and it can be migrated without any pain.

The main problems with earlier tests are:

  • 1. it is not integrated into the workflow and uses a separate web page as the carrier. Over time, this step will be forgotten, the use case may be invalid, and the new team members will not notice the existence of this work.
  • 2. the unit testing of model/collection was not rigorous at that time, and it relied on the PHP server environment that provided mock data.
  • 3. because the view layer is not well componentized, there is a lack of testing of view components

Practice of jest for Backbone

jest is a relatively new testing framework with zero configuration by default, but it also provides a flexible adaptation method that can adapt to various projects, including Backbone.js.

This @captbaritone brother provided a good explanation video (requires scientific Internet access https://www.youtube.com/watch?v=BwzjVNTxnUY&t=15s), and is accompanied by an example code (https://github. com/captbaritone/tdd-jest-backbone).

Configure the necessary dependencies and mapping

//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. Configure two npm scripts, which are used to run tests in real time during development and run tests during build time respectively
  2. In the target project, the ES6 translation is actually done with babel 5; however, since the previous source code has all been developed using ES6 syntax (part of the initial AMD code has also been automatically converted), we can use newer ones when testing. babel 6

Add support for older versions of react

//.babelrc

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

moduleNameMapper: {
	"unmountMixin": "react-unmount-listener-mixin",
	...
},
...
 
  1. According to the target project, enzyme-adapter-react-13 is used for adaptation
  2. Use cross-env to set the environment variable test to configure the .babelrc file suitable for jest without affecting the production environment
  3. According to the specific situation in the project, do the mapping of component names according to the original rules

Add unit test to build task

If only the written test, and alone, can only be npm testimplemented, it would repeat the mistakes of the original; here by means of grunt-runplug-in will add an existing grunt buildworkflow:

//Gruntfile.js

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

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

In this way, in subsequent build tasks, once a unit test fails, the entire process will stop executing.

Test model and collection

A model might look like this:

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;
 

Inject the global url prefix in the test

It can be found that the model relies on attributes in a global variable _appFacade.ajaxPrefix

First write a fake global object:

//__test__/fakeAppFacade.js

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

window._appFacade = facade;
module.exports = facade;
 

In the test suite, just import this module before the model:

//__test__/models/CardBinding.spec.js

import fakeAppFacade from '../fakeAppFacade';
import Model from "models/CardBinding";
 

Intercept asynchronous requests with sinon

After getting the address of the asynchronous request, it is natural to intercept the real request;

//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 of the request, including Backbone.sync/model.fetch (), etc., jQuery nature or in the calling $.ajaxprocess (default), i.e. xhr conventional manner using well qualified can sinon this the sucker work:

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();
});
 

Verify operation test

Calling the isValid() method of the Backbone.Model instance will get the boolean result of whether the data is valid, trigger the internal validate() method, and update its validationError value; using these features, we can do the following tests:

//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(' ');
});
 

The collection test is nothing special compared to the model, so I won t repeat it

The inevitable testable componentization of view

As mentioned at the beginning, in the previous outdated test cases in the project, the view layer part is missing.

This aspect was limited by the lack of awareness of testing at the time, and the main reason was the failure to solve the problem of componentization.

To test the view, it must be split and reconstructed into various small components with clear functions and easy reuse.

ES6 class evolution of Backbone.View

The first thing to be done is similar to the evolution of React.createClass to class extends Component. Backbone.View can also turn around gorgeously.

The traditional view is written like this:

const MyView = Backbone.View.extend({

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

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

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

});
 

Using ES6 class writing, it may be:

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){
			...
		}
	}
}
 

Extraction of components

Many pages of the target project do not properly encapsulate sub-components, but only extract the part of the html that needs to be reused into templates, "assemble" multiple sub-templates on this page, or reuse them with other pages. This is partly due to Backbone's "excessive freedom". The official website or the general practice at that time did not give a good componentization solution, but stayed at the stage of implementing _.template() with the dependent underscore.

This is actually the same as the dilemma faced by early WeChat applets: Due to the lack of componentization methods, modules can only be packaged at the js/wxml/wxss levels; and until the end of 2017 (version 1.6.3), applets did not have Developed its own component solution.

Another difficulty is that in the constructor/initialize "constructor" of Backbone.View, custom props parameters cannot be accepted.

The solution is to carry out a certain outer layer encapsulation:

//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;
};
 

You can also "inherit" a 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;
};
 

When using in the page, first pass the parameters to get the real Backbone.View component:

const Menu1View = MenuWithQRCode({
	data: {
		styleName: "menu1",
		list: tdata.menu1,
	}
});
 

Then manually call its render() method and add it to the DOM of the page view:

this.$el.find('.menu1_wrapper').replaceWith(
	(new Menu1View).render().$el
);
 

This achieves the encapsulation and nesting of Backbone.View components to a large extent.

Test the Backbone.View component

Compared to testing React, it also needs the support of enzyme, etc. Testing Backbone.View is actually much simpler. You only need to get its $el property and call jQuery's usual method:

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);
});
 

Test for method calls

Naturally still use sinon to do:

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();
});
 

Process templates introduced with require.js text plugin

A small problem in Backbone.js + Require.js testing is that text.js components are generally used to introduce templates in pages or components, and the ES6 form is:

import cardTmpl from 'text!templates/card.html';
 

Because the test environment does not have the support of require.js or webpack, we can only find a way to hijack it and inject the correct result into the corresponding test module;

To achieve this purpose, it is necessary to use jest.doMock()the method, the drawback is the use of this syntax can not be used for ES6 the import, configure and use a brief description is as follows:

//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;
 

summary

  • jest's flexible configuration capabilities make it easy to apply to TDD development and reconstruction of various types of existing projects
  • The previous use cases under other test frameworks can be quickly migrated to jest
  • Backbone.View view component after ES6 upgrade and reasonable packaging, can significantly improve the cleanliness of the page, and smoothly applied to unit testing
  • You can use sinon.createFakeServer() to intercept asynchronous requests in Backbone.Model
  • The original template introduced with the text.js component under Require.js can also be well supported by jest.doMock()
  • Adding unit test tasks to the original build workflow can ensure the continued effectiveness of the relevant code afterwards


(end)

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

Long press the QR code or search for Fewelife to follow us