编写可测试的JavaScript代码


/**
 * 谨献给可爱的小黑
 *
 * 原文出处:https://www.toptal.com/javascript/writing-testable-code-in-javascript
 * @author dogstar.huang <chanzonghuang@gmail.com> 2016-04-02
 */

不管我们正是使用的是像Mocha或Jasmine这样结点配对的测试框架,或者是像PhantomJS这样模拟浏览器围绕DOM 依赖的测试,现在我们对于JavaScript单元测试的选择都比以前好了很多。

然而,这并不意味着我们要测试的代码如同我们的工具那样容易!组织和编写易于测试的代码需要一些努力和计 划,但这里有一些由函数编程概念启发的模式,可用于当需要测试代码时避免我们陷入痛苦之中。在这篇文章中, 我们将探索一些用于编写可测试的JavaScript代码的有用技巧与模式。

分离业务逻辑与显示逻辑

基于JavaScript浏览器应用的早期工作之一是侦听由终端用户触发的DOM事件, 然后通过运行一些业务逻辑和在页面上显示结果来向用户作出响应。很容易就在设置DOM事件侦听的地方编写一个 做很多事情的匿名函数。由此产生的问题是你现在不得不模拟DOM事件以便测试你的匿名函数。这会产生代码行数 和执行测试的时间这两方面的开销。

取而代之,应该是编写一个命名的函数并把它传递给事件处理器。这样的话你可以直接为命名的函数编写测试并且 无须费事去触发一个假的DOM事件。

这不仅仅可以应用到DOM。很多API,包括在浏览器和在Node中,都是围绕着启动和侦听事件或者等待其他待完成的 异步工作类型而设计的。经验法则是如果你正在编写大量匿名回调函数,那么你的代码是不易测试的。

// 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(', '));
}

使用回调或者带异步代码的承诺

在上面的示例中,我们重构后的refactored方法执行了一个AJAX执行, 以便异步完成它大部分的工作。这意味着我们不能执行这个方法以及测试我们期望它所做的所有事情,因为我们 不知道它何时完成。

解决这个问题最常见的方式是把一个回调函数作为一个参数传递给这个异步执行的方法。在你的单元测试里可以 在所传递的回调中执行你的断言。

另外一个通用且日渐流行的组织异步代码的方式是使用承诺API(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里,数组和对象是通过按引用而不是按值传值,并且他们 是可以被修改的。这意味着当你把一个对象或者一个数组作为参数传递给一个函数时,你的代码和传递对象或数组 的函数都有能力修改在内存中相同的数组或对象实例。这意味着如果你正在测试自己的代码,你不得不相信你的代 码所调用的全部函数都没有修改你的对象。每一次添加一处修改相同对象的代码,都逐渐使得追踪对象的看起来是 怎样变更越来越困难,使得测试更难。

相反地,如果你有一个接收了对象或者数组的函数并根据这个对象或数组采取行动的话,就假设它是只读的。在代 码中创建一个新的对象或数组并且根据你的需要为其添加值。或者,在操作它之前使用Underscore 或者Lodash克隆传递的对象或者数组。甚至更进一步,使用像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应用时有一些坑是很容易掉进去的。但这些技巧让我们又充满了希望, 并且记得经常保持代码尽可能简单以及尽可能是可工作的,我们可以保持很高的测试覆盖率以及很低的代码复杂度!

dogstar

一位喜欢翻译的开发人员,艾翻译创始人之一。

广州