Overriding a Builder With a Test Data Builder
After we have finished this lesson, we
- Know why we should override a builder with a test data builder.
- Understand when we should override a builder with our own test data builder.
- Can create a test data builder that overrides a builder.
This lesson assumes that:
Get the source code from Github
The Perils of the Builder Pattern
Even though the builder pattern is a great tool for creating stateful objects, it can cause us a lot of problems when we are writing tests which create new objects by using the provided builder.
The root cause of these problems is that the builder pattern helps us to make our production code safe and easy to read and write. When we implement the builder pattern, we want to fulfill these three goals:
- We want to solve the problems caused by the telescoping constructor (anti)pattern.
- We want to provide an API which allows us to create an object that’s in its initial state.
- We want to ensure that we cannot create an object that’s in an invalid state.
Additional Reading:
These are excellent goals. However, when we fulfill these goals, our choices have a huge effect on our test code. For example, let’s assume that the Task class provides a builder which ensures that we can create only open tasks. The source code of the Task class looks as follows:
1public class Task {
2
3 private Long id;
4 private Assignee assignee;
5 private Closer closer;
6 private Creator creator;
7 private String description;
8 private Modifier modifier;
9 private String title;
10 private TaskStatus status;
11 private TaskResolution resolution;
12
13 private Task(Builder builder) {
14 this.id = builder.id;
15 this.assignee = builder.assignee;
16 this.closer = null;
17 this.creator = builder.creator;
18 this.description = builder.description;
19 this.modifier = builder.modifier;
20 this.title = builder.title;
21 this.status = TaskStatus.OPEN;
22 this.resolution = null;
23 }
24
25 public static Builder getBuilder() {
26 return new Builder();
27 }
28
29 //Getters are omitted
30
31 public static class Builder {
32
33 private Long id;
34 private Assignee assignee;
35 private Creator creator;
36 private String description;
37 private Modifier modifier;
38 private String title;
39
40 private Builder() {}
41
42 public Builder withId(Long id) {
43 this.id = id;
44 return this;
45 }
46
47 public Builder withAssignee(Long assigneeId) {
48 if (assigneeId != null) {
49 this.assignee = new Assignee(assigneeId);
50 }
51 return this;
52 }
53
54 public Builder withCreator(Long creatorId) {
55 if (creatorId != null) {
56 this.creator = new Creator(creatorId);
57 this.modifier = new Modifier(creatorId);
58 }
59 return this;
60 }
61
62 public Builder withDescription(String description) {
63 this.description = description;
64 return this;
65 }
66
67 public Builder withTitle(String title) {
68 this.title = title;
69 return this;
70 }
71
72 public Task build () {
73 return new Task(this);
74 }
75 }
76}
When we take a look at this class we notice that the builder is useless to us because:
- If we want to create a task that’s in progress, we have to figure out a way to change the
statusof createdTaskobject. - If we want to create a closed task, we have to figure out a way to set the
closerof the created task, and change thestatusandresolutionof the task.
In other words, the goals of the builder pattern are incompatible with the requirements of our test code. The builder pattern protects us from our own mistakes, and this is a good thing when we are writing production code. However, when we are writing test code, we want to use a clean and flexible API that helps us to save time when we create our test data.
We can try to solve this problem by using one of these three options:
First, we can add new methods to our test classes and set the required field values by using these methods. If we use this option, we put the object creation logic (aka business logic) to our test classes. As we remember, this is a problem because it makes our tests hard to read, write, and maintain.
Second, we can add new methods to the Task class and set the required field values by using these methods. If we use this option, we end up writing production code that fulfills the requirements of our test code. The problem is that the requirements of our test code are often incompatible with the requirements of our production code.
As we remember, our production code must allow us to create an object that’s in its initial state and ensure that we cannot create an object that’s in an invalid state. That’s why our implementation of the builder pattern allows us to create only open tasks.
On the other hand, if we want to write comprehensive tests for our application, we have to be able to create Task objects which are in different states.
Third, if the Task class already has public methods which change the state of a task, we can use these methods. However, we must remember that the goal of these methods is to ensure that the user of our application cannot perform an illegal state transition. For example, it’s possible that we can close only tasks which are in progress.
Generally speaking, the problem is that the rules concerning the state transitions of a stateful object are most likely incompatible with the requirements of our test code. For example, it’s possible (and even likely) that we cannot trigger a state transition from the initial state of an object to its final state. Instead, we have to trigger multiple state transitions. This means that if we use this option, our object creation code will be more complex than it could be.
Next, I will introduce a clean solution that doesn’t force us to add test code to our production codebase. To be more specific, we will find out how we can write a test data builder that solves our problem.
Creating a Test Data Builder That Overrides the Builder Pattern
Our goal is to create a test data builder that has the same API as the test data builder which we created in the previous lesson . That’s why we won’t create our new test data builder from the scratch. Instead, we will make the following changes to the test data builder which we created in the previous lesson:
First, we have to ensure that our test data builder is compatible with the API of our builder. We can do this by following these steps:
- Delete the
assignee,closer,creator, andmodifierfields. - Add the
assigneeId,closerId,creatorId, andmodifierIdfields to our test data builder class. - Modify the methods which set the field values of the
assignee,closer,creator, andmodifierfields. The new implementations of these methods set the values of theassigneeId,closerId,creatorId, andmodifierIdfields. - Ensure that the
build()method creates a newTaskobject by using the builder that’s provided by theTaskclass.
After we have made these changes, the source code of our test data builder class looks as follows:
1public final class TaskBuilder {
2
3 private Long assigneeId;
4 private Long closerId;
5 private Long creatorId;
6 private String description;
7 private Long id;
8 private Long modifierId;
9 private TaskResolution resolution;
10 private TaskStatus status;
11 private String title;
12
13 public TaskBuilder() {
14
15 }
16
17 public TaskBuilder withAssignee(Long assigneeId) {
18 this.assigneeId = assigneeId;
19 return this;
20 }
21
22 public TaskBuilder withCreator(Long creatorId) {
23 this.creatorId = creatorId;
24 return this;
25 }
26
27 public TaskBuilder withDescription(String description) {
28 this.description = description;
29 return this;
30 }
31
32 public TaskBuilder withId(Long id) {
33 this.id = id;
34 return this;
35 }
36
37 public TaskBuilder withModifier(Long modifierId) {
38 this.modifierId = modifierId;
39 return this;
40 }
41
42 public TaskBuilder withResolutionDone(Long closerId) {
43 this.closerId = closerId;
44 this.resolution = TaskResolution.DONE;
45 this.status = TaskStatus.CLOSED;
46 return this;
47 }
48
49 public TaskBuilder withResolutionDuplicate(Long closerId) {
50 this.closerId = closerId;
51 this.resolution = TaskResolution.DUPLICATE;
52 this.status = TaskStatus.CLOSED;
53 return this;
54 }
55
56 public TaskBuilder withResolutionWontDo(Long closerId) {
57 this.closerId = closerId;
58 this.resolution = TaskResolution.WONT_DO;
59 this.status = TaskStatus.CLOSED;
60 return this;
61 }
62
63 public TaskBuilder withStatusInProgress() {
64 this.closerId = null;
65 this.resolution = null;
66 this.status = TaskStatus.IN_PROGRESS;
67 return this;
68 }
69
70 public TaskBuilder withStatusOpen() {
71 this.closerId = null;
72 this.resolution = null;
73 this.status = TaskStatus.OPEN;
74 return this;
75 }
76
77 public TaskBuilder withTitle(String title) {
78 this.title = title;
79 return this;
80 }
81
82 public Task build() {
83 return Task.getBuilder()
84 .withAssignee(assigneeId)
85 .withCreator(creatorId)
86 .withDescription(description)
87 .withId(id)
88 .withTitle(title)
89 .build();
90 }
91}
Second, we have to add a private setFieldValue() method to the TaskBuilder class. This method sets a field value of a Task object by using reflection, and it has three method parameters:
- The
Taskobject whose field value is set. - The name of the field whose value is set.
- The new field value.
After we have added this method to the TaskBuilder class, its source code looks as follows:
1import java.lang.reflect.Field;
2
3public final class TaskBuilder {
4
5 private Long assigneeId;
6 private Long closerId;
7 private Long creatorId;
8 private String description;
9 private Long id;
10 private Long modifierId;
11 private TaskResolution resolution;
12 private TaskStatus status;
13 private String title;
14
15 public TaskBuilder() {
16
17 }
18
19 public TaskBuilder withAssignee(Long assigneeId) {
20 this.assigneeId = assigneeId;
21 return this;
22 }
23
24 public TaskBuilder withCreator(Long creatorId) {
25 this.creatorId = creatorId;
26 return this;
27 }
28
29 public TaskBuilder withDescription(String description) {
30 this.description = description;
31 return this;
32 }
33
34 public TaskBuilder withId(Long id) {
35 this.id = id;
36 return this;
37 }
38
39 public TaskBuilder withModifier(Long modifierId) {
40 this.modifierId = modifierId;
41 return this;
42 }
43
44 public TaskBuilder withResolutionDone(Long closerId) {
45 this.closerId = closerId;
46 this.resolution = TaskResolution.DONE;
47 this.status = TaskStatus.CLOSED;
48 return this;
49 }
50
51 public TaskBuilder withResolutionDuplicate(Long closerId) {
52 this.closerId = closerId;
53 this.resolution = TaskResolution.DUPLICATE;
54 this.status = TaskStatus.CLOSED;
55 return this;
56 }
57
58 public TaskBuilder withResolutionWontDo(Long closerId) {
59 this.closerId = closerId;
60 this.resolution = TaskResolution.WONT_DO;
61 this.status = TaskStatus.CLOSED;
62 return this;
63 }
64
65 public TaskBuilder withStatusInProgress() {
66 this.closerId = null;
67 this.resolution = null;
68 this.status = TaskStatus.IN_PROGRESS;
69 return this;
70 }
71
72 public TaskBuilder withStatusOpen() {
73 this.closerId = null;
74 this.resolution = null;
75 this.status = TaskStatus.OPEN;
76 return this;
77 }
78
79 public TaskBuilder withTitle(String title) {
80 this.title = title;
81 return this;
82 }
83
84 public Task build() {
85 return Task.getBuilder()
86 .withAssignee(assigneeId)
87 .withCreator(creatorId)
88 .withDescription(description)
89 .withId(id)
90 .withTitle(title)
91 .build();
92 }
93
94 private void setFieldValue(Task target,
95 String fieldName,
96 Object fieldValue) {
97 try {
98 Field targetField = target.getClass()
99 .getDeclaredField(fieldName);
100
101 targetField.setAccessible(true);
102 targetField.set(target, fieldValue);
103 }
104 catch (Exception ex) {
105 throw new RuntimeException(
106 String.format(
107 "Cannot set the value of the field: %s",
108 fieldName
109 ),
110 ex
111 );
112 }
113 }
114}
Third, we have to make the following changes to the build() method of our test data builder class:
- Store the created
Taskobject in a local variable. - Set the field values of the
closer,modifier,status, andresolutionfields by using reflection. - Return the created object.
After we have made the required changes to the build() method, the source code of our test data builder looks as follows:
1import java.lang.reflect.Field;
2
3public final class TaskBuilder {
4
5 private Long assigneeId;
6 private Long closerId;
7 private Long creatorId;
8 private String description;
9 private Long id;
10 private Long modifierId;
11 private TaskResolution resolution;
12 private TaskStatus status;
13 private String title;
14
15 public TaskBuilder() {
16
17 }
18
19 public TaskBuilder withAssignee(Long assigneeId) {
20 this.assigneeId = assigneeId;
21 return this;
22 }
23
24 public TaskBuilder withCreator(Long creatorId) {
25 this.creatorId = creatorId;
26 return this;
27 }
28
29 public TaskBuilder withDescription(String description) {
30 this.description = description;
31 return this;
32 }
33
34 public TaskBuilder withId(Long id) {
35 this.id = id;
36 return this;
37 }
38
39 public TaskBuilder withModifier(Long modifierId) {
40 this.modifierId = modifierId;
41 return this;
42 }
43
44 public TaskBuilder withResolutionDone(Long closerId) {
45 this.closerId = closerId;
46 this.resolution = TaskResolution.DONE;
47 this.status = TaskStatus.CLOSED;
48 return this;
49 }
50
51 public TaskBuilder withResolutionDuplicate(Long closerId) {
52 this.closerId = closerId;
53 this.resolution = TaskResolution.DUPLICATE;
54 this.status = TaskStatus.CLOSED;
55 return this;
56 }
57
58 public TaskBuilder withResolutionWontDo(Long closerId) {
59 this.closerId = closerId;
60 this.resolution = TaskResolution.WONT_DO;
61 this.status = TaskStatus.CLOSED;
62 return this;
63 }
64
65 public TaskBuilder withStatusInProgress() {
66 this.closerId = null;
67 this.resolution = null;
68 this.status = TaskStatus.IN_PROGRESS;
69 return this;
70 }
71
72 public TaskBuilder withStatusOpen() {
73 this.closerId = null;
74 this.resolution = null;
75 this.status = TaskStatus.OPEN;
76 return this;
77 }
78
79 public TaskBuilder withTitle(String title) {
80 this.title = title;
81 return this;
82 }
83
84 public Task build() {
85 Task task = Task.getBuilder()
86 .withAssignee(assigneeId)
87 .withCreator(creatorId)
88 .withDescription(description)
89 .withId(id)
90 .withTitle(title)
91 .build();
92
93 if (closerId != null) {
94 setFieldValue(task, "closer", new Closer(closerId));
95 }
96
97 if (modifierId != null) {
98 setFieldValue(task, "modifier", new Modifier(modifierId));
99 }
100
101 setFieldValue(task, "status", status);
102 setFieldValue(task, "resolution", resolution);
103
104 return task;
105 }
106
107 private void setFieldValue(Task target,
108 String fieldName,
109 Object fieldValue) {
110 try {
111 Field targetField = target.getClass()
112 .getDeclaredField(fieldName);
113
114 targetField.setAccessible(true);
115 targetField.set(target, fieldValue);
116 }
117 catch (Exception ex) {
118 throw new RuntimeException(
119 String.format(
120 "Cannot set the value of the field: %s",
121 fieldName
122 ),
123 ex
124 );
125 }
126 }
127}
We have now created a test data builder which overrides the builder that’s provided by the Task class. Because we didn’t make any changes to the API of our test data builder, we don’t have to make any changes to our existing unit tests. For example, if we want to create a task that was closed as a duplicate, we can still use the following code:
1import org.junit.Test;
2
3public class TaskTest {
4
5 private static final Long ID = 1L;
6 private static final Long ASSIGNEE_ID = 99L;
7 private static final Long CLOSER_ID = 55L;
8 private static final Long CREATOR_ID = 44L;
9 private static final Long MODIFIER_ID = 66L;
10 private static final String TITLE = "Test data builder example";
11 private static final String DESCRIPTION = "Write an example project";
12
13 @Test
14 public void createTaskThatWasClosedAsDuplicate() {
15 Task duplicate = new TaskBuilder()
16 .withId(ID)
17 .withAssignee(ASSIGNEE_ID)
18 .withCreator(CREATOR_ID)
19 .withModifier(MODIFIER_ID)
20 .withTitle(TITLE)
21 .withDescription(DESCRIPTION)
22 .withResolutionDuplicate(CLOSER_ID)
23 .build();
24 }
25}
Let’s move on and take a look at the pros and cons of our new test data builder.
The Pros and Cons of Our New Test Data Builder
The pros of our test data builder are:
- We can hide the object creation logic (aka business logic) from the people who read and write our tests. This means that our actual test code is easy to read and write.
- We can put the object creation logic (aka business logic) to one place. This ensures that our tests are easy to write and maintain.
- We don’t have to make any changes to our production code. In other words, if we use a test data builder, we can keep our production code clean and as easy to read as possible.
The cons of our test data builder are:
- Our test data builder uses reflection. This can cause problems if we rename the fields of the
Taskclass and we don’t use an IDE. However, this isn’t a big problem because most of the time we are using an IDE. - Using reflection is more complex than adding new methods to the
Taskclass. However, this isn’t a huge problem because we have to write this code only once. - We have to specify the field values which are required by our builder. This can be cumbersome if the created object has many mandatory fields. We will talk more about this problem in the next lesson of this topic.
Next, we will find out when we should override a builder with a test data builder.
When Should We Use This Technique?
As we remember, the previous lesson of this topic
taught us that we should create stateful objects with a test data builder if these objects are used by multiple test classes, and we cannot use a factory method or the new keyword. However, we must remember that our test data builder created our test data by using the JavaBeans style construction.
During this lesson we created a test data builder that overrides the builder provided by the Task class. Also, this lesson demonstrated that this technique helps us to write tests which are easy to read, write, and maintain. Even though this technique has minor drawbacks, it should be clear that its pros outweigh the cons.
That’s why I think that we should override a builder with a test data builder if we are creating stateful objects which are used by multiple test classes, and we cannot use a factory method.
We know why we should override a builder with a test data builder, we can write a test data builder which overrides a builder, and we understand when we should use this technique. Let’s summarize what we learned from this lesson.
Summary
This lesson has taught us three things:
- The goals of the builder pattern are incompatible with the requirements of our test code.
- We should override a builder with a test data builder if we are creating stateful objects which are used by multiple test classes, and we cannot use a factory method.
- When we override a builder with a test data builder, we shouldn’t make any changes to our production code. Instead, we should use reflection.