这篇文章算是 A List Apart 系列文章中,包含滑动门在内,令我印象最深入的文章之一。近来有时候翻译了一下,分享给更多人,愿望对人人有所协助!
我们已面临到了这一逆境:一最先我们写的 JavaScript 只要戋戋几行代码,然则它的代码量一直在增进,我们不停的加参数、加前提。末了,粗 bug 了…… 我们才不能不摒挡这个烂摊子。
如上所述,本日的客户端代码确实承载了更多的义务,浏览器里的悉数运用都越变越庞杂。我们发明两个显著的趋向:1、我们没法经由历程纯真的鼠标定位和点击来磨练代码是不是一般事变,自动化的测试才会真正让我们宁神;2、我们或许应当在撰写代码的时刻就斟酌到,让它变得可测试。
神马?我们须要转变本身的编码体式格局?是的。由于纵然我们意想到自动化测试的好,大部份人能够只是写写集成测试(integration tests)罢了。集成测试的侧重点是让悉数体系的每一部份调和共存,然则这并没有通知我们每一个自力的功用单位运转起来是不是都和我们预期的一样。
这就是为何我们要引入单位测试。我们已准备好阅历一段痛楚的撰写单位测试的历程了,但终究我们能够撰写可测试的 JavaScript。
单位与集成:有什么差别?
撰写集成测试通常是相称直接的:我们纯真的撰写代码,形貌用户如何和这个运用举行交互、会取得如何的结果就好。Selenium 是这类浏览器自动化东西中的佼佼者。而 Capybara 能够便于 Ruby 和 Selenium 取得联系。在别的语言中,这类东西也不胜枚举。
下面就是搜刮运用的一部份集成测试:
def test_search
fill_in('q', :with => 'cat')
find('.btn').click
assert( find('#results li').has_content?('cat'), 'Search results are shown' )
assert( page.has_no_selector?('#results li.no-results'), 'No results is not shown' )
end
集成测试对用户的交互行动感兴致,而单位测试每每仅专注于一小段代码:
当我陪伴特定的输入挪用一个函数的时刻,我是不是收到了我预期中的结果?
我们依据传统思绪撰写的顺序是很难举行单位测试的,同时也很难保护、调试和扩大。然则假如我们在撰写代码的时刻就斟酌到我未来要做单位测试,那末如许的思绪不仅会让我们发明测试代码写起来很直接,也会让我们真正写出更优良的代码。
我们经由历程一个简朴的搜刮运用的例子来做个树模:
当用户搜刮时,该运用会向服务器发送一个 XHR (Ajax 要求) 取得相应的搜刮结果。并当服务器以 JSON 花样返回数据以后,经由历程前端模板把结果显如今页面中。用户在搜刮结果中点“赞”,这个人的名字就会出如今右边的点“赞”列内外。
一个“传统”的 JavaScript 完成大概是这个模样的:
// 模板缓存,缓存的内容均为 jqXHR 对象
var tmplCache = {};
/**
* 载入模板
* 从 '/templates/{name}' 载入模板,存入 tmplCache
* @param {string} name 模板称号
* @return {object} 模板要求的 jqXHR 对象
*/
function loadTemplate (name) {
if (!tmplCache[name]) {
tmplCache[name] = $.get('/templates/' + name);
}
return tmplCache[name];
}
/**
* 页面主要逻辑
* 1. 支撑搜刮行动并展示结果
* 2. 支撑点“赞”,被赞过的人会出如今点“赞”列内外
*/
$(function () {
var resultsList = $('#results');
var liked = $('#liked');
var pending = false; // 用来标识之前的搜刮是不是还没有完毕
// 用户搜刮行动,表单提交事宜
$('#searchForm').on('submit', function (e) {
// 屏障默许表单事宜
e.preventDefault();
// 假如之前的搜刮还没有完毕,则不最先新的搜刮
if (pending) { return; }
// 取得要搜刮的关键字
var form = $(this);
var query = $.trim( form.find('input[name="q"]').val() );
// 假如搜刮关键字为空则不举行搜刮
if (!query) { return; }
// 最先新的搜刮
pending = true;
// 发送 XHR
$.ajax('/data/search.json', {
data : { q: query },
dataType : 'json',
success : function (data) {
// 取得 people-detailed 模板
loadTemplate('people-detailed.tmpl').then(function (t) {
var tmpl = _.template(t);
// 经由历程模板衬着搜刮结果
resultsList.html( tmpl({ people : data.results }) );
// 完毕本次搜刮
pending = false;
});
}
});
// 在取得服务器相应之前,清空搜刮结果,并涌现守候提醒
$('<li>', {
'class' : 'pending',
html : 'Searching …'
}).appendTo( resultsList.empty() );
});
// 绑定点“赞”的行动,鼠标点击事宜
resultsList.on('click', '.like', function (e) {
// 屏障默许点击事宜
e.preventDefault();
// 找到当前人的名字
var name = $(this).closest('li').find('h2').text();
// 消灭点“赞”列表的占位元素
liked.find('.no-results').remove();
// 在点“赞”列表到场新的项目
$('<li>', { text: name }).appendTo(liked);
});
});
我的朋侪 Adam Sontag 称之为“本身给本身挖坑”的代码:展示、数据、用户交互、运用状况悉数疏散在了每一行代码里。这类代码是很轻易举行集成测试的,但险些不能够针对功用单位举行零丁的测试。
单位测试为何这么难?有四大罪魁祸首:
- 没有清楚的组织。险些统统的事变都是在
$(document).ready()
回调里举行的,而这统统在一个匿名函数里,它在测试中没法暴露出任何接口。 - 函数太庞杂。假如一个函数超过了 10 行,比方提交表单的谁人函数,预计人人都以为它太忙了,一口气做了许多事。
- 隐蔽状况照样同享状况。比方,由于
pending
在一个闭包里,所以我们没有办法测试在每一个步骤中这个状况是不是准确。 - 强耦合。比方这里
$.ajax
胜利的回调函数不应当依靠 DOM 操纵。
组织我们的代码
首当其冲的是把我们代码的逻辑缕一缕,依据职责的差别把整段代码分为几个方面:
- 展示和交互
- 数据治理和保留
- 运用的状况
- 把上述代码竖立并勾通起来
在之前的“传统”完成里,这四类代码是混在一同的,前一行我们还在处置惩罚界面展示,后两行就在和服务器通讯了。
我们相对能够写出集成测试的代码,但我们应当很难写出单位测试了。在功用测试里,我们能够做出诸如“当用户搜刮东西的时刻,他会看到相应的搜刮结果”的断言,然则没法再详细下去了。假如内里出了什么题目,我们照样得追踪进去,找到确实的失足位置。如许的话功用测试实在也没帮上什么忙。
假如我们深思本身的代码,那无妨从单位测试写起,经由历程单位测试这个角度,更好的视察,是那里出了题目。这进而会协助我们革新代码,让代码变得更易于重用、易于保护、易于扩大。
我们的新版代码遵照下面几个准绳:
- 依据上述四类职责,列出每一个互不相干的行动,并分别用一个对象来示意。对象之前互不依靠,以防止差别的代码混在一同。
- 用可设置的内容替换写死的内容,以防止我们为了测试而复刻悉数 HTML 环境。
- 坚持对象要领的简朴明了。这会把测试事变变得简朴易懂。
- 经由历程组织函数建立对象实例。这让我们能够依据测试的须要复刻每一段代码的内容。
作为起步,我们有必要搞清楚,该如何把运用分解成差别的部份。我们有三块展示和交互的内容:搜刮框、搜刮结果和点“赞”列表。
我们另有一块内容是从服务器猎取数据的、一块内容是把统统的内容粘合在一同的。
我们从悉数运用最简朴的一部份最先吧:点“赞”列表。在原版运用中,这部份代码的职责就是更新点“赞”列表:
var liked = $('#liked');
var resultsList = $('#results');
// ...
resultsList.on('click', '.like', function (e) {
e.preventDefault();
var name = $(this).closest('li').find('h2').text();
liked.find( '.no-results' ).remove();
$('<li>', { text: name }).appendTo(liked);
});
搜刮结果这部份是完全和点“赞”列表搅在一同的,而且须要许多 DOM 处置惩罚。更好的易于测试的写法是建立一个点“赞”列表的对象,它的职责就是封点缀“赞”列表的 DOM 操纵。
var Likes = function (el) {
this.el = $(el);
return this;
};
Likes.prototype.add = function (name) {
this.el.find('.no-results').remove();
$('<li>', { text: name }).appendTo(this.el);
};
这段代码供应了建立一个点“赞”列表对象的组织函数。它有 .add()
要领,能够在发生新的赞的时刻运用。如许我们就能够写许多测试代码来保证它的一般事变了:
var ul;
// 设置测试的初始状况:天生一个搜刮结果列表
setup(function(){
ul = $('
*');
});
test('测试组织函数', function () {
var l = new Likes(ul);
// 断言对象存在
assert(l);
});
test('点一个“赞”', function () {
var l = new Likes(ul);
l.add('Brendan Eich');
// 断言列表长度为1
assert.equal(ul.find('li').length, 1);
// 断言列表第一个元素的 HTML 代码是 'Brendan Eich'
assert.equal(ul.find('li').first().html(), 'Brendan Eich');
// 断言占位元素已不存在了
assert.equal(ul.find('li.no-results').length, 0);
});
如何?并不难吧 :-) 我们这里用到了名为 Mocha 的测试框架,以及名为 Chai 的断言库。Mocha 供应了 test
和 setup
函数;而 Chai 供应了 assert
。测试框架和断言库的挑选另有许多,我们出于引见的目标给人人展示这两款。你能够找到属于合适本身的项目——除了 Mocha 以外,QUnit 也比较盛行。别的 Intern 也是一个测试框架,它运用了大批的 promise 体式格局。
我们的测试代码是从点“赞”列表这一容器最先的。然后它运行了两个测试:一个是确定点“赞”列表是存在的;另一个是确保 .add()
要领达到了我们预期的结果。有这些测试做后援,我们就能够宁神重构点“赞”列表这部份的代码了,纵然代码被损坏了,我们也有自信心把它修复好。
我们新运用的代码如今看起来是如许的:
var liked = new Likes('#liked'); // 新的点“赞”列表对象
var resultsList = $('#results');
// ...
resultsList.on('click', '.like', function (e) {
e.preventDefault();
var name = $(this).closest('li').find('h2').text();
liked.add(name); // 新的点“赞”操纵的封装
});
搜刮结果这部份比点“赞”列表更庞杂一些,不过我们也该拿它开刀了。和我们为点“赞”列表建立一个 .add()
要领一样,我们要建立一个与搜刮结果有交互的要领。我们须要一个点“赞”的进口,向悉数运用“播送”本身发生了什么变化——比方有人点了个“赞”。
// 为每一条搜刮结果的点“赞”按钮绑定点击事宜
var SearchResults = function (el) {
this.el = $(el);
this.el.on( 'click', '.btn.like', _.bind(this._handleClick, this) );
};
// 展示搜刮结果,猎取模板,然后衬着
SearchResults.prototype.setResults = function (results) {
var templateRequest = $.get('people-detailed.tmpl');
templateRequest.then( _.bind(this._populate, this, results) );
};
// 处置惩罚点“赞”
SearchResults.prototype._handleClick = function (evt) {
var name = $(evt.target).closest('li.result').attr('data-name');
$(document).trigger('like', [ name ]);
};
// 对模板衬着数据的封装
SearchResults.prototype._populate = function (results, tmpl) {
var html = _.template(tmpl, { people: results });
this.el.html(html);
};
如今我们旧版运用中治理搜刮结果和点“赞”列表之间交互的代码以下:
var liked = new Likes('#liked');
var resultsList = new SearchResults('#results');
// ...
$(document).on('like', function (evt, name) {
liked.add(name);
})
这就更简朴更清楚了,由于我们经由历程 document
在各个自力的组件之间举行音讯通报,而组件之间是互不依靠的。(值得注意的是,在真正的运用当中,我们会运用一些诸如 Backbone 或 RSVP 库来治理事宜。我们出于让例子只管简朴的斟酌,运用了 document
来触发事宜) 我们同时隐蔽了许多脏活累活:比方在搜刮结果对象里寻觅被点“赞”的人,要比放在悉数运用的代码里更好。更主要的是,我们如今能够写出保证搜刮结果对象一般事变的测试代码了:
var ul;
var data = [ /* 填入假数据 */ ];
// 确保点“赞”列表存在
setup(function () {
ul = $('
*');
});
test('测试组织函数', function () {
var sr = new SearchResults(ul);
// 断言对象存在
assert(sr);
});
test('测试收到的搜刮结果', function () {
var sr = new SearchResults(ul);
sr.setResults(data);
// 断言搜刮结果占位元素已不存在
assert.equal(ul.find('.no-results').length, 0);
// 断言搜刮结果的子元素个数和搜刮结果的个数雷同
assert.equal(ul.find('li.result').length, data.length);
// 断言搜刮结果的第一个子元素的 'data-name' 的值和第一个搜刮结果雷同
assert.equal(
ul.find('li.result').first().attr('data-name'),
data[0].name
);
});
test('测试点“赞”按钮', function() {
var sr = new SearchResults(ul);
var flag;
var spy = function () {
flag = [].slice.call(arguments);
};
sr.setResults(data);
$(document).on('like', spy);
ul.find('li').first().find('.like.btn').click();
// 断言 `document` 收到了点“赞”的音讯
assert(flag, '事宜被收到了');
// 断言 `document` 收到的点“赞”音讯,个中的名字是第一个搜刮结果
assert.equal(flag[1], data[0].name, '事宜里的数据被收到了' );
});
和服务器直接的交互是别的一个风趣的话题。原版的代码包含一个 $.ajax()
的要求,以及一个直接操纵 DOM 的回调函数:
$.ajax('/data/search.json', {
data : { q: query },
dataType : 'json',
success : function( data ) {
loadTemplate('people-detailed.tmpl').then(function(t) {
var tmpl = _.template( t );
resultsList.html( tmpl({ people : data.results }) );
pending = false;
});
}
});
一样,我们很难为如许的代码撰写测试。由于许多差别的事变同时发生在这一小段代码中。我们能够重新组织一下数据处置惩罚的部份:
var SearchData = function () { };
SearchData.prototype.fetch = function (query) {
var dfd;
// 假如搜刮关键字为空,则不做任何事,马上 `promise()`
if (!query) {
dfd = $.Deferred();
dfd.resolve([]);
return dfd.promise();
}
// 不然,向服务器要求搜刮结果并把在取得结果以后对其数据举行包装
return $.ajax( '/data/search.json', {
data : { q: query },
dataType : 'json'
}).pipe(function( resp ) {
return resp.results;
});
};
如今我们转变了取得搜刮结果这部份的代码:
var resultList = new SearchResults('#results');
var searchData = new SearchData();
// ...
searchData.fetch(query).then(resultList.setResults);
我们再一次简化了代码,并经由历程 SearchData
对象扬弃了之前运用顺序主函数里芜杂的代码。同时我们已让搜刮接口变得可测试了,只管如今和服务器通讯这里另有事变要做。
起首我们不是真的要跟服务器通讯——不然这又变成集成测试了:诸如我们是有义务感的开辟者,我们已确保服务器肯定不会出错等等,是如许吗?为了替换这些东西,我们应当“mock”(捏造) 与服务器之间的通讯。Sinon 这个库就能够做这件事。第二个停滞是我们的测试应当掩盖非抱负环境,比方关键字为空。
test('测试组织函数', function () {
var sd = new SearchData();
assert(sd);
});
suite('取数据', function () {
var xhr, requests;
setup(function () {
requests = [];
xhr = sinon.useFakeXMLHttpRequest();
xhr.onCreate = function (req) {
requests.push(req);
};
});
teardown(function () {
xhr.restore();
});
test('经由历程准确的 URL 猎取数据', function () {
var sd = new SearchData();
sd.fetch('cat');
assert.equal(requests[0].url, '/data/search.json?q=cat');
});
test('返回一个 promise', function () {
var sd = new SearchData();
var req = sd.fetch('cat');
assert.isFunction(req.then);
});
test('假如关键字为空则不查询', function () {
var sd = new SearchData();
var req = sd.fetch();
assert.equal(requests.length, 0);
});
test('假如关键字为空也会有 promise', function () {
var sd = new SearchData();
var req = sd.fetch();
assert.isFunction( req.then );
});
test('关键字为空的 promise 会返回一个空数组', function () {
var sd = new SearchData();
var req = sd.fetch();
var spy = sinon.spy();
req.then(spy);
assert.deepEqual(spy.args[0][0], []);
});
test('返回与搜刮结果相对应的对象', function () {
var sd = new SearchData();
var req = sd.fetch('cat');
var spy = sinon.spy();
requests[0].respond(
200, { 'Content-type': 'text/json' },
JSON.stringify({ results: [ 1, 2, 3 ] })
);
req.then(spy);
assert.deepEqual(spy.args[0][0], [ 1, 2, 3 ]);
});
});
出于篇幅的斟酌,这里对搜刮框的重构及其相干的单位测试就不逐一引见了。完全的代码能够移步至此查阅。
当我们依据可测试的 JavaScript 的思绪重构代码以后,我们末了用下面这段代码开启顺序:
$(function() {
var pending = false;
var searchForm = new SearchForm('#searchForm');
var searchResults = new SearchResults('#results');
var likes = new Likes('#liked');
var searchData = new SearchData();
$(document).on('search', function (event, query) {
if (pending) { return; }
pending = true;
searchData.fetch(query).then(function (results) {
searchResults.setResults(results);
pending = false;
});
searchResults.pending();
});
$(document).on('like', function (evt, name) {
likes.add(name);
});
});
比清洁整齐的代码更主要的,是我们的代码具有了更硬朗的测试基本作为后援。这也意味着我们能够宁神的重构恣意部份的代码而没必要忧郁顺序遭到损坏。我们还能够继承为新功用撰写新的测试代码,并确保新的顺序能够经由历程统统的测试。
测试会在宏观上让你变轻松
看完这些的长篇大论你肯定会说:“纳尼?我多写了这么多代码,结果照样做了这么一点事变?”
关键在于,你做的东西日夕要放到网上的。一样是花时候解决题目,你会挑选在浏览器里点来点去?照样自动化测试?照样直接在线上让你的用户做你的小白鼠?不管你写了若干测试,你写好代码,他人一用,若干会发明点 bug。
至于测试,它能够会花掉你一些分外的时候,然则它到末了真的是为你省下了时候。写测试代码测出一个题目,总比你宣布到线上以后才发明有题目要好。假如有一个体系能让你意想到它真的能防止一个 bug 的流出,你肯定会心存感谢感动。
分外的资本
这篇文章只能算是 JavaScript 测试的一点外相,然则假如你对此抱有兴致,那末能够继承移步至:
- 幻灯演示 2012 Full Frontal conference in Brighton, UK
- Grunt 一个能够举行自动化测试等诸多事变的东西
- 测试驱动的 JavaScript 开辟 及其 中文版