Introduction to Spies
After we have finished this lesson, we
- Know what a spy is.
- Understand how a spy works.
- Understand when we should use spies.
This lesson assumes that:
What Is a Spy?
A spy is an object that is used to replace an external dependency of the system under test. Every spy must fulfill these requirements:
- A spy must provide the same API as the replaced external dependency. This means that if the external dependency is a class, our spy must extend it and override the methods that interest us. On the other hand, if the replaced dependency implements an interface, our spy must implement the same interface.
- A spy provides no way to configure the response that is returned when its method is invoked.
- A spy provides no way to specify the expected values or objects that should be passed as method parameters when a method of a spy is invoked.
- A spy provides no way to verify the interactions that happen between the system under test and the spy in question. Instead it provides getter methods which are used to access the information that was recorded by the spy.
- A spy might record the method invocations of the target method and the method parameters of these method invocations, but it doesn’t have to record both of these things.
Now we will demonstrate these rules by creating a simple spy. Let’s start by taking a look at the system under test.
Introduction to System Under Test
The system under test has a single dependency called TaskAuditLog. This interface declares one method called logTask() that takes a Task object as a method parameter and doesn’t return anything.
The source code of the TaskAuditLog interface looks as follows:
1public interface TaskAuditLog {
2
3 void logTask(Task task);
4}
Let’s find out how we can create a simple TaskAuditLog spy.
Creating a Simple Spy
We can create a spy that can replace real TaskAuditLog objects by following these steps:
First, we have to create a public and final class that implements the TaskAuditLog interface. It is a good idea to mark this class as final because it prevents developers from extending it.
The source code of our spy class looks as follows:
1public final class TaskAuditLogSpy implements TaskAuditLog {
2
3}
When I name my spy classes, I like to append the string: ‘Spy’ to the type of the “real” object. Because our spy class is used to replace TaskAuditLog objects, its name should be: TaskAuditLogSpy.
Also, this example assumes that we need to use this spy class from multiple test classes that are not necessarily in the same package as the spy class. However, if this is not the case, we should limit the visibility of our spy class.
Second, we have to create a mechanism that records the invocation history of the logTask() method. We can do this by following these steps:
- Add
invocationCountfield into the spy class. This field describes how many times thelogTask()method was invoked by the system under test. - Add
auditedTasksfield into the spy class. This field contains theTaskobjects that were passed to thelogTask()method as method parameters. - Implement a constructor that initializes these fields.
After we have implemented this mechanism, the source code of the TaskAuditLogSpy class looks as follows:
1import java.util.ArrayList;
2import java.util.List;
3
4public final class TaskAuditLogSpy implements TaskAuditLog {
5
6 private int invocationCount;
7 private List<Task> auditedTasks;
8
9 public TaskAuditLogSpy() {
10 invocationCount = 0;
11 auditedTasks = new ArrayList<>();
12 }
13}
Third, we have to implement the logTask() method of the TaskAuditLog interface by following these steps:
- Increase the value of the
invocationCountfield by one. - Add the
Taskobject into theauditedTaskslist.
After we have implemented the logTask() method, the source code of the TaskAuditLogSpy class looks as follows:
1import java.util.ArrayList;
2import java.util.List;
3
4public final class TaskAuditLogSpy implements TaskAuditLog {
5
6 private int invocationCount;
7 private List<Task> auditedTasks;
8
9 public TaskAuditLogSpy() {
10 invocationCount = 0;
11 auditedTasks = new ArrayList<>();
12 }
13
14 @Override
15 public void logTask(Task task) {
16 invocationCount++;
17 auditedTasks.add(task);
18 }
19}
Fourth, we have to implement the getter methods which are used to access the information recorded by our spy. We have to implement two getter methods:
- The
getInvocationCount()method returns the invocation count of thelogTask()method. - The
getAuditedTasks()method returns a list ofTaskobjects that were passed to thelogTask()method as method parameters.
After we have implemented these getter methods, the source code of the TaskAuditLogSpy class looks as follows:
1import java.util.ArrayList;
2import java.util.List;
3
4public final class TaskAuditLogSpy implements TaskAuditLog {
5
6 private int invocationCount;
7 private List<Task> auditedTasks;
8
9 public TaskAuditLogSpy() {
10 invocationCount = 0;
11 auditedTasks = new ArrayList<>();
12 }
13
14 @Override
15 public void logTask(Task task) {
16 invocationCount++;
17 auditedTasks.add(task);
18 }
19
20 public int getInvocationCount() {
21 return invocationCount;
22 }
23
24 public List<Task> getAuditedTasks() {
25 return auditedTasks;
26 }
27}
We have now created a simple spy. Next we will write a test method that demonstrates how our spy works.
Using Our Spy
If we want to use a spy in our tests, we must understand that spies are not stateless. This means that we have to create a new spy before a test method is invoked. We can do this by adding a setup method into our test class and creating our spy in that method.
After we have added this setup method into our test class, its source code looks as follows:
1import org.junit.Before;
2
3public class TaskRepositorySpyTest {
4
5 private static final String TITLE = "example title";
6
7 private TaskAuditLogSpy auditLog;
8
9 @Before
10 public void createSpy() {
11 auditLog = new TaskAuditLogSpy();
12 }
13}
We can now write our test method by following these steps:
- Create a new
Taskobject. - Invoke the
logTask()method of theTaskAuditLoginterface. - Ensure that the
logTask()method was invoked only once. - Ensure that the created
Taskobject was passed as a method parameter.
After we have implemented our test method, the source code of our test class looks as follows:
1import org.junit.Before;
2import org.junit.Test;
3
4import java.util.List;
5
6import static org.assertj.core.api.Assertions.assertThat;
7
8public class TaskRepositorySpyTest {
9
10 private static final String TITLE = "example title";
11
12 private TaskAuditLogSpy auditLog;
13
14 @Before
15 public void createSpy() {
16 auditLog = new TaskAuditLogSpy();
17 }
18
19 @Test
20 public void logTask() {
21 Task task = new TaskBuilder()
22 .withTitle(TITLE)
23 .build();
24
25 auditLog.logTask(task);
26
27 int actualInvocationCount = auditLog.getInvocationCount();
28 assertThat(actualInvocationCount).isEqualByComparingTo(1);
29
30 List<Task> auditedTasks = auditLog.getAuditedTasks();
31 assertThat(auditedTasks).containsExactly(task);
32 }
33}
This example has demonstrated that a spy is a test double that:
- Monitors the method invocations of the target method.
- Can record the invocation count of the target method and save the objects passed to the target method as method parameters for later use.
- Provides getter methods that are used to access the recorded information.
- Forces us to use assertions because we cannot configure the expected method parameters and verify interactions that happen between the system under test and the spy in question.
Next we will learn to identify the situations when we should use spies.
When Should We Use Spies?
If we are doing greenfield development, the odds are that we won’t need spies at all. That being said, there are a few specific situations when spies are useful. These situations are:
First, we need to verify the interactions between the system under test and our test double, and we don’t know how many times the target method is invoked or what values should be passed as method parameters. Because we cannot specify our expectations and verify interactions by using a mock object, our only option is to use a spy.
Second, it’s hard to report a failed expectation back to the test runner. This might happen if the system under test is running in a different thread or process than our test case. For example, this situation happens if the system under test is running inside a container.
Third, we want to use assertions because we want to customize the error message or we feel that assertions reveal our intentions better than specifying our expectations by using a mock object.
Let’s summarize what we learned from this lesson.
Summary
This lesson has taught us six things:
- A spy is an object that replaces an external dependency of the system under test.
- A spy provides no way to configure the response that is returned when its method is invoked.
- A spy provides no way to verify the interactions between the system under test and a spy.
- A spy provides no way to specify the expected values or objects that should be passed as method parameters when a method of a spy is invoked.
- If we use a spy, we have to write assertions which verify that the recorded information is correct.
- A spy is a somewhat uncommon test double and if we are doing greenfield development, it is very likely that we don’t have to use it.