单元测试你的客户端 JavaScript

Tags: javascript

我不认为我们需要讨论测试的重要性,这不是项目额外添加的而是项目的基础。而且,由于测试是非常重要的,我们有一大堆工具供我们使用。我们都知道(我希望如此)如何测试我们的后台代码。然而,一旦我们切换到前台时,事情会变得有些不同。当前我们正在开发一个大的单页应用,并且测试是我们的重点之一。在这篇文章中,你将了解到如何对前端代码进行单元测试。

示例

为了说明问题以及它的解决方案,我们将创建一个简单的 AngularJS应用。为什么是 AngularJS?因为它是一个流行框架,并且你可能已经很熟悉它了。本来这篇文章中所描述的概念是一个使用 Ractive.js的项目,但它适用于 AngularJS 而且也应该可以在 Ember.js 或 Backbone.js 上工作。

假设,我们有一个菜单并且菜单的最后一个连接用来显示新用户的注册表单,表单的字段有一些验证机制,这些机制是强制的,要求至少输入多少个字符。总体来说,我们的页面看起来是这样的:

Unit test your client-side JavaScript

在我们的表单底部有一个占位符用来显示错误信息,下面是我们的代码:

<!doctype html>
<html ng-app="app">
  <head>
    <link rel="stylesheet" href="./css/styles.css">
    <script src="./vendor/angular.min.js"></script>
    <script type="text/javascript">

      angular.module('app', [])
      .controller('Controller', function($scope) {})
      .directive('registerForm', function() {
        ...
      })
      .directive('appHeader', function() {
        ...
      });

    </script>
  </head>
  <body>
    <div ng-controller="Controller">
      <app-header></app-header>
    </div>
  </body>
</html>

在 AngularJS 中我们可以使用称之为 directive 的东东。简单的说,我们可以使用它来包装逻辑,我们可以向页面添加自定义标签来表示这些逻辑。appHeader 执行用来显示上面 gif 动画中的菜单,这里是实现代码:

.directive('appHeader', function() {
  var link = function(scope, element, attrs) {
    scope.showRegisterForm = function() {
      element.find('register-form').css({
        display: 'block'
      })
    }
  }
  return {
    restrict: 'E',
    template: '\
      <header>\
        <ul>\
          <li><a href="#">Fake button</a></li>\
          <li><a href="#">Another fake button</a></li>\
          <li><a href="#" ng-click="showRegisterForm()">Register</a></li>\
        </ul>\
        <register-form class="register-form" />\
      </header>\
    ',
    link: link
  }
});

我们在 JavaScript 中定义直接定义标签,在使用模板时这不是最好的做法。不过,为了简化示例,我们将会使用这种方式来定义。这里,我们可以看到表示表单和输入字段的 <register-form> 标签。

rgisterForm 指令(directive)包含了字段验证功能。同样,模板被直接嵌入到指令定义中了。

.directive('registerForm', function() {
  return {
    restrict: 'E',
    template: '\
      <form>\
        <p>Please fill the form below</p>\
        <label for="username">Your name</label>\
        <input type="text" name="username" id="username" ng-model="username" />\
        <label for="username">Password</label>\
        <input type="password" name="password" id="password" ng-model="password"/>\
        <br />\
        <input type="button" value="register" ng-click="register()" id="register-button" />\
        <br />\
        <span id="message">{{message}}</span>\
      </form>\
    ',
    controller: function($scope) {      
      var validateInput = function() {
        var u = $scope.username;
        var p = $scope.password;
        if (u === '' || u === undefined) {
          return { status: false, message: 'Missing username.'}
        } else if (p === '' || p === undefined) {
          return { status: false, message: 'Missing password.'}
        } else if (u.length < 10) {
          return { status: false, message: 'Too short username.'}
        } else if (p.length < 6) {
          return { status: false, message: 'Too short password.'}
        }
        return { status: true }
      }
      $scope.register = function() {
        var isValid = validateInput();
        if(false === isValid.status) {
          $scope.message = isValid.message;
          return;
        } else {
          $scope.message = '';
        }
      }
    }
  }
});

代码略微有些长不过仍然很简单。这个指令(directive)中的关键点是 validateInput 方法,它用来处理用户输入并决定输入内容是否有效,以及针对错误输入输出相应的错误信息。

单元

现在进入有趣的部分了。我们打算如何针对我们的应用编写单元测试呢?首先或许是最重要的一件事情是将应用分隔为较小的独立部分,因此我们需要创建单 元。上面例子中的两个指令(diretives)可以认为是单元。头部(header)不包含任何自定义逻辑,因此无需对它进行测试。没错,那里有 click 处理程序,不过我们并不需要为它编写单元测试,因为它是框架的一部分,我们认为谷歌的那些家伙已经测试过了。更有趣的是注册表单,我们需要确定用户输入任何内容时指令都会有适当的反应。

我们需要从其他代码中提取 registerForm。如果我们仔细观察,我们会发现 AngularJS 中的这个指令除了函数以外没有其他任何内容,因此我们修改代码为:

// a new file register-form.js
registerFormDirective = function() {
  return {
    restrict: 'E',
    ...
    ...
  }
};

// main file
<script src="./js/register-form.js"></script>
<script>
angular.module('app', [])
.controller('Controller', function($scope) {})
.directive('registerForm', registerFormDirective)
...
</script>

我们将定义移动到全局作用域中并在后面 main 文件中使用(这是使用全局作用域的一个非常好的实践,不过让我们继续保持简单)

现在我们有了一个可以在任何位置调用的简单的 JavaScript 函数了,更重要的是我们可以测试它。

编写测试

这里有很多测试客户端 JavaScript 的方法。

  • 当然,最简单的方式是编写一个测试然后在浏览器中执行。这适合小的团队并且在同意在部署前运行测试。然而,这不适合大公司或者团队。你不可能信赖自己或者同事在每次交付代码前都记得执行这个额外的步骤,它应该是自动化的,换句话说这个步骤应该集成到部署工作中。

  • 有很多 headless 浏览器比如 PhantomJS 或者 Selenium 可以通过编程方式对我们的页面进行访问和交互。如果我们使用这些工具,我们可以避免手动执行测试。但问题是,我们并不是在进行单元测试,而是在进行集成测试。我们需要为 registerForm 指令(Directive)创建单独页面才能针对它进行单元测试。因此,这个方法并不适合单元测试,它非常适合集成测试。

  • 最后一个方法,也是我们将要使用的方法,解决了这上面的问题 - 自动化以及单元测试。我们不需要访问页面,我们仅需要简单的获取单元,创建单元的一个实例然后启动 UI 操作,不需要 headless 浏览器参与,我们将使用 Node.js。我们的 registerForm 现在已经是一个简单的 JavaScript 函数,因此它当然可以在 node 中执行。

虽然不是全部,不过大多数当前流行的客户端框架都操作 DOM。这是一个问题,因为 Node.js 中没有 DOM 树。幸运的是有一个模块可以非常好的模拟 DOM 树,这个模块叫 jsdom。让我们创建 package.json 并且输入模块信息:

{
  "name": "test",
  "version": "0.0.0",
  "dependencies": {
    "mocha": "2.0.1",
    "jsdom": "1.1.0"
  }
}

配置 jsdom 的同时我们也配置了 Mocha - 流行的 JavaScript 测试框架,它可以用于 Node.js 以及浏览器。输入 npm install,我们已经具备编写测试的基础环境了。我们测试代码的框架大概是:

var assert = require('assert');

suite('Register form', function() {
  test('validations', function(done) {
      this.timeout(5000);
      ...
      done();
    });
  });
});

assert 是 Node.js 中的断言模块非常适合单元测试,使用 suitetest 是因为我们打算使用 Mocha 的 tdd 接口。如果我们的测试成功了,我们需要调用 done 方法。我们将超时时间修改为 5 秒,这样 AngularJS 可以完成它自己的一些工作。

为 AngularJS 创建合适的环境是编写单元测试最大的挑战,这样它可以像在浏览器中一样工作,下面是如何使用 jsdom 在完成环境创建的:

var jsdom = require("jsdom");
jsdom.env({
  html: '<html ng-app="app"><body><div ng-controller="Controller"><register-form></register-form></div></body></html>',
  scripts: [
    __dirname + '/../vendor/angular.min.js',
    __dirname + '/../js/register-form.js'
  ],
  features: {
    FetchExternalResources: ["script"],
    ProcessExternalResources: ["script"],
  },
  done: function(errors, window) {
    ...
  }
});

我们 require 必要的模块后调用 env 方法来有效的创建虚拟页面,页面的标签在 html 属性中定义。这些代码已经最后启动 AngularJS 应用并且显示我们的注册表单。当然,我们需要引入 AngularJS 以及 register-form.js 文件,我们通过 scripts 属性来完成这些工作。最后我们需要告诉 jsdom 我们需要我们的加载以及处理这些脚本。done 属性接受我们测试入口的回调函数。

从上面的例子我们可以看到,我们可以访问 window 对象了,它与浏览器中的 window 对象是相同的。实际上 jsdom 是 WHATWG DOM 和 HTML 标准的 JavaScript 实现。因此,我们可以通过它完成很多工作,我们可以使用 window.document.querySelector 在生成的 DOM 上选择一个元素或者触发一个事件,这就是我们测试我们指令(directive)功能所需要的全部了基础功能了。

为了让我们的生活更轻松些,我们需要编写一些帮助函数:

var $ = function(selector) {
  return window.document.querySelector(selector);
}
var trigger = function(el, ev) {
  var e = window.document.createEvent('UIEvents');
  e.initEvent(ev, true, true);
  el.dispatchEvent(e);
}

然后添加一些用来在屏幕上显示指令(directive)的基本的 AngularJS 代码

window
  .angular
  .module('app', [])
  .controller('Controller', Controller)
  .directive('registerForm', window.registerFormDirective);

当然,还有实际执行测试的控制器(controller)实现:

var Controller = function($scope) {
  var runTests = function() {

    var register = $('#register-button');
    var message = $('#message');
    var username = $('#username');
    var password = $('#password');

    register.click();
    assert.equal(message.innerHTML, 'Missing username.');

    username.value = 'test';
    trigger(username, 'change');
    register.click();
    assert.equal(message.innerHTML, 'Missing password.');

    password.value = 'test';
    trigger(password, 'change');
    register.click();
    assert.equal(message.innerHTML, 'Too short username.');

    username.value = 'testtesttesttest';
    trigger(username, 'change');
    register.click();
    assert.equal(message.innerHTML, 'Too short password.');

    password.value = 'testtesttesttest';
    trigger(password, 'change');
    register.click();
    assert.equal(message.innerHTML, '');

    done();

  };
  setTimeout(runTests, 1000);
}

这里的 setTimeout 调用是因为我们需要先完成一些必须的处理,我们需要确保 DOM 和 AngularJS 的绑定在选择元素前已初始化完成。

另一个有趣的规则是,我们需要在每次向文本框中输入内容后触发 change 事件,这是因为 AngularJS 并不知道我们修改了文本框的值,这同样适用于我们触发 blur, focus 或者 click 事件。

一个简短的说明

不是学究,但是我们必须说明上面编写的测试更像功能测试而不是单元测试。事实是,单元测试仅测试类的某个方法而且实际上是测试某个特定功能。在注册表单指令(directive)测试中我们做的比单元测试要多一些(所以是功能测试)。

最后的话

编写测试并不容易。然而,我们需要编写测试,而且测试需要自动化执行。有时候使用像 Selenium 之类的工具无法测试系统的每个部分,有时候我们很难将这些工具集成在一起。jsdom 是最好的测试工作环境之一,并且它非常符合我们的要求。

如果你喜欢这篇文章,你应该也会对 Atomus 感兴趣,它实际上是 jsdom 的包装。

测试的全部代码:

var assert = require('assert');

suite('Register form', function() {

  test('validations', function(done) {
    this.timeout(5000);

    var jsdom = require("jsdom");
    jsdom.env({
      html: '<html ng-app="app"><body><div ng-controller="Controller"><register-form></register-form></div></body></html>',
      scripts: [
        __dirname + '/../vendor/angular.min.js',
        __dirname + '/../js/register-form.js'
      ],
      features: {
        FetchExternalResources: ["script"],
        ProcessExternalResources: ["script"],
      },
      done: function(errors, window) {
        if(errors != null) console.log('Errors', errors);

        var $ = function(selector) {
          return window.document.querySelector(selector);
        }

        var trigger = function(el, ev) {
          var e = window.document.createEvent('UIEvents');
          e.initEvent(ev, true, true);
          el.dispatchEvent(e);
        }

        var Controller = function($scope) {
          var runTests = function() {

            var register = $('#register-button');
            var message = $('#message');
            var username = $('#username');
            var password = $('#password');

            register.click();
            assert.equal(message.innerHTML, 'Missing username.');

            username.value = 'test';
            trigger(username, 'change');
            register.click();
            assert.equal(message.innerHTML, 'Missing password.');

            password.value = 'test';
            trigger(password, 'change');
            register.click();
            assert.equal(message.innerHTML, 'Too short username.');

            username.value = 'testtesttesttest';
            trigger(username, 'change');
            register.click();
            assert.equal(message.innerHTML, 'Too short password.');

            password.value = 'testtesttesttest';
            trigger(password, 'change');
            register.click();
            assert.equal(message.innerHTML, '');

            done();

          };
          setTimeout(runTests, 1000);
        }

        window
          .angular
          .module('app', [])
          .controller('Controller', Controller)
          .directive('registerForm', window.registerFormDirective);

      }
    });

  });

});

本文链接:http://www.4byte.cn/learning/42861/dan-yuan-ce-shi-ni-de-ke-hu-duan-javascript.html