Recently, during a discussion on unit testing, I made an inadvertent comment about how unit testing is like desk-checking a function. That comment was treated with a set of blank stares from the room. It looks like desk-checking is no longer something that is taught in comp-sci education these days. After explaining what it was, I felt like the engineers in the room were having similar moments I had when a senior engineer would talk about their early days with punch cards just after I entered the field. I guess times have changed…
What followed was a very interesting discussion on what Unit Testing is, why it is important and how Mocking fills in one of the last gaps in function oriented testing. Through this discussion, I had my final Unit Testing light bulb moment and it all came together and went from an abstract best-practice to an absolutely sane and necessary best practice. This article puts out a unified view on what Unit Testing is, is not, and how one can conceptualize unit tests.
Unit Testing is abstract
Unit testing is ensuring that the unit of code that is being tested is operating as designed. This is the unit in isolation, no supporting functionality. You are stimulating the function to ensure it is operating as designed and continues to operate as implemented. You change the function, you will probably need to change the unit tests. Your unit tests should represent a full mapping to every path and conditional logic branch in your code.
To achieve this you need to turn off the inherent need to test something in a real context. You need to remove the consideration for the system functional testing as well as the underlying subsystems that support your function. Starting from first principles, I’ll build up unit testing from scratch.
Control Flow Graphs
A control flow graph is a representation, in graph form, of the control flow within a function. Cyclomatic Complexity is a measure of that. An example of simple call graphs are available on wikipedia control flow graph, I have included them below.
Obviously, the examples are too simple to be useful, a considerably more complex control flow graph is shown below. The image below is from the Dr Garbage website. Most functions will fall somewhere in between.
Back before I entered the workforce, desk checking was considered a best practice as you went from design to implementation. You would select a few areas in your code, and with pen in hand, make notes as to how you would expect the code to flow based on various conditions. You’d scribble out tables. You’d then visually wander through your code making sure that it appeared to go through the correct paths.
In essence, you were taking the control flow graph and working out if you could go down each and every one of those paths.
Enter Unit Tests
Unit tests help proceduralize the desk checking of yore. You call the unit under test in different ways through it’s functional interface. This should trigger different progressions through the different control flows. This ensures that there is reasonable coverage for the function. Unfortunately in most real world code there is some underlying functions that get called to help deliver higher level functionality. If you call those real functions (such as network or database functions) that operate on real data, you have slipped form unit testing into subsystem testing.
The cardinal rule of unit testing is Test Only the Unit Under Test. To do this, you should almost blindly focus on the control flow of the function as shown above. To tease the unit test into doing what we need, we adjust the stimuli to ensure we go down each path appropriately. Looking at the stimulus available to a function we have the functional interface itself, the environment that the function will refer to and finally the functions called by the function under test.
The Unit Test frameworks, provide the capability for the first two stimulus. Mocking provides the final piece around triggering particular functions. Interestingly, mocking provides two opportunities for test – both the ability to influence the component under test, and the ability to confirm that the component under test is interacting with the system in the correct way.
Mechanics, not Functionality
Avoiding the trap of functional testing when considering unit test cases is sometimes illogical. You need to forget the functional intent of the code and consider primarily the control flow and engineering the stimulus to target that flow. So-called White Box testing re-iterates this in its very dry reference to control and data flow testing.
Cyclomatic Complexity and Refactoring
It should be noted that the complexity of stimulating the unit test sufficiently to execute all paths is increased in difficulty the more conditional logic that a function contains. The Cyclomatic Complexity is a measure which helps quantify the number of conditional branches as well as the number of functional exits. Although the metric exists, it has fallen out of favor as a metric to target and track.
When exploring the stimulus needed to get coverage, a function with many conditionals (or high cyclomatic complexity) will become quite difficult to tease out each control flow. Refactoring will usually simplify the function and allow more focused unit testing.
Feel free to provide feedback in the comments.