Writing testable code might seem like a task done for the sake of easier testing, but it goes beyond that. Sure, testable code does make the testing process smoother, but the benefits don’t stop there.
Testable code enforces good programming practices, such as low coupling, high cohesion, and the principle of the least surprise, which makes the codebase more understandable and manageable. This in turn leads to fewer bugs, easier maintenance, and a more productive development process.
In this guide, we will explore various practices that enhance your code’s testability, showcasing effective strategies to incorporate them into your programming routine.
Ask for What You Need
Each function should ask for only the data it needs to complete its task. Not only does this make the function easier to test, but it also enhances comprehensibility and maintainability by reducing the function’s dependencies.
Here’s an example of a function asking for more information than it needs:
data class User(
private val name: String,
private val password: String,
private val posts: List<Post>,
// ...
)
class AuthService(api: AuthApi) {
fun logIn(user: User): Result = api.logIn(user.name, user.password)
}
The only parameters the api requires are the username and password, however, the whole User object is passed in. Now if we wanted to test the AuthService class it would look something like this:
class AuthServiceContract {
@Test
fun `test simple log in`() {
val mockUser = User(
"John",
"topSecret123",
listOf(),
// ...
)
val mockService = AuthService(MockApi())
assertEquals(mockService.logIn(user), Result.Success)
}
}
Notice how in order to test a simple logIn() method we had to create a dummy object with multiple fields that aren’t relevant to the specific test.
Testing this function isn’t the only problem.
What if the login() function is used in multiple places and in some we don’t have the whole User object?
One option would be to query the whole User object from the database which doesn’t make sense since we have all the information we need.
Another option is to create a dummy User object in those places and pass that in. But if the api.logIn() function adds another parameter later on, our program will work, but you would send dummy data in each place where we didn’t have the complete User object.
To avoid these types of errors you should always Ask for what you need.
class AuthService(api: AuthApi) {
fun logIn(username: String, password: String): Result = api.logIn(username, password)
}
Single Responsibility Principle
If you’re familiar with the Single Responsibility Principle (SRP), you know that every class should have one, and only one, reason to change. However, many people seem to forget that object construction is a responsibility for itself as well. So when we load our constructors with responsibilities beyond simple initialization, we’re essentially violating this principle.
Here’s an example of a constructor doing more work than it’s supposed to:
class NetworkClient {
private val httpClient: HttpClient = HttpClientImpl()
// ...
}
In this example, the NetworkClient creates a new instance of the HttpClient inside the constructor. This approach has several issues:
- We cannot test the NetworkClient without instantiating the real HttpClient
- We cannot polymorphically change the configuration of the HttpClient
- The NetworkClient is hiding its use of HttpClient making it harder to reason about the class’s behavior, dependencies, and interactions with other parts of the system
This can be avoided by simply passing in the HttpClient instead of creating it (Ask for what you need).
class NetworkClient(private val httpClient: HttpClient) {
// ...
}
Avoid Global States
Global state refers to data that’s mutable and accessible from anywhere in your codebase. To an inexperienced developer, global state might not seem like such a problem at first. After all, who doesn’t want readily accessible data from any part of the codebase? However, it’s this very accessibility that proves to be the downfall when it comes to maintaining and testing our code.
object GlobalState {
var isUserLoggedIn = false
}
class UserAuthenticator {
fun authenticate() {
// Authentication logic here...
GlobalState.isUserLoggedIn = true
}
}
While it’s straightforward and seemingly convenient to check isUserLoggedIn from anywhere, it’s a nightmare for testing:
- Running the tests in parallel will result in inconsistent errors
- Debugging which actor is responsible for the global state change becomes more and more difficult as the codebase grows
- Without a proper synchronization mechanism for accessing the global state, inconsistent errors will occur
The solution for such a problem? Dependency injection! Instead of accessing global state directly, we should pass the necessary data into our classes or functions. And define the scope of the Global State we are providing.
class DIModule {
@ClassScope
fun provideLoginState(): LoginState = LoginState()
}
class LoginState {
var isUserLoggedIn = false
}
class UserAuthenticator(private val loginState: LoginState) {
fun authenticate() {
// Authentication logic here...
loginState.isUserLoggedIn = true
}
}
By using dependency injection (DI), the code becomes more modular and easier to test. Instead of having the UserAuthenticator class directly modify the global state, the state is passed in as a dependency. This allows you to easily swap out the actual LoginState for a mock, making the tests more reliable and less likely to be affected by side effects from other parts of the codebase.
In addition, defining the scope of the provided LoginState instance at the DI module level ensures that the same instance is used throughout the class, rather than having multiple instances with potentially different states.
Composition Over Inheritance
In software design, a common principle is “favor composition over inheritance”. This principle suggests that it is more flexible to build complex objects by combining simpler ones, rather than extending simpler objects to provide more complex behavior.
Consider a scenario where we’re building an application that handles different types of audio media playback. Initially, one might think of using inheritance, resulting in a structure like this:
open class AudioPlayer {
fun play() {
println("Playing audio...")
}
}
class MusicPlayer : AudioPlayer() {
fun playMusic() {
play()
println("Playing music...")
}
}
class NotificationPlayer : AudioPlayer() {
fun playNotification() {
play()
println("Playing notification...")
}
}
There are several problems with this approach:
- Tight Coupling: Inheritance leads to tight coupling between classes, making changes risky and potentially affecting numerous dependent subclasses.
- Inflexibility: Subclasses inherit all properties and methods of the parent, including unwanted ones, limiting flexibility in design and making it harder to isolate behaviors for testing.
- Code Reuse Problems: Attempting to achieve code reuse through inheritance can lead to unnecessary duplication or irrelevant behaviors in subclasses.
- Hierarchy Complexity: Deep inheritance trees complicate understanding and maintenance of the code. Overridden methods in subclasses can lead to unpredictable behavior.
interface Player {
fun play()
}
class MusicPlayer(private val player: Player) {
fun playMusic() {
player.play()
println("Playing music...")
}
}
class NotificationPlayer(private val player: Player) {
fun playNotification() {
player.play()
println("Playing notification...")
}
}
class MP3Player : Player {
override fun play() {
println("Playing audio with MP3 player...")
}
}
class StreamingPlayer : Player {
override fun play() {
println("Playing audio with streaming player...")
}
}
In this design, MusicPlayer and NotificationPlayer both have a Player but are not directly linked to a single superclass like AudioPlayer.
This approach increases flexibility, making it easy to add new types of players or change the behavior of existing ones without causing widespread disruptions in the codebase. This is the essence of composition over inheritance: building flexible and robust systems by composing objects with the needed behaviors, rather than relying on a rigid hierarchy.
In Conclusion
Remember, these principles are not just about making code testable, but also about good software design overall. By implementing these practices, we cultivate code that’s robust, maintainable, and high quality. Keep striving for improvement and happy coding!
Test My Code, Not My Limits was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.