编写可测试的前端Javascript代码第一部分--反模式及其解决方案

这是介绍编写可测试的Javascript UI代码两篇文章中的第一篇。这一篇展示了一个基本的示例应用程序,该应用程序包含了几个常见的反模式以及它们的解决方案。

在第二篇中,我们将用描述的优化技能来重构该应用,并提出了一个简单地XHR模拟,并且添加了一个测试用例以帮助后面的开发者维护代码。

前端开发带来了一系列的挑战,但是很少有文章是来讨论单元测试的。自动初始化,逻辑的封装,DOM事件处理,XHR请求以及回调嵌套都会使得测试变的困难。

幸运的是,我们可以编写可测试的前端代码,但是,这确实需要一些知识和思考。

常见的编码实践--容易理解,但难以测试

虽然较短,但这个人为的例子使用了几个常见的反模式

<!DOCTYPE html>
<html>
<head>
  <title>An Untestable Authentication Form</title>
</head>
<body>
  <form id="authentication_form">
    <label for="username">Username:</label>
    <input type="text" id="username" name="username"></input>
    <label for="password">Password:</label>
    <input type="password" id="password" name="password"></input>
    <button>Submit</button>
 
    <p id="username_password_required" style="display: none;">
      Both the username and password are required.
    </p>
 
    <p id="authentication_success" style="display: none;">
      You have successfully authenticated!
    </p>
 
    <p id="authentication_failure" style="display: none;">
      This username/password combination is not correct.
    </p>
 
    <p id="authentication_error" style="display: none;">
      There was a problem authenticating the user, please try again later.
    </p>
  </form>
  <script src="jquery.min.js"></script>
  <!-- Inline Javascript is impossible to test from an external test harness -->
  <script>
    // Even if test harness was included in the HTML, Javascript is
    // inaccessible to tests
    $(function() {
 
      // Pyramid of doom - A mixture of disparate concerns and
      // very difficult to test individual parts
      $("#authentication_form").on("submit", function(event) {
        // Event handler logic is mixed with form handling logic
        event.preventDefault();
 
        var username = $("#username").val();
        var password = $("#password").val();
 
        if (username && password) {
          // Without a mock, XHR requests require a functioning 
          // back end, adding extra dependencies and delay
          $.ajax({
            type: "POST",
            url: "/authenticate_user",
            data: {
              username: username,
              password: password
            },
            success: function(data, status, jqXHR) {
              // Knowing when this completes requires some sort
              // of notification
              if (data.success) {
                $("#authentication_success").show();
              }
              else {
                $("#authentication_failure").show();
              }
            },
            error: function(jqXHR, textStatus, errorThrown) {
              $("#authentication_error").show();
            }
          });
        }
        else {
          $("#username_password_required").show();
        }
      });
    });
  </script>
</body>
</html>

反模式使得应用的代码变的难以测试
1.内联Javascript – 嵌入在HTML文件中的Javascript代码是无法包含在外部单元测试工具中的。
2.无法复用的代码 – 即使Javascript代码单独放在外面,也没有提供公共的接口供其他人调用。
3.没有构造函数/原型对象 – 个人的单元测试就意味着独立的操作。测试一个单例是很困难的,因为一个测试的结果可能会影响到其他测试的结果。
4.金字塔厄运 – 深层的嵌套在Javascript开发中非常多见,但是他们是让人各种担忧的抓狂的东西。深层嵌套在内部的代码逻辑是很难进行单独测试的,并且随着时间的推移,会有变得像意大利面条式的难以维护的倾向。
5.拙劣的DOM事件处理程序 – 事件处理程序和表单提交逻辑混在一起,从而导致无法避免的担忧。
6.真正的XHR请求 – 真正的XHR请求需要一个可用的后端服务,前端和后端高速并行的开发是很困难的,因为XHR请求需要一个能工作的后端才能看到请求结果。
7.状态通知 更少的异步逻辑 – 没有某种形式的通知,是无法知道一个异步函数是什么时候执行完成的。

如何编写可测试的Javascript UI代码

上面列出的每一个问题都是可以解决的。稍微动下脑子思考一下,其实前端的代码是很容易测试的。

外链所有的Javascript代码

直接嵌入到一个HTML文件中的Javascript代码是无法被另一个HTML文件使用的。外链的Javascript代码是可复用的,并且可以被不止一个的HTML文件所引入。

提供一个公共接口
代码必须要提供公共接口才能被测试。在提供一个公共接口的时候,被用来封装逻辑的最经常使用的模式是使用模块。Addy Osmani的非常优秀的Javascript设计模式必知必会一书中,他指出:模块模式最初在传统软件行业中作为类的私有和公共接口的封装被提出的。
原来的样例应用程序没有公共接口,所有的代码都封装在一个自调用的私有函数中。唯一可以进行测试的地方就是表单提交事件功能部分。虽然确定是可以(进行测试)的,用仅有的混合事件处理程序来编写测试用例会有不必要的麻烦。
适当的封装模块可以用来限制功能访问,减少全局命名空间的污染,并且可以提供公共接口方便测试。
var PublicModule = (function() {
  "use strict";
 
  // This is the public interface of the Module.
  var Module = {
     // publicFunction can be called externally
     publicFunction: function() {
       return "publicFunction can be invoked externally but " 
                  + privateFunction();
     }
  };
  
  // privateFunction is completely hidden from the outside.
  function privateFunction() {
     return "privateFunction cannot";
  }
 
  return Module;
}());

Addy指出的 ,模块模式的一个弊端在于“无法创建对私有成员的自动化单元测试”。一个函数如果不能被直接访问,那么它就不能被直接进行测试。模块设计的时候,在保持成员私有化和向公众公开成员之间存在一定的扩展性。
Mozilla Persona代码库中,我们经常在测试公共接口的私有函数时暴露出困难,会很明显的把额外的函数作为测试API的一部分。虽然其他的开发者仍然可以调用这些私有函数,但作者的意图是很明显的。
...
    publicFunction: function() {
      return "publicFunction can be invoked externally but " 
                 + privateFunction();
    }
 
    // BEGIN TESTING API
    ,
    privateFunction: privateFunction
    // END TESTING API
  };
  
  // privateFunction is now accessible via the TESTING API
  function privateFunction() {
  ...
在注释标记// BEGIN TESTING API和//END TESTING API 之间的代码可以在项目构建的时候删除掉。

使用可实例化的对象

最初的应用程序并没有使用可实例化的对象,它的代码被设计的只执行一次。这一约束使得重置应用状态以及独立的执行单元测试变得很困难。
测试可以被多次初始化的模块相对来说是更容易的。在Javascript中,存在两个相似的方法:构造函数和Object.create.
前面两个例子中的PublicModule变量是对象而不是函数,而Object.create可以被用来创建对象的副本。可以在原型中增加一个可选的初始化函数以便在构造函数中执行初始化。
...
    // the init function takes care of initialization traditionally done 
    // in a constructor
    init: function(options) {
      this.valueSetOnInit = options.valueSetOnInit;
    },
 
    publicFunction: function() {
    ...

// create an instance of the PublicModule.
  var objInstance = Object.create(PublicModule);
  objInstance.init({
    valueSetOnInit: "value set during initialization"
  });

减少嵌套回调

非常不幸的是,嵌套回调是前端Javascript编程很重要的一部分。上面不可测试验证的Form表单的例子绝没有额外的包含三层嵌套的回调。深层的嵌套回调代码是这样的--他们将功能糟糕的混杂在一起并且让人担忧重重。
将金字塔式的代码拆分为各功能组件我们可以得到较“平坦”的代码,这些代码会由小的,有粘着力的以及功能易测试的代码组成。

将DOM事件处理程序和它的行为分离

不可测试验证的Form例子用了一个单独的提交处理程序来同时关注事件处理和表单提交。不仅仅是同时关注了这两件事,而且这个混合结果导致如果不使用混合的事件程序将无法提交表单。
 ...
  $("form").on("submit", function(event) {
    event.preventDefault();
    
    // this code is impossible to invoke programmatically 
    // without using a synthetic DOM event.
    var name = $("#name").val();
    doSomethingWithName(name);
  });
  ...
将表单处理逻辑从事件处理程序中分离出来让我们可以编程提交表单而不用求助于混合的事件处理程序。
...
  $("form").on("submit", submitHandler);
 
  function submitHandler(event) {
    event.preventDefault();
    
    submitForm();
  });
 
 
  // form submission can now be done programmatically
  // by calling submitForm directly.
  function submitForm() {
    var name = $("#name").val();
    doSomethingWithName(name);
  }
  ...

单元测试可以使用submitForm而不必使用混合的表单提交事件处理程序。

模拟XHR请求

几乎所有的现代网站都是用XHR(AJAX)请求。XHR请求依赖于服务端;从前端的请求必须被服务端响应,否则应用什么都做不了。只有服务端也准备好了才能测试真正的XHR请求,否则会严重影响并行开发。
...
  // This is an explicit dependency on the jQuery ajax functionality as well
  // as a working back end.
  $.ajax({
    type: "POST",
    url: "/authenticate_user",
      data: {
        username: username,
        password: password
    },
    success: function(data, status, jqXHR) {
  ...
与其执行真正的XHR请求,不如用一种格式定义良好的XHR响应来模拟。 Mock对象是一种以可控的方式来模拟真正对象的行为的模拟对象。模拟对象经常被用于那些需要依赖不可获得的、较慢的、不可控的或者缺陷太多而无法信任的功能上面。XHR请求恰好是一个很好的例子。
同时测试前端和后端是很重要的,但是这最好留给功能测试。单元测试意味着测试单独的条目。
一个发起XHR请求的模块应该接受一个包含在其构造函数或者初始化函数中的XHR模拟对象。然后这个模块使用这个被包含的模拟对象而不是去直接调用$.ajax。模块在执行单元测试的时候使用模拟对象,但是在生产中使用$.ajax。
合理的默认值可以减少生产体系中初始化代码的数量。
...
  init: function(options) {
    // Use the injected ajax function if available, otherwise
    // use $.ajax by default.
    this.ajax = options.ajax || $.ajax;
  },
 
  submitForm: function() {
    ...
    // This can call either an XHR mock or a production XHR resource 
    // depending on how the object is initialized.
    this.ajax({
      type: "POST",
      url: "/authenticate_user",
        data: {
          username: username,
          password: password
      },
      ...
    });
  }
  ...

异步编程需要通知机制

上面不可测试验证的Form表单的例子缺少通知的机制来表明什么时候所有的进程已结束。这在异步函数运行结束后的需要执行的单元测试中会是一个问题。
Javascript中存在很多的通知机制,回调,观察者模式以及事件是几个。简单的回调函数是目前最常用的。
...
  submitForm: function(done) {
    ...
    this.ajax({
      ...
      // an ajax call is asynchronous. When it successfully completes, 
      // it calls the done function.
      success: done
    });
  }
  ...

单元测试后清除不必要的代码

单元测试应该单独的进行;一旦一个单元测试结束,所有的测试状态应该被清除,包括DOM事件处理。 导致对象将DOM事件处理程序绑定到相同的DOM元素的两个测试用例容易相互影响,而这容易被开发者疏忽。为了排除这种影响,一个没用的对象应该从它的DOM事件处理程序中移除。额外的工作会提供一些额外的好处;在应用中创建和销毁对象可以大大的减少内存溢出。
 ...
    teardown: teardown() {
      $("form").off("submit", submitHandler);
    }
    ...

总结

就是这样。实际上没有编写多少前端Javascript代码,这样就可以进行单元测试。公共接口,初始化对象,少嵌套的代码结构,组织良好的事件处理程序以及测试之后不必要代码的清除。
这篇文章的代码可以在Github上看到。

后记

在文章的第二部分,上面提到的几点优化建议将被用来重构上面那个简单的例子,并编写一个简单的XHR模拟,增加一个完整的单元测试用例,敬请期待!
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值