When we are writing automated tests for our code, often we notice that it's not possible to invoke the real dependencies of the system under test. The problem might be that:
- The problematic dependency invokes an external API which cannot be accessed from our test environment.
- We cannot invoke the real dependency because invoking it would cause unwanted side effects.
- The real dependency is too slow and invoking it would slow down our test suite.
If we cannot use the real dependency, we have to replace it with a test double that must provide the same API as the replaced dependency. This ensures that the system under test will think that it's interacting with the real thing.
There are multiple different test doubles and each test double helps us to solve a very specific problem. This time we will take a closer look at a test double called a stub.
After we have finished this blog post, we:
- Know what a stub is.
- Understand how a stub works.
- Understand when we should use stubs.
Let's begin.
What Is a Stub?
A stub is a test double that returns a configured response every time when an expected interaction happens between the system under test and a stub. A stub must fulfill there requirements:
- A stub must provide the same API as the replaced dependency. This means that if the external dependency is a class, our stub must extend it and override all methods. On the other hand, if the replaced dependency is an interface, our stub must implement the replaced interface.
- We must be able to configure the response that's returned every time when an expected interaction happens between the system under test and a stub. This means that we can configure the stub to either return an object or throw an exception.
- When an unexpected invocation happens between the system under test and a stub, a stub can either return a default response (such as
null, an emptyOptionalor an empty collection) or throw an exception. - A stub provides no way to verify the interactions that happen between the system under test and the stub.
- An expected invocation means that the system under test invokes the stubbed method and passes the expected arguments to the invoked method.
- An unexpected invocation means that the system test either invokes a method that's not stubbed or invokes a stubbed method and passes unexpected arguments to the invoked method.
Additional Reading:
Next, we will put theory into practice and create a new stub. Let's start by taking a quick look at the system under test.
Introduction to the System Under Test
The system under test has one dependency (TodoItemRepository) which declares a method that gets the information of the requested todo item from the database. This method (findById()) takes the id of the requested todo item as a method parameter and returns an Optional object which contains the found todo item. If no todo item is found from the database, the findById() method returns an empty Optional.
The source code of the TodoItemRepository interface looks as follows:
import java.util.Optional;
interface TodoItemRepository {
Optional<TodoItem> findById(Long id);
}
The TodoItem class contains the information of a single todo item. Its source code looks as follows:
public class TodoItem {
private Long id;
private String title;
//Getters an setters are omitted
}
Let's assume that we have to write unit tests for the findById() method of the TodoItemFinderService class. This method simply invokes the findById() method of the TodoItemRepository interface and returns the Optional object that contains the found todo item.
The source code of the TodoItemFinderService class looks as follows:
import java.util.Optional;
public class TodoItemFinderService {
private final TodoItemRepository repository;
public TodoItemFinderService(TodoItemRepository repository) {
this.repository = repository;
}
public Optional<TodoItem> findById(Long id) {
return repository.findById(id);
}
}
Let's move on and find out how we can create a simple TodoItemRepository stub.
Creating a Simple Stub
When we want to create a stub that can replace the real TodoItemRepository dependency, we have to follow these steps:
First, we have to create a new class and ensure that this class implements the TodoItemRepository interface. After we have created our stub class, its source code looks as follows:
import java.util.Optional;
class TodoItemRepositoryStub implements TodoItemRepository {
@Override
public Optional<TodoItem> findById(Long id) {
//Implementation left blank on purpose
}
}
TodoItemRepository objects, its name should be: TodoItemRepositoryStub.
Also, this example assumes that the classes which use our stub class are in the same package as the stub class. However, if this is not the case, we must change the visibility of our stub class.
Second, we have to add a private and final TodoItem field to the TodoItemRepositoryStub class. This field contains the TodoItem object that's returned by our stub when the system under test invokes the findById() method by using the expected id.
After we have added this field to the stub class, its source code looks as follows:
import java.util.Optional;
class TodoItemRepositoryStub implements TodoItemRepository {
private final TodoItem returned;
@Override
public Optional<TodoItem> findById(Long id) {
//Implementation left blank on purpose
}
}
Third, we have to implement a constructor which allows us to configure the returned TodoItem object. When we implement this constructor, we must ensure that:
- The returned
TodoItemobject isn'tnull. - The
idof the returnedTodoItemobject isn'tnull.
After we have implemented our constructor, the source code of the TodoItemRepositoryStub class looks as follows:
import java.util.Optional;
class TodoItemRepositoryStub implements TodoItemRepository {
private final TodoItem returned;
TodoItemRepositoryStub(TodoItem returned) {
if (returned == null) {
throw new NullPointerException(
"The returned todo item cannot be null"
);
}
if (returned.getId() == null) {
throw new IllegalArgumentException(
"The id of the returned todo item cannot be null"
);
}
this.returned = returned;
}
@Override
public Optional<TodoItem> findById(Long id) {
//Implementation left blank on purpose
}
}
Fourth, we have to implement the findById() method by following these steps:
- If the method invocation is expected, return an
Optionalobject that contains the found todo item. A method invocation is expected if theidargument isn'tnulland it's equal to theidof the returnedTodoItemobject. - If the method invocation is unexpected, throw a new
UnexpectedInteractionException. A method invocation is unexpected if theidargument isnullor it's not equal to theidof the returnedTodoItemobject.
After we have implemented the findById() method, the source code of our stub class looks as follows:
import java.util.Optional;
class TodoItemRepositoryStub implements TodoItemRepository {
private final TodoItem returned;
TodoItemRepositoryStub(TodoItem returned) {
if (returned == null) {
throw new NullPointerException(
"The returned todo item cannot be null"
);
}
if (returned.getId() == null) {
throw new IllegalArgumentException(
"The id of the returned todo item cannot be null"
);
}
this.returned = returned;
}
@Override
public Optional<TodoItem> findById(Long id) {
if (invocationIsExpected(id)) {
return Optional.of(returned);
}
throw new UnexpectedInteractionException(
"Unexpected method invocation. Expected that id is: %d but was: %d",
returned.getId(),
id
);
}
private boolean invocationIsExpected(Long id) {
return (id != null) && id.equals(returned.getId());
}
}
- The test which invokes our stub will fail.
- We know immediately why our test failed.
We have now written a simple stub. Next, we will write a few test methods which use our new stub.
Using Our New Stub
We can use our new stub by following these steps:
First, we have to create a new stub object and replace the TodoItemRepository dependency of the system under test with the created stub. Because stubs aren't stateless, we have to create a new stub before a test method is invoked. In other words, we have to add a new setup method to our test class and annotate this method with the @BeforeEach annotation. After we have added a new setup method to our test class, we have to implement it by following these steps:
- Create a new
TodoItemobject. - Create a new
TodoItemRepositoryStubobject and configure theTodoItemobject that's returned when thefindById()method of theTodoItemRepositoryinterface is invoked. - Create a new
TodoItemFinderServiceobject and ensure that the created object uses our stub.
After we have written our setup method, the source code of our test class looks as follows:
import org.junit.jupiter.api.BeforeEach;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
class TodoItemFinderServiceTest {
private static final Long ID = 1L;
private static final String TITLE = "title";
private TodoItemFinderService service;
@BeforeEach
void configureSystemUnderTest() {
TodoItem found = createFoundTodoItem();
TodoItemRepositoryStub repository = new TodoItemRepositoryStub(found);
service = new TodoItemFinderService(repository);
}
private TodoItem createFoundTodoItem() {
TodoItem found = new TodoItem();
found.setId(ID);
found.setTitle(TITLE);
return found;
}
}
Second, we can now write tests methods which use our stub by following these steps:
- Ensure that the system under test returns a non-empty
Optionalobject when it's invoked by using the argument1L. - Ensure that the system under test returns a
TodoItemobject that has the expectedidandtitlewhen it's invoked by using the argument1L.
After we have written these test methods, the source code of our test class looks as follows:
import org.assertj.core.api.SoftAssertions;
import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(SoftAssertionsExtension.class)
class TodoItemFinderServiceTest {
private static final Long ID = 1L;
private static final String TITLE = "title";
private TodoItemFinderService service;
@BeforeEach
void configureSystemUnderTest() {
TodoItem found = createFoundTodoItem();
TodoItemRepositoryStub repository = new TodoItemRepositoryStub(found);
service = new TodoItemFinderService(repository);
}
private TodoItem createFoundTodoItem() {
TodoItem found = new TodoItem();
found.setId(ID);
found.setTitle(TITLE);
return found;
}
@Test
@DisplayName("Should return the found todo item")
void shouldReturnFoundTodoItem() {
Optional<TodoItem> result = service.findById(ID);
assertThat(result).isPresent();
}
@Test
@DisplayName("Should return the expected information of the found item")
void shouldReturnExpectedInformationOfFoundTodoItem(SoftAssertions assertions) {
TodoItem found = service.findById(ID).get();
assertions.assertThat(found.getId())
.as("id")
.isEqualByComparingTo(ID);
assertions.assertThat(found.getTitle())
.as("title")
.isEqualTo(TITLE);
}
}
Let's move on and identify the situations when we should use stubs.
When Should We Use Stubs?
A system under test can have dependencies that:
- Query information which is used by the system under test. These dependencies can read information from the database, fetch it from an external API, and so on.
- Trigger an action which has a side effect. These dependencies can save information to the database, send an HTTP request to an external API, trigger an event, and so on.
- Provide utility functions to the system under test. These functions are usually stateless and don't use any external services such as databases or APIs. For example, these functions can transform objects into other objects, enforce business or other validation rules, parse information from an object given as an argument, and so on.
Next, we will go through these dependencies one by one and identify the dependencies which should be replaced with stubs.
First, if a dependency queries information that's used by the system under test, we should replace it with a stub because there is no need to verify the interactions which happen between the system under test and this dependency.
If we want to verify that the system under test invokes our stub, we can either write assertions for the object returned by the system under test, write assertions for the exception thrown by the system under test, or ensure that the system under test uses the information returned by our stub when it interacts with other test doubles.
Second, if a dependency triggers an action which has a side effect, we must verify that the action is triggered by using the expected information. Because a stub provides no way to verify the interactions that happen between the system under test and the stub, we cannot replace this dependency with a stub.
Third, if a dependency provides utility functions to the system under test, we should use the real dependency because stubbing these functions will reduce the code coverage of our tests and make our test code more complex than it could be.
If we want to verify that the system test invokes these utility functions, we can either write assertions for the object returned by the system under test, write assertions for the exception thrown by the system under test, or ensure that the system under test uses the information returned by these functions when it interacts with other test doubles.
At this point, we should understand how a stub works and know when we should replace a dependency of the system under test with a stub. Let's summarize what we learned from this blog post.
Summary
This blog post has taught us four things:
- A stub must provide the same API as the replaced dependency.
- A stub returns the same response every time when an expected interaction happens between the system under test and a stub.
- When an unexpected invocation happens between the system under test and a stub, a stub must throw an exception and provide an error message which explains why the exception was thrown.
- We should replace a dependency with a stub if the dependency queries information that's used by the system under test.
P.S. You can get the example application of this blog post from Github.