用 JavaScript 编写可测试代码:简要概述

已发表: 2022-03-11

无论我们是使用 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 编码挑战回顾展