Designing for Testability - Part 2
In my previous article, I started to share the importance of designing software to make it more testable. It's an important topic as testing plays such a vital role in the software delivery process and is an essential part of producing quality software. Software that is difficult to test and automate, is unlikely to be able to achieve or maintain high-quality standards without exhaustive testing efforts. And this includes the ability of developers to write unit tests. We often speak of the testing pyramid which sees the highest number of tests at a unit test level. But if developers are struggling to find ways of writing unit tests for the code, it's a clear indication that the design of the software s not testable.
All this extra testing effort required from software that doesn't follow testable design standards will often lead to fewer and bigger software releases, which in themselves are far riskier than regular, frequent, small releases. So, by extension, if you want to develop software that is considered to be of a "high quality", it needs to be built with the needs of testing in mind.
I identified 5 aspects of testable design that are important for Architects and developers to think about when designing their software applications and today I will present another 5 important aspects of testable design that are vital to ensure your software is easier to test and allow the team to write automated tests across all payers that will improve the ability to enhance and improve rapidly too.
Cohesive classes are classes that do only one thing. Cohesive classes tend to be easier to test. This is because fewer responsibilities imply fewer test cases, fewer responsibilities often imply fewer dependencies which in turn incurs a lower testing cost, as the reduced complexity makes the tests easier to script.
On the other hand, a non-cohesive class tends to consume a large amount of testing effort. The mocking effort to cater for all the dependencies becomes time-consuming to put together, and the high number of test cases required to then cover all possible permutations will not only take longer to script but increase the maintenance effort considerably as well.
Refactoring non-cohesive classes are, therefore, important tasks when it comes to testability. A common way to do this is by splitting the non-cohesive class into several smaller-but-cohesive classes. Each small class can then be tested separately, and the class that combines them might rely either on mock objects to assert the correctness of the interactions among the dependencies or on an integration test (or both). So, while there will still be tests that require some extensive mocking, that effort is greatly reduced, and the number of tests required to ensure successful integration as opposed to catering for every possible permutation is now reduced.
Coupling refers to the number of classes that a class depends on. A highly coupled class requires several other classes to do its work. And much like cohesiveness, this increased complexity decreases the testability of the code.
A tester trying to test a highly dependent class ends up having to test all its dependencies together. If the tester then decides to use stubs/mocks, the costs of setting them up will also be higher than they needed to be (just imagine yourself setting up 10 or 15 stubs/mocks to test a single class). Moreover, the number of test cases that would be required to achieve a minimum amount of coverage is too high, as each dependency probably brings together a whole set of requirements and conditions.
Reducing coupling, however, is often tricky, and may be one of the biggest challenges in software design. Both for legacy systems - which may have a high level of coupling built-in – and new systems, as coupling can often make the initial development process easier. The impact it has on testing though will quickly outweigh that development speed through the entire development process and so it's important that we consider ways to remove coupling in our code base as much as possible.
A common coupling-related refactoring is to group dependencies together into a higher and meaningful abstraction.
To further illustrate this, imagine that class A depends on B, C, D, and E. After inspection, you notice that B interacts with C, and D interacts with E. Devising a new class that handles the communication between B and C (which we will call BC) and another one that handles the communication between D and E (which we will call DE) already reduces A's coupling. Now A depends only on BC, and DE, making it far easier to mock and test while also allowing those coupled pairs of BC and DE to also be tested more easily
In general, pushing responsibilities and dependencies to smaller classes and later connecting them via larger abstractions is a better way to think about software design.
As has already been showcased earlier, simplicity is easier to test than complexity. Code that features complex conditions (e.g., an if/switch statement composed of multiple Boolean operations) requires greater effort from testers as the number of decisions and boundaries that need to be tested increases. The increased complexity of the code in general also increases the chance of things going wrong.
Ideally, you want to keep the number of decisions that are required in your code to a bare minimum and so when thinking about how your program will need to make certain decisions, it’s worthwhile not looking for which path is the easiest to code, but which requires the least number of decisions to operate. Coincidentally, this may also improve the performance of the code too, albeit minor.
Changing some of these conditions though can be difficult, as the code still needs to take them somewhere. Reducing the complexity of such conditions, for example by breaking it into multiple smaller conditions, will not reduce the overall complexity of the problem, but will "spread" it over different parts of the once and make each function easier to test, increasing the overall testability of the solution, even if not solving its overall complexity.
Private methods are another coding principle that makes it easier for the developer, but harder for the tester. As private methods can only be called from inside the class where they are defined, it means they cannot be tested independently by the tester. Now, some developers may argue that a private method shouldn’t need to be tested separately as it's part of the class, and therefore simply testing the class should be sufficient. But there are reasons that you may want to test these methods separately.
In principle, testers should test private methods only through their public methods. However, testers often feel the urge to test a particular private method in isolation. One common cause for this feeling is the lack of cohesion or the complexity of this private method. In other words, this method does something so different from the public method, and/or its task is so complex, that it has to be tested separately.
In terms of the design, this might mean that this private method does not belong in its current place. A common refactoring is to extract this method, maybe to a brand-new class. There, the former private method, now a public method, can be tested normally. The original class, where the private method used to be, should now depend on this new class.
As has already been made apparent, stubbing and mocking is very important to testing and static methods adversely affect testability, as they cannot be stubbed easily. Therefore, a good rule of thumb is to avoid the creation of static methods whenever possible.
The only exceptions to this rule are possibly utility methods which are responsible for performing routine programming tasks where testing is not as crucial and so there is more benefit to keeping these methods static.
If your system has to depend on a specific static method, e.g., because it comes with the framework your software depends on, then it would be beneficial to add a layer of abstraction on top of it to increase its testability.
The same recommendation applies when your system needs code from others or external dependencies. Again, creating layers/classes that abstract away the dependency might help you in increasing testability. Developers should not be afraid to create these extra layers. While it might seem that these layers will increase the overall complexity of the design, the increased testability pays off.
Testability is just good software design in action
Finally, it’s worth noting how there is a deep synergy between well-designed production code and testability. So even if you’re not convinced of the importance of testability, just sticking to proven design best practices should lead you and your team to a more testable design.
High-quality software is only achieved when software systems are designed with testability in mind, and rigorous testing techniques are applied.