Is there a problem with
RuleEvaluator? It turns out that there is.
RuleEvaluator has three separate responsibilities: It evaluates, parses, and tokenizes. While code can often exist well for a while in that state, invariably, the separate responsibilities become tangled and the class becomes difficult to maintain. Let's see what happens when we start to fix the design problems.
Figure 2: An improved version.
Figure 2 shows the system after we've extracted a class for tokenization. Our testing situation is much better now. The
getNextToken method is public on a new class and we can test it easily. Improving the design also fixed the testing problem.
This example might annoy you. You may think that without any need to reuse the code in
hasMoreTokens(), pulling them out into their own class is pointless it increases the number of classes needlessly. But making this move does make the code more modular. We could reuse
RuleTokenizer at some point in the future. At the very least, it is now very clear where we have to go in the code to make changes in tokenization functionality. Our code is more orthogonal and it is easier to change one thing without impacting another.
If this were the only example of lack of testability being an indicator of poor design, it could just be considered a fluke, but it isn't. In general, pain in unit testing is an indication that there is something wrong. Sometimes this pain is very obvious. For example, if you have to create too many objects to get the one you want to test, it is often an indication of excessive coupling. Other cases are subtler. Let's examine a few.
Other Indicative Smells
It's generally accepted that unit tests should run in isolation. We should be able to execute each of them without affecting the state of any other test. When we don't work this way, we end up with unexplained failures that are hard to reproduce. It often takes a considerable amount of debugging to find the source of these problems. The design problem that this indicates is global mutable state. We've known for decades that global variables can be problematic. Unit testing makes the pain evident and, once again, if we work on the design problem, unit testing becomes easier.
The fact that each of our tests should execute as if they were in a hermetically sealed container often helps us see other design issues. Often, I notice that when teams try to make key classes of their system independently testable, they discover that various objects don't release resources when they are destroyed. In the application, this sort of thing may never be noticed. When you are running thousands of unit tests, however, resource leaks become evident very quickly.
One of the most pervasive testing challenges points toward design issues as well. Teams often notice that unit testing in the presence of a third-party API is difficult. A classic example is UI-intensive code. Developers often place computational code in event handlers. It would be nice to be able to test those computational pieces independently, but it is nearly impossible because you can only exercise the code by triggering events from the UI. The design problem is lack of separation of concerns. Again, once the computation concern is separated from the UI concern, the computation is easily tested.
The big question at the end of all of this is, "Why?" Why do testing problems indicate design problems? A few years ago, I read an interesting paper by Thomas Mullen. In it, he hypothesized that many of the generally accepted principles of good design are "good" because they mirror our cognitive processes. It is easier for us to understand things in small chunks, and it helps when those chunks are independent. Perhaps the reason why unit testing serves as a good probe of design is because it is, essentially, a cognitive process. When we write tests, we are going through a reasoning process, and we are making our reasoning explicit in test code. Pieces of a program that are hard to understand with tests are likely to be hard to understand without them.
It's an odd situation to be in, isn't it? Pain isn't fun. We can create tooling that eliminates much of the pain of unit testing, but then what would we have? We might just end up with less of the feedback that could trigger us to discover better design.