小小文

编写可测试的JavaScript代码-令人疲惫的JavaScript

小小文 · 2017-01-07翻译 · 363阅读 原文链接

一开始,这篇文章是为Toptal's tech blog写的,为了后人着想,我又把他发到了这里。

无论是在使用Node的时候,配合着测试框架(像Mocha或者Jasmine),或者是使用DOM-无关的无头浏览器(如PhantomJS),关于对JavaScript进行单元测试,目前我们都拥有相比以前更好的的选择。

然而,这并不意味着我们要测试的代码和我们所用的工具一样简单!组织编写容易测试的代码需要付出一些努力,但是,受函数式编程概念的启发,我们参照一些可以用的模式,可以让我们在测试代码的时候避免跳进深坑。

分离业务逻辑和展示逻辑

基于JavaScript的浏览器应用程序的一个主要任务就是监听用户触发的DOM事件,然后执行一些业务逻辑并在当前页面显示结果。在设置DOM事件监听器的地方编写匿名的函数来完成这一坨工作是很有吸引力的。由此带来的问题就是你必须得模拟DOM事件来测试你的匿名函数,并带来代码量和测试的运行时间的开销。

应该编写命名函数,并传给事件处理器。这样你就可以为命名函数编写测试而不需要费劲的触发伪DOM事件。

而且这个方法不仅仅适用于DOM。很多浏览器中和Node中的API,被设计用来触发或者监听事件,或者是等待其他异步任务的完成。为此,你可以编写很多的匿名回调函数,但是你的代码可能就不太容易测试了。

// hard to test
$('button').on('click', () => {
    $.getJSON('/path/to/data')
        .then(data => {
            $('#my-list').html('results: ' + data.join(', '));
        });
});

// testable; we can directly run fetchThings to see if it
// makes an AJAX request without having to trigger DOM
// events, and we can run showThings directly to see that it
// displays data in the DOM without doing an AJAX request
$('button').on('click', () => fetchThings(showThings));

function fetchThings(callback) {
    $.getJSON('/path/to/data').then(callback);
}

function showThings(data) {
    $('#my-list').html('results: ' + data.join(', '));
}

使用回调函数OR在异步代码中使用Promises

在上边的代码示例中,我们重构了fetchThings函数,fetchThings发起一个AJAX请求,并且大部分任务都是异步的。这就意味着我们不能测试此函数是否能像期望的那样工作,因为无法获悉它何时结束运行。

最常用的解决方法是将回调函数作为参数传递给异步执行的函数。在你的单元测试中,你可以在传递的回调函数中执行断言。

另一种常见并且越来越流行的方式是用Promise API来组织异步代码。幸运的是,$.ajax和jQuery的其它大多数异步函数都已经把Promise对象作为其返回值,所以很多常见的用例已经被覆盖到了。

// hard to test; we don't know how long the AJAX request will run
function fetchData() {
    $.ajax({ url: '/path/to/data' });
}

// testable; we can pass a callback and run assertions inside it
function fetchDataWithCallback(callback) {
    $.ajax({
        url: '/path/to/data',
        success: callback,
    });
}

// also testable; we can run assertions when the returned Promise resolves
function fetchDataWithPromise() {
    return $.ajax({ url: '/path/to/data' });
}

避免副作用

编写接受一些参数,并完全根据这些参数计算返回值的函数,就好比是将一些数字代入数学公式中然后得到结果。如果你的函数依赖于一些外部的状态(比如,一个类实例的属性或者一个文件的内容),你需要在测试函数之前就完成状态的建立,因而就必须要在测试时做更多的初始工作。并且,你不得不认为任何其他要运行的代码不会修改此状态。

同样,要避免编写在运行时改变外部状态(如写入文件或将值保存到数据库)的函数。 这可以防止对其他测试带来副作用。 一般来说,应该尽量减少副作用对代码造成的影响。对类和对象实例来说,类方法的副作用应该限制在被测试的类实例的内部。

// hard to test; we have to set up a globalListOfCars object and set up a
// DOM with a #list-of-models node to test this code
function processCarData() {
    const models = globalListOfCars.map(car => car.model);
    $('#list-of-models').html(models.join(', '));
}

// easy to test; we can pass an argument and test its return value, without
// setting any global values on the window or checking the DOM the result
function buildModelsString(cars) {
    const models = cars.map(car => car.model);
    return models.join(',');
}

使用依赖注入

依赖注入是减少函数对外部状态的使用的一个常用模式-将函数需要使用的所有外部信息作为参数传递进去。

// depends on an external state database connector instance; hard to test
function updateRow(rowId, data) {
    myGlobalDatabaseConnector.update(rowId, data);
}

// takes a database connector instance in as an argument; easy to test!
function updateRow(rowId, data, databaseConnector) {
    databaseConnector.update(rowId, data);
}

使用依赖注入的主要好处之一是,你可以在单元测试中传递模拟对象,而不会带来真正的副作用(这里是一个更新数据库记录的例子),你可以断言模拟对象的行为是在预料之中的。

一个函数只做一件事

将功能丰富的长函数分解成短的、单一用途的函数集合。这使得对每个函数正确性的测试变得很容易,而不是寄希望于一个复杂的函数每个部分都正确执行。

在函数式编程中,把多个单用途函数链式执行叫做代码组合。Underscore.js甚至有个_.compose函数,将一系列的函数链式执行,其中,把每一步的执行结果作为下一个函数的参数传递进去。

// hard to test
function createGreeting(name, location, age) {
    let greeting;
    if (location === 'Mexico') {
        greeting = '!Hola';
    } else {
        greeting = 'Hello';
    }

    greeting += ' ' + name.toUpperCase() + '! ';

    greeting += 'You are ' + age + ' years old.';

    return greeting;
}

// easy to test
function getBeginning(location) {
    if (location === 'Mexico') {
        return '¡Hola';
    } else {
        return 'Hello';
    }
}

function getMiddle(name) {
    return ' ' + name.toUpperCase() + '! ';
}

function getEnd(age) {
    return 'You are ' + age + ' years old.';
}

function createGreeting(name, location, age) {
    return getBeginning(location) + getMiddle(name) + getEnd(age);
}

不要对参数进行修改

在JavaScript中,数组和对象都是可修改的引用传参。这表示,当你传递一个对象或者数组作为函数的参数时,无论是你的代码,抑或是你调用的使用该对象或数组作为参数的函数都可以对其做出修改。也就是说,如果你要测试自己的代码,你不得不认为你调用的所有函数不会对你的对象做出修改。每当你增加了对同一个对象做出修改的代码,此对象应该是什么样子就越难知道,因而测试就越难。

相反,如果你有一个使用对象或数组作为参数的函数,你应该认为参数中的对象或者数组是只读的。根据需要,在操作参数之前,你应该在代码中创建一个新的对象或者数组,或者,使用UnderscoreLodash来克隆该参数。使用像Immutable.js之类的工具来创建只读的数据结构会更好。

// alters objects passed to it
function upperCaseLocation(customerInfo) {
   customerInfo.location = customerInfo.location.toUpperCase();
   return customerInfo;
}

// sends a new object back instead
function upperCaseLocation(customerInfo) {
   return {
      name: customerInfo.name,
      location: customerInfo.location.toUpperCase(),
      age: customerInfo.age
   };
}

先写测试,后写代码

先写测试,后写代码叫做测试驱动开发(TDD),很多开发者都认为TDD很有用。

先编写测试用例会迫使你从开发人员的角度去考虑需要暴露的API。这可以帮助你写够用的代码,而不是将解决方案复杂化。

在实践中,很难保证在所有代码中都使用TDD。但是,这是很值得一试的,因为他可以最大程度的保证你的代码是可测试的。

总结

我们都知道,在编写和测试复杂JavaScript代码的时候,有一些陷阱是很容易掉进去的。但是,值得庆幸的是,有了这些提示,并且,时刻记住要保持代码和函数尽量简单,较高的测试覆盖度和较低的总体代码复杂度就可以得到保证。

相关文章