I'm presently reading Building Maintainable Software by Joost Visser and some of the maintenance guidelines they recommend include: A) each unit/method should be short (less than 15 lines per method) and B) methods should have a low cyclomatic complexity. It suggests that both these guideline helps with testing.
The example below is from the book explaining how they would refactor a complex method to reduce the per method cyclomatic complexity.
Before:
public static int calculateDepth(BinaryTreeNode<Integer> t, int n) {
int depth = 0;
if (t.getValue() == n) {
return depth;
} else {
if (n < t.getValue()) {
BinaryTreeNode<Integer> left = t.getLeft();
if (left == null) {
throw new TreeException("Value not found in tree!");
} else {
return 1 + calculateDepth(left, n);
}
} else {
BinaryTreeNode<Integer> right = t.getRight();
if (right == null) {
throw new TreeException("Value not found in tree!");
} else {
return 1 + calculateDepth(right, n);
}
}
}
}
After:
public static int calculateDepth(BinaryTreeNode<Integer> t, int n) {
int depth = 0;
if (t.getValue() == n)
return depth;
else
return traverseByValue(t, n);
}
private static int traverseByValue(BinaryTreeNode<Integer> t, int n) {
BinaryTreeNode<Integer> childNode = getChildNode(t, n);
if (childNode == null) {
throw new TreeException("Value not found in tree!");
} else {
return 1 + calculateDepth(childNode, n);
}
}
private static BinaryTreeNode<Integer> getChildNode(
BinaryTreeNode<Integer> t, int n) {
if (n < t.getValue()) {
return t.getLeft();
} else {
return t.getRight();
}
}
In their justification they state (emphasis mine):
Argument:
“Replacing one method with McCabe 15 by three methods with McCabe 5
each means that overall McCabe is still 15 (and therefore, there are
15 control ow branches overall). So nothing is gained.”
Counter Argument:
Of course, you will not decrease the overall McCabe complexity of a system by
refactoring a method into several new methods. But from a
maintainability perspective, there is an advantage to doing so: it
will become easier to test and understand the code that was written.
So, as we already mentioned, newly written unit tests allow you to
more easily identify the root cause of your failing tests.
Question: How does it become easier to test?
According to the answers to this question, this question, this question, this question and this blog we should not be testing private methods directly. Which means we need to test them via the public methods that use them. So going back to the example in the book, if we are testing the private methods via the public method, then how does the unit tests functionality improve, or change at all for that matter?
Best Answer
After having written lots of test, I am strongly in favour of splitting up large methods, and of testing private methods. Splitting up functionality into smaller steps has two great advantages:
By introducing a name for an operation, the code becomes more self-documenting.
By using smaller methods, the code is simpler and thus more likely correct. E.g. you can immediately understand
getChildNode()
.While the overall program cyclomatic complexity isn't reduced, these two advantages outweigh the bit of extra code in my book. There is a third advantage: the code becomes much easier to test, assuming we can get around the
private
access modifier.People who advise against testing private implementation details make a good point: the test should show that the implementation adheres to it's public interface, but this can only be done by black-box tests of the public methods. Such tests don't require that you get 100% coverage, but missing coverage is an indication of dead code that's not required by the specification. Since TDD tests should define externally observable behaviour, such tests fall into this category.
But you can also use a different approach to testing: showing that an existing implementation is likely correct, and works as the programmer expected. Since we already have the code, we can design our tests to maximize coverage. We can use boundary values to meticulously exercise the behaviour of the program. In other words, we can write white-box tests that know about implementation details of the system under test. These tests are highly coupled to the implementation, but that is OK as long as you also have more general black-box tests.
As a result, I prefer a few black-box tests that walk through the “happy path” and basic guarantees of the interface. But it is way to cumbersome to exercise all possibilities: with each input parameter or state variable in the function, the test space increases exponentially! A function
f(bool)
might take two tests, a functionf(bool, bool)
2²=4, andf(bool, bool, bool, bool)
already 24=16. This is untenable. But by splitting a large function into smaller functions, I only have to show that each smaller function works as expected, and that the functions work together correctly (I call this testing by induction). My workload now adds, instead of multiplying – a great improvement if you want to be thorough!In your concrete example, either possibility is suboptimal because in the first try there's loads of code duplication requiring duplicate testing, and in the second try there are interdependencies between the functions that cannot be mocked away. Only
getChildNode()
is easy to test, but this function is incorrect ifn
is equal tot.getValue()
, which will never happen if that function is only ever called bytraverseByValue()
. An easy to test alternative would be:This specific case doesn't even use any helper functions, because it's simple enough to do without – there are only 4 paths through this code. Your previous implementation hid this by nesting conditionals when the other branch had already been terminated by a
throw
orreturn
, and by unnecessary code duplication.However, testing a recursion or loop can be difficult. While we could create a fairly complex tree and check for the correct result, we would like a way to check the loop invariant. In a language with higher-order functions, this might be:
Now we could run a test plan like:
calculateDepthLoop(null, ANY, ANY)
throws.calculateDepthLoop(Tree(x, ANY, ANY), x, ANY) is
0`.calculateDepthLoop(Tree(x, left, ANY), y, callback)
fory < x
invokesresult = callback(left, y, callback)
and returns theresult + 1
.calculateDepthLoop(Tree(x, ANY, right), y, callback)
forx < y
invokesresult = callback(right, y, callback)
and returns theresult + 1
.with only 4 tests (one for each path) we can be sure that
calculateDepthLoop()
works as expected. We might want a couple more just to be sure that everything works for all valid values ofx
andy
. Now we only need another test to check that everything integrates as it should, this can be done with a black-box test ofcalculateDepth()
, which I'd do by creating a moderately complex tree requiring the function to recurse both left and right and return some value.