How Dependency Injection Makes You Write Better Unit Tests

Software Development
Published May 28, 2022 ยท 4 min read
The purpose of writing unit tests is to test a unit in isolation. However, sometimes isolation may not be accessible when the unit we are testing logically depends on other external units. Here comes Dependency Injection (DI) alongside other concepts to serve the mentioned purpose.

Motivation

Let's start with the following example:

package me.ezzedine.mohammed.di;

import me.ezzedine.mohammed.di.Repository;
import me.ezzedine.mohammed.di.LibraryService;
import lombok.NonNull;

public class Library {
    private Repository<Book> bookRepository;
    private Repository<Client> clientRepository;
    private LibraryService libraryService;

    public Library() {
        bookRepository = new BookRepository();
        clientRepository = new ClientRepository();
        libraryService = new LibraryService();
    }

    public void borrowBook(@NonNull String bookIsbn, @NonNull String clientId) throws BookNotFoundException, ClientNotFoundException {
        Book book = bookRepository
                .get(bookIsbn)
                .orElseThrow(() -> new BookNotFoundException(bookIsbn));

        Client client = clientRepository
                .get(clientId)
                .orElseThrow(() -> new ClientNotFoundException(clientId));

        if (libraryService.isBookAvailable(book) && libraryService.isClientEligibleToBorrow(client)) {
            libraryService.clientBorrowBook(clientId, bookIsbn);
        }
    }
}

In the above code, we have a class representing a library. This class references other helper classes, which are: Repository<Book> and Repository<Client> for data access, and LibraryService for the application logic of a library. For the sake of this example, we limited the functionality of the class to one method only to borrow a book. The logic of the method is straightforward: get the book and client, validate them, and delegate the call to the application layer. However, testing it in isolation from the logic in the needed dependencies is not possible, since we then will be testing all four classes together, hence, it's not a unit test anymore.

Concept

The concept of dependency injection is to avoid creating instances of the needed classes from inside the class that requires them. Instead, we require them in the constructor arguments and move the complexity one step higher. That can be done easily as follows:

package me.ezzedine.mohammed.di;

import me.ezzedine.mohammed.di.Repository;
import me.ezzedine.mohammed.di.LibraryService;
import lombok.NonNull;

public class Library {
    private Repository<Book> bookRepository;
    private Repository<Client> clientRepository;
    private LibraryService libraryService;

    public Library(Repository<Book> bookRepository, Repository<Client> clientRepository, LibraryService libraryService) {
        this.bookRepository = bookRepository;
        this.clientRepository = clientRepository;
        this.libraryService = libraryService;
    }

    // rest of the class
}

We could also use different ways to inject the requried instances, like using setters, or the famous builder pattern.

Mocking

Dependency injection alone does not solve our original issue, which is testing in isolation. It only provides us with the ability to provide the required instances at runtime. In order to achieve the concept of isolation, we need to provide mock instances of the needed classes.

The idea of mocks is fairly simple: I want to provide an instance that is compatible with the required type at compile-time, but I also want to specify its behavior and the values it returns. This way, I can control the full scenario of execution when writing a unit test.

There are several libraries that help create mock instances of types. In java, I prefer Mockito.

Writing Unit Tests With Mocks

The following example demonstrates the concept of writing unit tests with the help of dependency injection and mocks (using Mockito)

package me.ezzedine.mohammed.di;

import me.ezzedine.mohammed.di.Repository;
import me.ezzedine.mohammed.di.LibraryService;
import org.junit.jupiter.api.BeforeEach;
import org.mockito.Mockito;

import static org.junit.jupiter.api.Assertions.*;

class LibraryTest {

    private Repository<Book> bookRepository;
    private Repository<Client> clientRepository;
    private LibraryService libraryService;
    
    private Library library;
    
    @BeforeEach
    public void testSetUp() {
        bookRepository = Mockito.mock(Repository.class);
        clientRepository = Mockito.mock(Repository.class);
        libraryService = Mockito.mock(LibraryService.class);
        
        library = new Library(bookRepository, clientRepository, libraryService);
    }

    // tests

}

The first step we did is that created mock instances of each of the required services, then we passed them to the library class that we are interested in testing.

Our next step is to start writing the tests:

package me.ezzedine.mohammed.di;

import me.ezzedine.mohammed.di.Repository;
import me.ezzedine.mohammed.di.LibraryService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;

import static org.junit.jupiter.api.Assertions.*;

class LibraryTest {

    // ...

    @Test
    @DisplayName("Throws BookNotFoundException when book with the provided ISBN is not found.")
    public void throws_book_not_found_exception_when_book_does_not_exist() {
        Mockito.when(bookRepository.get(Mockito.any())).thenThrow(BookNotFoundException.class);
        assertThrows(ookNotFoundException.class, library.borrowBook("ISBN", "clientId"));
    }
}

In the above test, we are specifying the behavior of the book repository mock instance by explicitly telling it to throw an exception whenever the method get() is called. This way, we can test the behavior of the method Library.borrowBook in isolation in the scenario where the book is not found.

We can do more of this with the functionalities Mockito and other similar libraries provide us with. All this help us achieve a good quality of tests that are run in isolation of external dependencies.