In the article Introduction to Unit Test, we highlighted the vital importance of Unit Testing. But before applying unit tests effectively in current or future projects, we need a crucial foundation: learning how to write Testable Code (Production Code designed to allow straightforward unit testing). This is often the hardest hurdle, so this article synthesizes key principles to help you write testable code effortlessly.
Before reading this article, make sure to review these prerequisite guides:
Write a Simple Unit
Writing a simple, cohesive Unit[1] makes testing significantly easier due to several key factors:
- Fewer
Test Casesare needed per unit. - Fewer dependencies are involved, which simplifies test setup and
Mocking[2]. - Drastically reduces the risk of missing edge cases.
What about complex business requirements?
Decompose large requirements into smaller, focused sub-problems and solve each one individually—the classic “divide-and-conquer” strategy ;)).
Separation Between Logic and Presentation
Separating business logic from persistence, UI presentation, and network/API I/O is mandatory for long-term maintainability. This separation is the foundational philosophy behind architectures like MVC, MVVM, and Hexagonal Architecture.
Even if your project does not mandate unit tests, maintaining this boundary is essential software engineering hygiene.
Furthermore, this separation directly enables Consistent Units in the next section.
Consistent Units
Consistency (determinism) means that given identical input, a Unit will always return the exact same output. This deterministic predictability is what makes unit test assertions possible, as introduced in Introduction to Unit Test.
Common sources of non-determinism include:
- Random number generation (
Random, UUIDs). - System clock / current time calculations (e.g., computing age from a date of birth relative to
now()). - External system I/O (Databases, REST APIs, Caching layers).
- Context-dependent state (thread context, security context, session scope).
- …
Here are 2 practical strategies to handle non-deterministic logic:
- Explicit Parameter Passing: Separate pure business logic from non-deterministic calls. Pass the output of the non-deterministic call as an explicit input argument into the pure logic unit, testing only the deterministic business logic.
- Abstractions & Inversion of Control: Abstract non-deterministic operations behind interfaces using
IoC/DI. The business logic acts as theClientand non-deterministic logic as theService Implementation. You test the business logic while substituting the dependency with aMock2.
Apply IoC/DI
Adopting IoC/DI allows you to effortlessly substitute dependencies with Mocks2 in classes under test, while simultaneously satisfying DIP.
Apply S.O.L.I.D Principles
Embracing S.O.L.I.D principles provides immense testability benefits:
Single responsibility principle: Keeps classes small and cohesive, reducing the matrix of test cases per unit.Open/Closed principle: Eliminates the need to rewrite existing unit test suites when extending features with new implementations.Liskov substitution principle: Suppose Class A depends on Class B, and both are thoroughly unit tested. If you introduce Class C extending Class B and replace B with C, satisfying LSP ensures Class A does not need to be rewritten or retested to accommodate C.Interface segregation principle: Combined withSRP, ISP ensures implementing classes only deal with relevant methods, making mock setups lightweight.Dependency inversion principle: High-level business logic depends only on abstractions, allowing complete isolation in test fixtures viaIoC/DI.
Conclusion
Writing Testable Code demands intentional discipline, but mastering it elevates your craftsmanship to the highest professional standard. Refactoring tightly coupled code into testable architectures later is costly and fraught with risk, so striving for testability from day one is always the winning approach.
Thanks for reading!
Reference articles
- Guide To Writing Testable Code
- 4 Properties of Highly Testable Code
- Writing Testable Code
- Why consistency is one of the top indicators of good code
A
Unitrefers to amethodin OOP, afunctionin functional programming, or aprocedurein procedural programming.Mocking is simulating the behavior of external collaborators during unit testing. See details in Use Mock to make Unit Test easy. ↩︎ ↩︎