Testing AngularJS Part 2 - Efficient Unit Testing
This post takes a step down from covering syntax and processes for testing in AngularJS, and instead will wax a little philosophical. A clear understanding of the material presented in this post will lead you to avoid testing things that don't need to be tested and will save you time and, in some cases, money.
Testing your code is about saving money
The reason we even care about unit testing our code is so that we can get more stuff done in our allotted time. If you're developing an application, and every time you complete a new feature you have to run through every previous feature to make sure you didn't break something from the past (as well as test all the new stuff you just added), clearly this would become a huge burden and a giant time suck. Plus you wouldn't even be very good at it - when developers manually test their own code, they use it in the way they know it's supposed to be used. They have a hard time looking past the bugs that can occur if you put your grandpa in front of the computer and have her test it instead, trying to do things with your app that weren't intended to be done.
With this in mind, it can be very helpful to gain a strong understanding of what you should be testing. Contrary to many beginners' inclination, testing everything under the sun in your application is inefficient and can actually become detrimental. If you follow some basic rules when it comes to testing, you can spend much less time writing tests that in fact do a better job of testing your application!
Testing Philosophies
There are a number of important rules you can follow in order to be testing in a way that saves you or your company time and money.
Test the interface, not the implementation
Consider what it's like to test a cup of coffee fresh from a coffee machine. Testing coffee from a machine involves putting in the correct ingredients and expecting the coffee to come out a certain way. If the coffee tastes good, you can consider the coffee machine to be working and move on with your day. This is an example of testing the interface. You don't really care how the coffee machine did what it did to produce good coffee, you just care that what comes out is acceptable. If instead you tried to test the coffee by opening up the machine and inspecting every detail of how the machine was working, you'd be doing far too much work to retrieve the same result as you could have by just testing the coffee at the end of the process. We'll cover this is more detail later in this post.
Only test your own code
Don't waste time making sure a third-party package is working correctly in your code. Specifically, this means no sending a signal to a third-party function or method and checking that the result comes back as expected. It is okay, however, to check that your code actually called the third party method, but don't worry about making assertions about what comes back from the call.
Specifically, what should I test?
You can achieve (close to) 100% test coverage by understanding the difference between a unit test and an integration/end-to-end test. In an unit test, you only care about the single unit of code you're testing, your "System Under Test" (SUT). Usually you can break this down to the function level - test each function as an individual unit - although this isn't necessarily a hard-and-fast rule.
Being efficient in your unit tests is as simple as following the rules in the following grid:
Message origins and types
Let's walk through the pieces of the above grid so we can fully understand them:
Message Origins
A "message" refers to any code that makes a call to another piece of code - a function that calls another function, perhaps. There are three different places these messages can come from, and it all depends on your point of reference. For example, consider the following code:
function add(a, b) {
return a + b
}
function doMath(a, b, operation) {
if (operation == "add") {
return add(a, b);
}
}
The doMath()
function makes a call (i.e. sends a message) to the add()
function. In this instance, add()
is the receiver of an incoming message - "return the sum of these two numbers" - and doMath()
is the sender of the outgoing message. (This example doesn't have an internal message sent to self included).
Message Types
There are two types of messages that get sent - Query Messages and Command Messages. They're fairly easy to tell apart - a query message asks for something to be returned, but not for something to be altered or changed, and a command message asks for nothing to be returned, but only for something to be changed. In the doMath()
example above, the add()
function is returning the result of a simple operation, so it is receiving an incoming query message. From the point of view of the doMath()
function, it is sending an outgoing query message. Neither is asking for any other object to be changed, so neither is sending a command message.
Understanding the grid
The grid pictured above tells us exactly what we should be testing in order to be the most efficient in our tests, and now that we have a greater understanding of message types and origins, we can learn how to think about our tests as directed by the grid.
Test incoming query messages by asserting that the result that it returns is what we expected
In the add()
method shown above, it receives a message from the doMath()
function and asks for a value to be returned. So all we need to do is test that when we call add(1, 2)
, what gets returned is 3
!
describe("Add function", function () {
it("should return a + b", function () {
expect(add(1, 2)).toBe(3)
});
});
For a function as simple as the add()
function, this is all we need to test to be confident it's working as expected!
Test incoming command messages by asserting that the change it requested actually happened.
In the grid it calls this "direct public side effects", but it basically just means "you asked me to change something, so I changed it." Code:
var count = 0;
function incrementCount() {
count++;
}
Instead of incrementCount()
returning anything, it alters or changes the value of something else. All we need to do in order to test it is make sure it's doing what it's supposed to be doing:
describe("Increment function", function () {
it("should increment count", function () {
count = 0; // Need to reset the global variable 'count' to 0 before the test
incrementCount();
expect(count).toBe(1);
});
});
Test outgoing command messages by making sure your SUT is sending the message to it
It's tempting to test that the system you're sending the message to is doing its job correctly, but that's not the point of unit testing - you need to keep your focus on this unit only. So with the following code:
// The function I'm currently testing
function addEmotion(str, emotion) {
if (emotion === "excitement") {
addExcitement(str);
} else {
addSadness(str);
}
}
// The external function I'm sending an outgoing command message to
// This is actually a combination of a query and command message,
// which happens pretty commonly, so don't be thrown off by the return statement
function addExcitement(str) {
return str + "!!!";
}
If we are testing the addEmotion()
function, and it makes a call to the addExcitement()
, we only need to test that addExcitement
is getting called, not test what is getting returned. The trick to this is that we need to create a jasmine spy of that external function so Jasmine can track if any calls are being made to it. In code:
Still need to fill in an example here.
We don't need to check that addExcitement()
is returning the string we passed in with added exclamation points because that is the job of the unit tests that test the addExcitement()
function. To the addExcitement
function, these are incoming command/query messages, so we test the value that comes back and we've covered our bases sufficiently here. If we were to test for the message that comes back from the call going out from addEmotion
to addExcitement
, we would be writing redundant tests.
Conclusion
Understanding what you really need to test is crucial to be able to spend as little time writing tests so you can spend more time adding new features and fixing bugs. If you learn to test efficiently, you will save yourself and other developers on the project TONS of wasted time spent manually testing each piece of code every time you make any kind of change to the project.