33 单元测试

优质
小牛编辑
132浏览
2023-12-01

Javascript是动态类型语言,它的表达式有强大的威力,但也因此使得编译器对它没有任何帮助。所以我们强烈的觉得它需要强大的测试框架。我们已经建好了这样一个框架,所以没有理由不用它吧。

不要把问题都搅在一起

单元测试,如名字一样就是单独测试每个部分的代码。单元测试视图解决的问题是:我的逻辑正确了吗?排序函数正确排序了吗?为了解决这样的问题我们非常需要将它们鼓励出来。因为当我们解决排序问题的时候,我们不希望还要考虑到其他的如果DOM操作的问题,或者需要执行异步请求来获取数据之类的。但是在通常的项目中,几乎很难去调用一个单独的纯粹的函数。因为开发者常常将问题搅在一起,最后写出的代码职责太多。比如异步读取数据同时又要排序又要操作DOM。在AngularJS中,我们试图让开发者正容易地使用正确的开发方法,所以我们通过依赖注入来让你异步获取数据(你也可以模拟),我们创建了数据抽象让你操作模型数据的时候不用去管DOM。所以,最后你可以写出纯粹的排序函数了,并且能去测试它。测试不需要等待异步请求,也不需要去管理DOM或者验证DOM是否也正确反应。AngularJS是以可测试为宗旨之一写的,但是仍然需要你自己正确地操作。我们试图让事情变得简单,但是如果你不照章办事,你还是会把你的应用搞的完全没办法测。

依赖注入

有几种你可以获取依赖的方式:1.你直接使用new运算符建立实例。2.你查找已知的域,比如全局对象。3.你使用一个已有的注册系统来获取(但你如何来获取注册系统呢,貌似你还是必须查找一个已知域)。4.等待依赖被传递给你。

上面的选项只有最后是可测的。让我们看看为什么:

使用new操作符

new操作符本身来说并没有什么错误,但是待用它的地方会将自己和被调用者永久绑定。比如说,我们现在实例化一个XHR对象来取数据。

function MyClass() {
  this.doWork = function() {
    var xhr = new XHR();
    xhr.open(method, url, true);
    xhr.onreadystatechange = function() {...}
    xhr.send();
  }
}

问题来了,在测试中,我们很可能想要模拟一个XHR来返回虚拟的数据。但是调用new XHT()使得我们被绑定在了这个真实的对象上,别且没有很好地方法能替换它。呃,其实有一些方法,但是都很丑陋并且会导致其他问题,这个已经超出本章范围,暂不讨论。

上面的类很难用于测试,我们不得不使用一些歪招:

var oldXHR = XHR;
XHR = function MockXHR() {};
var myClass = new MyClass();
myClass.doWork();
// assert that MockXHR got called with the right arguments
XHR = oldXHR; // if you forget this bad things will happen

全局查找

另一种解决上述问题的方法是在一个已知域查找服务

function MyClass() {
  this.doWork = function() {
    global.xhr({
      method:'...',
      url:'...',
      complete:function(response){ ... }
    })
  }
}

虽然没有使用new来创建实例,但本质上和new是一样。在代码中还是没有办法为了测试的需要而去拦截global.xhr的调用,除非又使用些怪招。这里面最基本的问题在于我们需要有一个方法来模拟依赖。更多这方面的知识请参阅 Brittle Global State & Singletons :

上面的类难测试是因为我们需要改变全局状态:

var oldXHR = global.xhr;
global.xhr = function mockXHR() {};
var myClass = new MyClass();
myClass.doWork();
// assert that mockXHR got called with the right arguments
global.xhr = oldXHR; // if you forget this bad things will happen

服务注册

看起来好像我们可以用一个注册系统来解决这个问题,在需要时替换掉不需要测试的部分就行了。

function MyClass() {
  var serviceRegistry = ????;
  this.doWork = function() {
    var xhr = serviceRegistry.get('xhr');
    xhr({
      method:'...',
      url:'...',
      complete:function(response){ ... }
    })
}

但是,这个serviceRegistry是哪里来的呢?貌似又要new一个。而且在测试全局的时候,我们无法重置服务。

上面的例子难以测试是因为我们必须改变全局状态:

var oldServiceLocator = global.serviceLocator;
global.serviceLocator.set('xhr', function mockXHR() {});
var myClass = new MyClass();
myClass.doWork();
// assert that mockXHR got called with the right arguments
global.serviceLocator = oldServiceLocator; // if you forget this bad things will happen

传递依赖

终于说到这个了。

function MyClass(xhr) {
  this.doWork = function() {
    xhr({
      method:'...',
      url:'...',
      complete:function(response){ ... }
    })
}

这是最好的一种方式,因为代码不关心xhr从哪里来,谁穿件来的谁负责实例化它。因为它的创建者和用它的地方肯定是不一样的,所以它在逻辑上分离了两者,简言之这就是依赖注入。

上面的例子是很好测的,我们可以这样写:

function xhrMock(args) {...}
var myClass = new MyClass(xhrMock);
myClass.doWork();
// assert that xhrMock got called with the right arguments

注意我们没有用到任何全局变量。

AngularJS已经内建好依赖注入系统来让事情比那的简单,但是你仍然需要按照规矩来,才能让你的应用更好测试。

控制器

应用的本质区别在于它的逻辑,这也正是我们希望测试的。如果你应用的逻辑里混杂着DOM操作,那就会很难测试,像下面这样:

function PasswordController() {
  // get references to DOM elements
  var msg = $('.ex1 span');
  var input = $('.ex1 input');
  var strength;

  this.grade = function() {
    msg.removeClass(strength);
    var pwd = input.val();
    password.text(pwd);
    if (pwd.length > 8) {
      strength = 'strong';
    } else if (pwd.length > 3) {
      strength = 'medium';
    } else {
      strength = 'weak';
    }
    msg
     .addClass(strength)
     .text(strength);
  }
}

上面的代码难测在于你需要同时去测试DOM是否也正确反应了。你的测试可能会像下面这样:

var input = $('<input type="text"/>');
var span = $('<span>');
$('body').html('<div class="ex1">')
  .find('div')
    .append(input)
    .append(span);
var pc = new PasswordController();
input.val('abc');
pc.grade();
expect(span.text()).toEqual('weak');
$('body').html('');

在AngularJS控制器是和DOM操作完全分离的,这使得我们能像下面这样更好的测试:

function PasswordCntrl($scope) {
  $scope.password = '';
  $scope.grade = function() {
    var size = $scope.password.length;
    if (size > 8) {
      $scope.strength = 'strong';
    } else if (size > 3) {
      $scope.strength = 'medium';
    } else {
      $scope.strength = 'weak';
    }
  };
}

测试会变得这样直白:

var pc = new PasswordController();
pc.password('abc');
pc.grade();
expect(span.strength).toEqual('weak');

注意我们的测试不只是变短了,而是更加清楚了。这样的测试代码告诉了你究竟是怎么运行的,而不是一堆看不懂的符号。

过滤器

过滤器是用来将输出给用户的数据变得更可读的。它们的重要性在于它们将格式化的工作从应用逻辑中抽离出来了,进一步的简化了应用逻辑。

myModule.filter('length', function() {
  return function(text){
    return (''+(text||'')).length;
  }
});

var length = $filter('length');
expect(length(null)).toEqual(0);
expect(length('abc')).toEqual(3);

指令

指令是在当模型数据改变时负责更新DOM的。