撰写可测试的 JavaScript

转自 勾三股四 - 撰写可测试的 JavaScriptjavascript

这篇文章算是 A List Apart 系列文章中,包括滑动门在内,令我印象最深入的文章之一。最近有时间翻译了一下,分享给更多人,但愿对你们有所帮助!html


咱们已经面对到了这一窘境:一开始咱们写的 JavaScript 只有区区几行代码,可是它的代码量一直在增加,咱们不断的加参数、加条件。最后,粗 bug 了…… 咱们才不得不收拾这个烂摊子。前端

如上所述,今天的客户端代码确实承载了更多的责任,浏览器里的整个应用都越变越复杂。咱们发现两个明显的趋势:一、咱们无法经过单纯的鼠标定位和点击来检验代码是否正常工做,自动化的测试才会真正让咱们放心;二、咱们也许应该在撰写代码的时候就考虑到,让它变得可测试。java

神马?咱们须要改变本身的编码方式?是的。由于即便咱们意识到自动化测试的好,大部分人可能只是写写集成测试(integration tests)罢了。集成测试的侧重点是让整个系统的每一部分和谐共存,可是这并无告诉咱们每一个独立的功能单元运转起来是否都和咱们预期的同样。git

这就是为何咱们要引入单元测试。咱们已经准备好经历一段痛苦的撰写单元测试的过程了,但最终咱们可以撰写可测试的 JavaScriptgithub

单元与集成:有什么不一样?

撰写集成测试一般是至关直接的:咱们单纯的撰写代码,描述用户如何和这个应用进行交互、会获得怎样的结果就好。Selenium 是这类浏览器自动化工具中的佼佼者。而 Capybara 能够便于 Ruby 和 Selenium 取得联系。在其它语言中,这类工具也举不胜举。ajax

下面就是搜索应用的一部分集成测试:json

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

集成测试对用户的交互行为感兴趣,而单元测试每每仅专一于一小段代码:数组

当我伴随特定的输入调用一个函数的时候,我是否收到了我预期中的结果?promise

咱们按照传统思路撰写的程序是很难进行单元测试的,同时也很难维护、调试和扩展。可是若是咱们在撰写代码的时候就考虑到我未来要作单元测试,那么这样的思路不只会让咱们发现测试代码写起来很直接,也会让咱们真正写出更优质的代码。

咱们经过一个简单的搜索应用的例子来作个示范:

clipboard.png

当用户搜索时,该应用会向服务器发送一个 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 操做。

组织咱们的代码

首当其冲的是把咱们代码的逻辑缕一缕,根据职责的不一样把整段代码分为几个方面:

  • 展示和交互
  • 数据管理和保存
  • 应用的状态
  • 把上述代码创建并串连起来

在以前的“传统”实现里,这四类代码是混在一块儿的,前一行咱们还在处理界面展示,后两行就在和服务器通讯了。

clipboard.png

咱们绝对能够写出集成测试的代码,但咱们应该很难写出单元测试了。在功能测试里,咱们能够作出诸如“当用户搜索东西的时候,他会看到相应的搜索结果”的断言,可是没法再具体下去了。若是里面出了什么问题,咱们仍是得追踪进去,找到确切的出错位置。这样的话功能测试其实也没帮上什么忙。

若是咱们反思本身的代码,那不妨从单元测试写起,经过单元测试这个角度,更好的观察,是哪里出了问题。这进而会帮助咱们改进代码,让代码变得更易于重用、易于维护、易于扩展。

咱们的新版代码遵循下面几个原则:

  • 根据上述四类职责,列出每一个互不相干的行为,并分别用一个对象来表示。对象以前互不依赖,以免不一样的代码混在一块儿。
  • 用可配置的内容代替写死的内容,以免咱们为了测试而复刻整个 HTML 环境。
  • 保持对象方法的简单明了。这会把测试工做变得简单易懂。
  • 经过构造函数建立对象实例。这让咱们能够根据测试的须要复刻每一段代码的内容。

做为起步,咱们有必要搞清楚,该如何把应用分解成不一样的部分。咱们有三块展示和交互的内容:搜索框、搜索结果和点“赞”列表。

clipboard.png

咱们还有一块内容是从服务器获取数据的、一块内容是把全部的内容粘合在一块儿的。

咱们从整个应用最简单的一部分开始吧:点“赞”列表。在原版应用中,这部分代码的职责就是更新点“赞”列表:

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 提供了 testsetup 函数;而 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 在各个独立的组件之间进行消息传递,而组件之间是互不依赖的。(值得注意的是,在真正的应用当中,咱们会使用一些诸如 BackboneRSVP 库来管理事件。咱们出于让例子尽可能简单的考虑,使用了 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 测试的一点皮毛,可是若是你对此抱有兴趣,那么能够继续移步至:

相关文章
相关标签/搜索