本文概述
无论是将Node与Mocha或Jasmine之类的测试框架配合使用, 还是在无头的浏览器(如PhantomJS)中分解与DOM相关的测试, 现在我们对JavaScript进行单元测试的选择都比以往任何时候都更好。
但是, 这并不意味着我们正在测试的代码像我们的工具一样容易使用!组织和编写易于测试的代码需要花费一些精力和计划, 但是在功能性编程概念的启发下, 有一些模式可以用来避免在测试代码时陷入困境。在本文中, 我们将介绍一些有用的技巧和模式, 以用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(', '));
}
对异步代码使用回调或承诺
在上面的代码示例中, 我们的重构的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中, 数组和对象是通过引用而不是值传递的, 并且它们是可变的。这意味着, 当你将对象或数组作为参数传递给函数时, 代码和传递对象或数组的函数都可以更改内存中该数组或对象的同一实例。这意味着, 如果你要测试自己的代码, 则必须相信, 代码调用不会改变你的对象。每次在代码中添加更改同一对象的新位置时, 跟踪该对象的外观将变得越来越困难, 从而使测试变得更加困难。
相反, 如果你有一个采用对象或数组的函数, 则该函数应对该对象或数组进行操作, 就好像它是只读的一样。在代码中创建一个新的对象或数组, 并根据需要向其添加值。或者, 使用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应用程序时, 容易陷入一些陷阱。但是希望借助这些技巧, 并记住始终保持我们的代码尽可能简单和功能, 我们可以使我们的测试覆盖率很高, 而总的代码复杂度却很低!
有关:
- JavaScript开发人员最常犯的10个错误
- 对速度的需求:JavaScript编码挑战赛回顾展
评论前必须登录!
注册