We said that programming is easy in the same way that playing a keyboard instrument is easy: “All one has to do is hit the right keys at the right time.”
But there is one difference. If you play a perfect concert on the piano, nobody can take it away from you. If you write a program that fulfills business
requirements to the letter, well, return to it after five years of maintenance.
You may hear the development team muttering that it would be easier to rewrite it than to continue maintaining it. “Time to put it out of its misery,”
they will say.
This section is about how to prevent that sorry state of affairs so your code can live a long and healthy life!
Investing for the Future with Unit Tests
Unit tests help your perfect program weather the storm of changes that you and others make to it over time. A unit test is a piece of code that verifies a small portion of your application. The unit in unit test behaves as if it should be given a specific set of conditions. More often than not, the unit under test will be a function, but that’s not always the case.
The code in the body of a unit test generally follows the pattern arrange, act, assert.
First, the test arranges: It establishes the conditions under which it will exercise the unit, perhaps configuring dependencies or setting up inputs to a function.
Next, the test acts: It exercises the unit under test. If the unit is a function, for instance, the test will execute the function with the inputs configured during the arrange stage.
Finally, the test asserts: It verifies that the unit behaved as expected when exercised under the established conditions. If the unit under test is a
function, the assert phase may verify that the function returned the expected value.
Investing in the creation of a full suite of unit tests is insurance against
future breaking changes to your program, and is the best investment that you can make to ensure that your application remains reliable. A failing unit test is a red flag that a change has altered the functionality of your program and
that the change that caused the failure warrants close inspection.
The section Using a Testing Framework in Chapter 2 covers, in detail, how to use the Jasmine unit test framework to create a JavaScript unit test suite.
Practicing Test-Driven Development
Test-driven development (TDD) helps ensure that the program you write is perfect in the first place. TDD is the practice of writing a unit test before you write the application code that allows the test to pass. Along with creating a full suite of unit tests as you develop your application, TDD helps you design the interfaces to the units as you create them.
The following sections describe the practice of TDD, and concepts to keep in mind while writing code in order to make the code testable.
A developer practicing test-driven development performs the following steps for each and every change he or she makes to an application. The change may be adding a completely new feature, tweaking an existing one, or fixing a bug.
1. Write a unit test that will succeed if the change is made correctly, but fails until then.
2. Write the minimum amount of application code that allows the test to pass.
3. Refactor the application code to remove any duplication.
The preceding steps are commonly summarized as red, green, refactor, where red and green signify the failing and passing state of the new unit test.
The most important aspect of TDD is that the test is written before the code that satisfies it. It is also one of the most difficult aspects to adjust to when starting out with TDD. Writing the test first feels just a bit uncomfortable, especially if the change you are testing is minor.
Before the practice of TDD is etched into your programmer-brain, it’s all too tempting to just slightly tweak the application code and move on to the next task on your never-ending list of things to do.
If you want the application you’re developing to be reliable, you must overcome the urge to not write the test. If you skip writing tests for even
“minor” changes (if there really is such a thing), the net effect is an
application with a lot of untested code. Your unit test suite, once a reliable safety net ensuring proper behavior of your application, quickly becomes
nothing more than a source of a false sense of security.
You may also be tempted to write the test after you change the application code. Again, you must not succumb to the temptation. You really can’t be sure that the change you made causes a test written after the fact to pass. It’s
possible that the new test passes because it’s faulty, adding no additional value to your unit test suite. Also, writing the test after the fact ensures that the application code behaves as it was written, which is not necessarily as it should behave.
We can’t overstate how important we feel the practice of TDD is when developing reliable JavaScript applications. This book contains countless examples of TDD, and Chapter 24, “Summary of the Principles of Test-Driven Development,” is devoted entirely to the topic.
Engineering Your Code to Be Easy to Test
One of the—if not the—most significant steps that you can take to create code that is easy to test is to properly separate concerns. (See “The Single
Responsibility Principle,” earlier in this chapter.)
For example, the following code sample defines the function
valididateAndRegisterUser, which has multiple concerns. Can you identify them?
var Users = Users || {};
Users.registration = function(){
return {
validateAndRegisterUser: function validateAndDisplayUser(user){
if(!user ||
user.name === "" ||
user.password === "" ||
user.password.length < 6) {
throw new Error("The user is not valid");
}
$.post("http://yourapplication.com/user", user);
$("#user-message").text("Thanks for registering, " + user.name);
} };
};
The function is doing three things:
It verifies that the user object is populated correctly.
It sends the validated user object to the server.
It displays a message in the UI.
Accordingly, we can enumerate three separate concerns at work:
User verification
Direct server communication Direct UI manipulation
Now try to come up with all the conditions that must be tested to ensure that the validateAndRegisterUser function operates correctly. Take your time;
we’re not going anywhere.
How many did you come up with? Probably quite a few. Here are some of the conditions we came up with:
An Error is thrown if user is null.
A null user is not posted to the server.
The UI isn’t updated if user is null. An Error is thrown if user is undefined.
An undefined user is not posted to the server.
The UI isn’t updated if user is undefined.
An Error is thrown if user has an empty name property.
A user with an empty name property is not posted to the server.
The UI isn’t updated if the user has an empty name property.
And so on and so forth. Those are just some of the invalid conditions that must be tested; there are many more. Additionally, tests that ensure valid conditions behave properly must be written as well, including tests that make sure the UI has been properly updated.
“But,” you may be wondering, “if I write a test that ensures an Error is thrown when user is provided without a name, why do I also need to write tests to make sure the nameless user isn’t posted to the server and used to update the UI? After all, the Error is thrown before those other things happen.”
The logic behind the question is sound, given the way that the code is written
today. If you omit those seemingly irrelevant tests, however, what happens when someone comes along tomorrow and changes the function so that the verification of user happens at the end?
var Users = Users || {};
Users.registration = function(){
return {
validateAndRegisterUser: function validateAndDisplayUser(user){
$.post("http://yourapplication.com/user", user);
$("#user-message").text("Thanks for registering, " + user.name);
if(!user ||
user.name === "" ||
user.password === "" ||
user.password.length < 6) {
throw new Error("The user is not valid");
} } };
};
The test that you wrote to ensure an Error is generated still passes, but the function is now significantly broken. Those additional tests don’t seem so irrelevant now, do they? Admittedly, that change is not likely one someone would make intentionally, but accidents do happen.
Make no mistake, this code is testable, it’s just not easy to test. There are so many permutations of conditions that must be tested, both valid and invalid, it’s unlikely that they will all be covered. If all of the code in an application is written in this manner, we can all but guarantee that application won’t have proper unit test coverage.
Suppose, however, that instead of working with the three separate concerns in the validateAndRegisterUser function, each of those concerns was
extracted into a separate object with that concern as its single responsibility.
For the purpose of this example, assume that each of the new objects has its own, complete test suite. The code for validateAndRegisterUser may then look something like this:
var Users = Users || {};
Users.registration = function(userValidator, userRegistrar, userDisplay){
return {
validateAndRegisterUser: function validateAndDisplayUser(user){
if(!userValidator.userIsValid(user)){
throw new Error("The user is not valid");
}
userRegistrar.registerUser(user);
userDisplay.showRegistrationThankYou(user);
} };
};
The new version of the registration module leverages dependency injection to provide instances of the objects that are responsible for user validation, registration, and display. In turn, validateAndRegisterUser uses the injected objects in place of the code that directly interacted with the different
concerns.
In essence, validateAndRegisterUser has transformed from a function that does work to a function that coordinates work done by others. The
transformation has made the function much easier to test. In fact, the following six conditions are the only ones that must be tested in order to completely test the function:
An Error is thrown if user is invalid.
An invalid user is not registered.
An invalid user is not displayed.
The userRegistrar.registerUser function is invoked with user if user is valid.
The userDisplay.showRegistrationThankYou function is not executed if
userRegistrar.registerUser throws an Error.
The userDisplay.showRegistrationThankYou function is executed with
user as an argument if user has been successfully registered.
Six tests. That’s it. We listed nine tests for the original version of the function, and we didn’t even finish all of the error conditions.
Creating small, simple modules that isolate separate concerns leads to code that is easy to write, test, and understand. Code with those properties is more likely to remain correct in the long term.
You might think that test-driven development would slow you down. If you write the tests after the code and the code does not properly separate its
concerns, that will be true. You will have baked in many mistakes, and writing
unit tests will be just about the slowest possible way to find them.
However, if you follow the red-green-refactor cycle to produce small increments of code, you will actually go faster, just as the musician who
practices slowly and carefully at first actually masters the piece more quickly.
First of all, because each increment is simple, you will be less likely to make a mistake, so you will save a lot of debugging time. Second, because your code will be completely covered by tests, you will be able to refactor without fear.
That will enable you to keep your code DRY, which generally means your code base is smaller, presenting fewer places where things can go wrong. DRY also means reusable, and we all know that reusable code saves time.