Writing Testable Code: Principles, Patterns, and Pitfalls

Kamal Walia
Nerd For Tech
Published in
4 min readMay 23, 2024

Writing testable code is a cornerstone of effective software development. It not only ensures that your application functions correctly but also enhances maintainability and readability. Testable code allows developers to easily identify bugs, ensure quality, and facilitate changes. This article explores three core principles for writing more testable code, provides insights into helpful design patterns, and highlights common anti-patterns that hinder testability.

Principles for Writing Testable Code:

1. High Cohesion: Do One Thing and Do It Well

High cohesion means that a module or class should focus on a single task or responsibility and perform it well. This principle simplifies testing because the scope of the code is limited and clear. A highly cohesive class or function is easier to understand, test, and maintain. For example, a class responsible for handling user authentication should not also manage user notifications.

2. Delegating to Collaborators: Leveraging Existing Capabilities

Delegation involves using existing objects and methods to accomplish tasks, rather than implementing everything within a single class. This promotes the reuse of code and makes testing easier by allowing you to mock or stub collaborators. For instance, instead of a class directly handling database operations, it should delegate these tasks to a dedicated database handler class. This way, the main class can be tested independently of the database logic.

3. On Refactoring: Continual Improvement of Code Quality

Refactoring is the process of restructuring existing code without changing its external behaviour. Regular refactoring improves code readability and reduces complexity, making it easier to test. Consider refactoring when:
- Implementing a feature for the third time to avoid duplication.
- Adding a feature to code that is hard to test.
- Completing a task where the implementation is hard to test.
- Reviewing pull requests to ensure the new code is testable.
- Tackling bigger issues incrementally and non-disruptively.

Patterns for Enhancing Testability

1. Factory Pattern

The Factory pattern is a creational design pattern that provides an interface for creating objects, allowing subclasses to alter the type of objects that will be created. This pattern helps in assembling objects and their dependencies, making each factory testable as an independent unit. For example, a `VehicleFactory` can create different types of vehicles, ensuring each creation process is tested separately.

2. Strategy Pattern

The Strategy pattern is an operational design pattern that defines a family of algorithms, encapsulates each one, and makes them interchangeable. It allows the algorithm to vary independently from clients that use it. This pattern facilitates testing by isolating the behaviour into discrete strategies. For example, a `RunStrategy` and a `BreakStrategy` can be tested independently of each other.

3. Null Object Pattern

A Null Object represents the absence of an object by providing a default, do-nothing behaviour. This pattern eliminates the need for null checks, simplifying the code and making it more testable. For example, a `NoMoney` object can represent an absence of monetary value, preventing null reference errors and making the handling of “no value” scenarios clearer.

4. Tiny Types

Tiny Types collect related fields into small, well-named classes, giving more meaning and scope to the information they represent. This makes the code more expressive and easier to test. For example, an `Address` class within a `Person` object encapsulates address-related data, making it easier to manage and validate.

5. Result Object

The Result Pattern is an alternative to exception-based error handling. It returns a result object that contains the success or failure of an operation and any error information. This allows the program flow to continue smoothly and makes error handling more testable. For example, `AuthorizationResult` can encapsulate success or error states from an authorization service.

6. Return Early Pattern

The Return Early pattern suggests that functions should have one entry point and multiple exit points. Returning as soon as an alternate condition is met simplifies the function’s logic, making it more readable and easier to test. For example, a function can check for invalid input at the start and return immediately, reducing nested conditions.

Anti-Patterns: Common Testability Pitfalls

1. Constructor Does Real Work

When constructors perform significant work such as creating collaborators or initializing services, it becomes challenging to test the class independently. The solution is to use dependency injection, which separates object creation from its usage, making the code more modular and testable.

2. Digging Into Collaborators

Passing entire objects to methods or constructors, only to use them as containers for specific data, complicates testing. Instead, pass only the required values. This reduces dependencies and makes the method or class easier to test.

3. Global State, Singletons, and Static Methods

Global state and singletons can be accessed and modified from anywhere, leading to unpredictable behaviour and tight coupling. Static methods often introduce similar issues. Using dependency injection can mitigate these problems by promoting loose coupling and more predictable behaviour.

4. Non-Deterministic Factors

Functions that produce different outputs for the same inputs are hard to test. Removing non-deterministic factors, such as external service calls, by mocking or stubbing dependencies ensures consistent and predictable test results.

5. The “God Class”

A “God Class” handles too many responsibilities, making it hard to understand, test, and maintain. Breaking it down into smaller classes, each with a single responsibility enhances testability and code manageability.

Conclusion:

By adhering to these principles, utilizing helpful patterns, and avoiding common anti-patterns, developers can write code that is more testable, maintainable, and scalable.

--

--

Kamal Walia
Nerd For Tech

👨‍💻 Software Engineer | Tech Enthusiast | Wordsmith 📝