Testable Design
Last modified by chrisby on 2024/09/19 10:50
What Makes Code Testable?
Object-Oriented Programming
Although both procedural and functional programming paradigms support testing, managing test complexity can become a challenge as the system scales. As complexity increases, maintaining test isolation becomes more difficult. An effective strategy is to use object-oriented techniques that encapsulate complex logic within hierarchies of classes, creating testable units that hide their internal complexity behind interfaces. Wrapping procedural or functional code in classes can combine the strengths of several paradigms. Any code base that reaches a certain level of complexity can benefit from an object-oriented approach to improve testability.
Object Structure
- Avoid direct instantiation of objects in units being tested; doing so tightly couples the implementation and restricts substitutability. Use factories and dependency injections instead.
- Avoid reflections undermining encapsulation.
- Keep private methods simple. Their functionality should be tested indirectly through the public methods they call, rather than testing them directly. If it becomes too complex, extracting a separate class should be considered.
- No business logic in constructors and static initialization blocks. Unnecessary logic could be inadvertently executed, or unexpected side effects could occur when a subclass calls the superclass constructor. Instead, place this logic in methods or other objects that can be replaced.
- Avoid the singleton design pattern because its private constructor prevents mocking. Consider a getInstance() method that returns an interface type instead.
Patterns and Principles
- Procedural code is harder to mock than object-oriented code. So if mocks are required, the code should be object-oriented.
- Favor composition over inheritance. Use inheritance only for polymorphism, as it is less flexible than composition. Composition should be the primary approach to code reuse.
- Outsource code that is difficult to test. For example, network connections to external services should not be part of unit or component tests and should therefore be mocked away.
- Create intermediate wrappers with testable interfaces for better mockability when integrating third-party libraries and immutable, untestable code.
- Modular design:
- Create distinct, small modules, each with unique functionality and responsibilities. This ensures that changes in one module are isolated from others.
- Maintain loose coupling: Minimize the dependencies between modules.
- Develop self-contained modules: Each module should be a self-contained unit of functionality. New features should be introduced by creating new modules.
Dependency Management
- Low number of dependencies in a unit for low complexity.
- Dependencies are injectable via constructor injection or setter injection.
- Injectable is equivalent to mockable. Mocks make testing with dependencies much easier. IoC containers are good tools for building many applications consisting of classes into which dependencies need to be injected.
- Injectable promotes decoupling. Dependency injection allows for more flexible and extensible code through interchangeable dependencies.
- Favor dependency injection over service lookups.
- Avoid method chaining, which can make it difficult to replace a units dependencies.