Bulletproofing your code
6.2 Challenges of testing imperative programs
The test code lives in a JavaScript file that’s not part of the main application code but imports all the functions that will be tested. Unit testing imperative programs is extremely challenging due to the presence of side effects and mutations. Let’s exam-ine some of the downfalls of testing imperative code.
6.2 Challenges of testing imperative programs
Imperative tests suffer from the same challenges as imperative code. Because impera-tive code is based on global state and mutations, rather than contained data flow and joined computations, testing is a real challenge. One of the main principles to follow when designing unit tests is isolation. A unit test should run as if in a vacuum and igno-rant of any other data or tests around it; but side effects in the code severely limit the extent to which you can test functions.
Imperative code is
■ Difficult to identify and decompose into simple tasks
■ Dependent on shared resources that make test results inconsistent
■ Forced to a predefined order of evaluation Let’s examine some of these challenges more closely.
6.2.1 Difficulty identifying and decomposing tasks
Unit tests are designed to test the smallest parts of your application. In procedural programs, it’s much harder to identify the units of modularity because there’s no intui-tive way to slice the different sections of a single, monolithic program that wasn’t
designed with that mindset to begin with. In this case, the units are functions that encapsulate your business logic. For example, recall the imperative version of show-Student that you’ve been working on throughout the book. Figure 6.2 shows a good attempt to slice it into its constituent parts.
As you can see, this program is made up of tightly coupled business logic that’s con-cerned with different aspects of a program, all in a single monolithic function. But there’s no real reason to couple data validation with fetching student records and appending elements to the DOM; those can be separate testable business units that are assembled via composition. In addition, as you learned in chapter 5, you should factor out error-handling logic and allow monads to handle it.
Monads and error handling
In chapter 5, you learned about a few design patterns that you can apply to consoli-date and remove error-handling code from your main functions while still keeping them fault-tolerant. By using the monads Maybe and Either, you can write point-free code that knows how to properly propagate errors through the components while mak-ing sure your program remains responsive.
function showStudent(ssn) { if(ssn !== null) {
ssn = ssn.replace(/^\s*|\-|\s*$/g, '');
if(ssn.length !== 9) {
throw new Error('Invalid input');
}
var student = db.get(ssn);
if (student !== null) { var info =
`${student.ssn},
${student.firstname},
${student.lastname}`;
document.querySelector(`\#${elementId}`) .innerHTML = info;
return info;
} else {
throw new Error('Student not found!');
} } else {
return null;
} }
1| Validation
2| Storage IO write
3| DOM IO
4| Error handling
Figure 6.2 The functional sections of the monolithic function showStudent. To simplify writing tests, these sections should be split into separate functions that deal with validation, IO, and error handling.
In order to widen the testable scope of this function, you need to find ways to split it into loosely coupled components that segregate the pure from the impure. Impure code is difficult to test due to the presence of side effects that can occur when reading and writing to external resources such as the DOM or external storage.
6.2.2 Dependency on shared resources leads to inconsistent results
In chapter 2, I talked about JavaScript’s unwieldy freedom to access globally shared data. Testing programs with side effects requires extreme care and discipline because you’re responsible for managing the state around the function under test. I’ve seen too many cases where adding a new test to a working test suite causes other unrelated tests to inadvertently fail. Why is this? In order for tests to be reliable, they must be self-contained or independent from the rest, which means each unit test essentially runs in its own sandbox, leaving the system in exactly the same state as it was found.
Tests that break this rule can never consistently produce the same outcomes.
I’ll use a simple example to illustrate. Recall the imperative increment function:
var counter = 0; // (global) function increment() {
return ++counter;
}
You can write a simple unit test to ensure that incrementing a number from 0 equals 1;
this result should hold whether you run it once or 100 times. But because the function modifies and reads from external data (see figure 6.3), this isn’t the case.
The second iteration fails because the first modified the external counter variable to 1, preempting the global context for the second run of the same code and causing it to fail in its assertion. By the same token, functions with side effects are also prone to bugs originating from order of evaluation. Let’s examine this next.
QUnit.test("Increment with zero", function (assert) { assert.equal(increment(), 1)
});
QUnit.test("Increment with zero (again)", function (assert) { assert.equal(increment(), 1)
});
Repeating same test
Figure 6.3 Repeating a unit test for the imperative increment function is impossible due to the function’s dependency on the external counter variable.
6.2.3 Predefined order of execution
Along the same lines as consistency, unit tests should be designed to be commutative, which means changing the order in which they run shouldn’t affect their outcome.
For the same reasons as before, this principle doesn’t work with impure functions. To work around this problem, unit testing libraries like QUnit contain out-of-the-box mechanisms to set up and tear down the global testing environment in order for sub-sequent tests to run; but the setup of one test may be completely different than another, so you’re forced to set up preconditions at the beginning of each test. This also implies that for each test, you’re responsible for identifying all the side effects (external dependencies) of the code under test.
To illustrate, let’s create simple tests around increment to verify its behavior against negative numbers, zero, and positive numbers (see figure 6.4). In the first run (left), all tests pass. Shuffling the order of the tests (right), with no additional changes, causes the second test to fail. This is because tests with side effects run based on the assumption that you’ve adequately set up the surrounding state.
As you can see from this simple exercise, even if you manage to successfully run multi-ple unit tests for a particular function by manipulating the global context within each test, you can’t guarantee they’ll work if you move them around. A simple shift in sequence is enough to invalidate all your assertions.
Thinking functionally can also help you build reliable test suites. And if your code is written in a functional style, you’ll get this for free. Instead of hopelessly shoehorn-ing functional principles into your test code, why not write functionally from the beginning and recover the invested time in the test phase? Let’s look at the benefits of functional code for testing.
QUnit.test("Increment with negative",function(assert) { counter = -10;
assert.equal(increment(), -9) });
QUnit.test("Increment with zero",function(assert) { assert.equal(increment(), 1)
});
QUnit.test("Increment with positive",function(assert) { counter = 10;
assert.equal(increment(), 11) });
QUnit.test("Increment with zero",function(assert) { assert.equal(increment(), 1)
});
QUnit.test("Increment with negative", function(assert) { counter = -10;
assert.equal(increment(), -9) });
QUnit.test("Increment with positive", function(assert) { counter = 10;
Incorrectly assuming a preexisting state causes the failure.
Figure 6.4 Falsely making assumptions about the global state of the system causes simple tests to fail. The left side shows that all tests executed perfectly, because each test correctly prepared its surrounding state before executing. But shuffling the tests (right) invalidates all assumptions about the state.