Whether we’re using Node paired with a test framework like Mocha or Jasmine, or spinning up DOM-dependent tests in a headless browser like PhantomJS, our options for unit testing JavaScript are better now than ever.
However, this doesn’t mean the code we’re testing is as easy on us as our tools are! Organizing and writing code that is easily testable takes some effort and planning, but there are a few patterns, inspired by functional programming concepts, that we can use to avoid getting into a tough spot when it comes time to test our code. In this article, we will go through some useful tips and patterns for writing testable code in JavaScript.
KEEP BUSINESS LOGIC AND DISPLAY LOGIC SEPARATE
One of the primary jobs of a JavaScript-based browser application is listening to DOM events triggered by the end user, and then responding to them by running some business logic and displaying the results on the page. It’s tempting to write an anonymous function that does the bulk of the work right where you’re setting up your DOM event listeners. The problem this creates is that you now have to simulate DOM events to test your anonymous function. This can create overhead both in lines of code and the time it takes for tests to run.
Instead, write a named function and pass it to the event handler. That way you can write tests for named functions directly and without jumping through hoops to trigger a fake DOM event.
This applies to more than the DOM though. Many APIs, both in the browser and in Node, are designed around firing and listening to events or waiting for other types of asynchronous work to complete. A rule of thumb is that if you are writing a lot of anonymous callback functions, your code may not be easy to test.
// 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(', '));
}
USE CALLBACKS OR PROMISES WITH ASYNCHRONOUS CODE
In the code example above, our refactored fetchThings function runs an AJAX request, which does most of its work asynchronously. This means we can’t run the function and test that it did everything we expected, because we won’t know when it’s finished running.
The most common way to solve this problem is to pass a callback function as a parameter to the function that runs asynchronously. In your unit tests you can run your assertions in the callback you pass.
Another common and increasingly popular way to organize asynchronous code is with the Promise API. Fortunately, $.ajax and most other of jQuery’s asynchronous functions return a Promise object already, so a lot of common use cases are already covered.
// 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' });
}
AVOID SIDE EFFECTS
Write functions that take arguments and return a value based solely on those arguments, just like punching numbers into a math equation to get a result. If your function depends on some external state (the properties of a class instance or the contents of a file, for example), and you have to set up that state before testing your function, you have to do more setup in your tests. You’ll have to trust that any other code being run isn’t altering that same state.
In the same vein, avoid writing functions that alter external state (like writing to a file or saving values to a database) while it runs. This prevents side effects that could affect your ability to test other code with confidence. In general, it’s best to keep side effects as close to the edges of your code as possible, with as little “surface area” as possible. In case of classes and object instances, a class method’s side effects should be limited to the state of the class instance being tested.
// 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(',');
}
USE DEPENDENCY INJECTION
One common pattern for reducing a function’s use of external state is dependency injection - passing all of a function’s external needs as function parameters.
// 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);
}
One of the main benefits of using dependency injection is that you can pass in mock objects from your unit tests that don’t cause real side effects (in this case, updating database rows) and you can just assert that your mock object was acted on in the expected way.
Give Each Function a Single Purpose
Break long functions that do several things into a collection of short, single-purpose functions. This makes it far easier to test that each function does its part correctly, rather than hoping that a large one is doing everything correctly before returning a value.
In functional programming, the act of stringing several single-purpose functions together is called composition. Underscore.js even has a function
_.compose
, that takes a list of functions and chains them together, taking the return value of each step and passing it to the next function in line.// 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);
}
DON’T MUTATE PARAMETERS
In JavaScript, arrays and objects are passed by reference rather than value, and they are mutable. This means that when you pass an object or an array as a parameter into a function, both your code and the function you passed the object or array to have the ability to alter the same instance of that array or object in memory. This means that if you’re testing your own code, you have to trust that none of the functions your code calls are altering your objects. Every time you add a new place in your code that alters the same object, it gets increasingly hard to keep track of what that object should look like, making it harder to test.
Instead, if you have a function that takes an object or array have it act on that object or array as though it were read-only. Create a new object or array in code and add values to it based on your needs. Or, use Underscoreor Lodash to clone the passed object or array before operating on it. Even better, use a tool like Immutable.js that creates read-only data structures.
// 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
};
}
WRITE YOUR TESTS BEFORE YOUR CODE
The process of writing unit tests before the code they’re testing is called test driven development (TDD). A lot of developers find TDD to be very helpful.
By writing your tests first, you are forced to think about the API you are exposing from the perspective of a developer consuming it. It also helps to ensure you’re only writing enough code to meet the contract being enforced by your tests, rather than over-engineering a solution that’s unnecessarily complex.
In practice, TDD is a discipline that can be difficult to commit to for all your code changes. But when it seems worth trying, it’s a great way to guarantee you are keeping all code testable.
WRAP UP
We all know there are a few pitfalls that are very easy to fall for when writing and testing complex JavaScript apps. But hopefully with these tips, and remembering to always keep our code as simple and functional as possible, we can keep our test coverage high and overall code complexity low!
Hire the top 3% of JavaScript developers here.
No comments:
Post a Comment