Painless Javascript Testing

The Pain

  • Ruby 有几种
  • Java 有几种测试框架
  • JavaScript 有几种...

为什么这么多测试框架

跟后端代码不太一样的东西无非就是

  • DOM
  • AJAX

    因此男厕的也就是他们

DOM - 举个🌰

it('should hide specific column by display filter', function () {
    $option1 = $('.ui-multiselect-checkboxes input[type=checkbox]:eq(1)');
    $option1.attr("checked", false).trigger("click");
    expect($($table.find('td:eq(2)'))).toBeHidden();
  });

它测的是这个模块...

ns("REA.reports.dashboard.TableFilter", function (ns) {
  ns.initialize = function (filter) {
    var $filter = $(filter);
    var $table = $($filter.data("table"));
    var checkboxes = $filter.multiselect("widget").find(":checkbox");
    $.each(checkboxes, function () {
      var $this = $(this);
      var idx = parseInt(this.id.split('-').pop(), 10) + 2;
      var $th = $table.find('th:nth-child(' + idx + ')');
      var $column = $table.find('td:nth-child(' + idx + ')');
      if ($this.is(":checked")) {
        $th.show();
        $column.show();
        $('.display-filters:visible select option[value="' + $this.val() + '"]').attr({selected: 'selected'});
      } else {
        $th.hide();
        $column.hide();
        $('.display-filters:visible select option[value="' + $this.val() + '"]').removeAttr('selected');
      }
    });

    REA.reports.dashboard.SlideNav.toggleSlideButton($table);
  };
});

问题

  • 肯定不是 TDD, 写的这么难测
  • 不是单元测试, 貌似是 UI test
    • 而且$option1.attr("checked", false).trigger("click") 这是几个意思
  • 强依赖, 导致很男厕

为什么分不清单元还是 ui 测试

单元测试 -> 输入输出

UI test -> 操作, 行为

  • 但是这里输出是行为...他改变了一大堆东西的状态
  • 而且还加入了用户操作这种东西$option1.attr("checked", false).trigger("click")
ns("REA.reports.dashboard.TableFilter", function (ns) {
  ns.initialize = function (filter) {
    var $filter = $(filter); // 依赖注入, mock 之
    var $table = $($filter.data("table")); // 返回的 data 也要 mock
    var checkboxes = $filter.multiselect("widget").find(":checkbox"); //不关心 multiselect, mock 之
    $.each(checkboxes, function () {
      var $this = $(this);
      var idx = parseInt(this.id.split('-').pop(), 10) + 2; // 逻辑
      var $th = $table.find('th:nth-child(' + idx + ')'); // 逻辑
      var $column = $table.find('td:nth-child(' + idx + ')');
      if ($this.is(":checked")) {
        $th.show(); //逻辑
        $column.show(); //逻辑
        $('.display-filters:visible select option[value="' + $this.val() + '"]').attr({selected: 'selected'});
      } else {
        $th.hide(); //逻辑
        $column.hide(); //逻辑
        $('.display-filters:visible select option[value="' + $this.val() + '"]').removeAttr('selected'); //逻辑
      }
    });

    REA.reports.dashboard.SlideNav.toggleSlideButton($table); //逻辑
  };
});

什么样的代码最好测

                        +-------------------+
             input        |                   | output
         ---------------+     function      +--------------
                        |                   |
                        |                   |
                        +-------------------+

当然是单输入输出

但是事实上, 很难做到写这么简单的函数

比如 AJAX

来写一个模块要去取github 用户的 follower 数量的模块.

那么我们应该有一个 User 的 Model.

// user.js
var $ = require('jquery');
function User(name) {
  this.name = name;
  this.followers = 0;
}
User.prototype.fetch = function(){
  return $.ajax({
    url: 'https://api.github.com/users/' + this.name,
    method: 'get',
    dataType: 'json'
  }).then(function(data){
      this.followers = data.followers;
  }.bind(this));
};
module.exports = User;

在这里要测的非常简单

这段代码写得应该算非常好测的,没有输入,输出是fetch之后populate User model

所以只要

  • 验证给 $.ajax 传入争取的地址
  • 验证拿到 $.ajax 后把数据(follower)放入model中

但是真的好测吗

注意到依赖 jquery 的 ajax 方法

jquery是第三方库,我们并不关心他对不对,因为知道给定正确输入肯定能得到确定的结果(不然我也不用它)。应该mock它,但是怎么...

好像不好下手啊

有两种方法可以mock jquery

  • 全局变量
  • 依赖注入

全局变量的缺点我就不说了,那依赖注入呢

User.prototype.fetch = function(ajax){
  return ajax({
    ...
  })
...
};

非常好测,把mock的ajax函数传进去即可

但是

每次使用fetch的时候还要这样写

new User('jcouyang').fetch($.ajax)

为了测试的方便, 牺牲了使用接口的易用性

还能不能愉快的写单元测试了, 这么多坑

  • 跟 UI 紧密相关, 让人不自觉的就操作 ui 来测试 javascript
    • 太多 dom 依赖, dom 相当于全局变量, 依赖一大堆全局变量会很难测, case 变得更复杂
    • 太多的 dom 操作, 相当于改变全局变量
  • 第三方依赖在模块化代码里难mock
    • 全局又不好模块化
    • 模块化又不好mock

来一条一条解决

jest auto mock

jest.dontMock('../user');
describe('User Model', function(){
  var user;
  beforeEach(function(){
    var $ = require('jquery').setAjaxReturn({followers: 23});
    var User = require('../user');
    user = new User('jcouyang');
  });

  it('should populate properties with data from github api', function(){
    user.fetch();
    expect(user.followers).toBe(23);
  });
});

所以这个测试看起来就跟文档一样了,

  1. dontMock('./user') 说明我关心 user 这个模块, 其他我都不 care.
  2. before 是我要进行操作所需要的东西.
  3. 我要 jquery ajax 请求给我想要的数据
  4. 我要一个我要测的 User 类的实例
  5. it 说明我关心地行为是神马
    • 我关心 fetch 的行为,是去取数据并给我把数据填充到我的 repo 实例中

你可能要问 setAjaxReturn 是哪里冒出来的

beforeEach(function(){
  var $ = require('jquery').setAjaxReturn({followers: 23});
  var User = require('../user');
  user = new User('jcouyang');
});

忍一忍稍后告诉你.

有没有看虽然我没有显式的 mock jquery, 但是 User 里面 require 到的 jquery 其实是假的, 不然我们就真的访问 github api 了. 那样就不会每次都返回 23 个 follower 了.

jest jsdom

好了现在我们来测 follower.js, 先看 follower 到底干了什么, 拿到 user 的信息然后组成一句话放到页面 id 为 content 的元素下面.

好, 所以我们关心

  • 组出来的话对不对
  • 有没有放到 content 元素下, 所以 jquery 的操作对不对也是我们关心的一部分

我们不关心

  • user 干了什么

这样,关心的就是不能 mock 的

jest.dontMock('../follower')
    .dontMock('jquery');
describe('follower', function(){
  var user, repo, follower;
    var $ = require('jquery');
  beforeEach(function(){
        var User = require('../user');
        follower = require('../follower');
        user = new User('jcouyang');
    // 我们不关心 user, 但是我们希望他能返回一个 deferred 类型
      user.fetch.mockReturnValue($.Deferred().resolve('dont care'));
    // 我们让我们不关心的 user 返回我们期望的东西就好
        user.name ='jcouyang';
        user.followers = 20;
    // 期待页面上有一个  id 为 content 的元素
        document.body.innerHTML = '<div id="content"></div>';
    });

  it('should populate properties with data from github api', function(){
        follower(user);
    // 希望 content 上能得到想要的内容
        expect($("#content").text()).toBe('jcouyang\'s followers: 20');
  });
});

Manual Mock

好了, 说好的解释 setAjaxReturn是怎么回事的

嗯嗯, 是这样的, jest 自动 mock 了我们不关心的模块, 这样我们可以直接检测该模块是否被调用, 参数对不对. 但是我们还是会希望 这个 mock 的玩意能有一些我们期望的行为, 也就是按我们的期望返回一些东西. 比如 这里就是我们不关心 ajax 的逻辑, 但是我们需要他能给我们返回一个东西,并且可以 thenable. 所以单纯的 mock 对象或函数都不能做到, 所以有了 manual mock 这种东西.

用 manual mock 需要建一个__ mocks__ 文件夹,然后把所有的 mock 都扔进去. 比如 我想 mock jquery, 那么我建一个jquery.js 扔进去

var data = {};
var mockDefered = function(data){
    return {
        then: function(cb){
            return mockDefered(cb(data));
        }
    };
};

function ajax() {
  return mockDefered(data);
}

function setAjaxReturn(shouldbe){
    data = shouldbe;
}
exports.setAjaxReturn = setAjaxReturn;
exports.ajax = ajax;

终于看见setAjaxReturn在哪里定义了:sweat_smile: 这里暴露两个函数

  • setAjaxReturn: 可以设置我希望 ajax 返回的值
  • ajax: 单纯的返回这个 thenable.

所以我也不需要显示的声明 mock jquery什么什么的, 直接在测试里设置ajax 的返回值就好了.

var $ = require('jquery').setAjaxReturn({stargazers_count: 23});

这是 repo 里面 require 的 jquery 已经被 mock 并且只要掉 ajax 都会返回我 期望的值.

etc

  • 并行测试: 还用说么, 既然已经模块化好了, 测试也就完全互不依赖. 没有什么理由一个一个测. 因此3个测试的耗时取决于最长时间的那个. 所以如果有 那个测试特别耗时,说明模块还不够细, 多拆几个就快了.
  • promise: 使用 pit() 来测试 thenable 的对象, 比如 repo 的例子,就 keyi 写成
    pit('should populate properties with data from github api', function(){
    return repo.fetch().then(
      expect(repo.followers).toBe(23);
    );
    });
  • Timer mocks: 可以使用 mock 的 timer 和 ticks, 也就是你可以加速 所有的setTimeout, setInterval, clearTimeout, clearInterval行为. 不需要等待.
    setTimeout(function() { callback(); }, 1000);
    expect(callback).not.toBeCalled();
    jest.runAllTimers();
    expect(callback).toBeCalled()