We have learned that “clean” unit tests might not be as clean as we think.
We have done our best to make our unit tests as clean as possible. Our tests are formatted properly, use domain-specific language, and avoid excessive mocking.
Nevertheless, our unit tests are not clean because:
- When we make changes to the tested code, most of our existing unit tests don’t compile or fail when we run them. Fixing these unit tests is slow and frustrating.
- When we add new methods to the tested class, we realize that writing new unit tests is a lot slower than it should be.
If this is the case, it is very likely that our unit tests suffer from these common problems:
- The method names of our test methods are way too long. If a test fails, the method name doesn’t necessarily describe what went wrong. Also, it is hard to get a brief overview of the situations that are covered by our tests. This means that we might test the same situation more than once.
- Our test methods contains duplicate code that configures mock objects and creates other objects that are used in our tests. This means that our tests are hard to read, write, and maintain.
- Because there is no clean way to share common configuration with only a few test methods, we must put all constants to the beginning of the test class. Some of you might claim that this is a minor issue, and you are right, but it still makes our test classes messier than they should be.
Let’s find out how we can solve all of these problems.
Nested Configuration to the Rescue
If we want to fix the problems found from our unit tests, we have to
- Describe the tested method and the state under test in a way that doesn’t require long method names.
- Find a way to move the common configuration from test methods to setup methods.
- Create a common context for test methods, and make setup methods and constants visible only to the test methods that belong to the created context.
There is a JUnit runner that can help us to achieve these goals. It is called the NestedRunner, and it allows us to run test methods placed in nested inner classes.
Before we can start solve our problems by using NestedRunner, we have to add the NestedRunner dependency to our Maven build and ensure that our test methods are invoked by the NestedRunner class.
First, we need to add the following dependency declaration to our pom.xml file:
1<dependency>
2 <groupId>com.nitorcreations</groupId>
3 <artifactId>junit-runners</artifactId>
4 <version>1.2</version>
5 <scope>test</scope>
6</dependency>
Second, we need to make the following changes to the RepositoryUserServiceTest class:
- Ensure that the test methods found from the RepositoryUserServiceTest class are invoked by the NestedRunner class.
- Remove the @Mock annotations from the passwordEncoder and repository fields.
- Create the required mock objects by invoking the static mock() method of the Mockito class and insert them to the passwordEncoder and repository fields.
The source code of the RepositoryUserServiceTest class looks as follows:
1import com.nitorcreations.junit.runners.NestedRunner
2import org.junit.Before;
3import org.junit.runner.RunWith;
4import org.springframework.security.crypto.password.PasswordEncoder;
5
6import static org.mockito.Mockito.mock;
7
8@RunWith(NestedRunner.class)
9public class RepositoryUserServiceTest {
10
11 private static final String REGISTRATION_EMAIL_ADDRESS = "john.smith@gmail.com";
12 private static final String REGISTRATION_FIRST_NAME = "John";
13 private static final String REGISTRATION_LAST_NAME = "Smith";
14 private static final SocialMediaService SOCIAL_SIGN_IN_PROVIDER = SocialMediaService.TWITTER;
15
16 private RepositoryUserService registrationService;
17
18 private PasswordEncoder passwordEncoder;
19
20 private UserRepository repository;
21
22 @Before
23 public void setUp() {
24 passwordEncoder = mock(PasswordEncoder.class);
25 repository = mock(UserRepository.class);
26
27 registrationService = new RepositoryUserService(passwordEncoder, repository);
28 }
29}
Additional Reading:
We have now configured NestedRunner and can start solving the problems found from our unit tests. Let’s start by replacing long method names with a nested class hierarchy.
Replacing Long Test Method Names With a Nested Class Hierarchy
Before we can replace the long test method names with a nested class hierarchy, we need to figure out what situations are covered by our unit tests. If we take a look at our test class, we notice that the unit tests found from the RepositoryUserServiceTest class ensure that:
- If there already is a user account that has the same email address, our code should
- throw an exception.
- not save a new user account.
- If there is no user account that has the same email address, our code should
- Save a new user account.
- Set the correct email address.
- Set the correct first and last names.
- Should create a registered user.
- Set the correct sign in provider.
- Not create encoded password for the user.
- Return the the created user account.
We can now eliminate the long test method names by replacing our test methods with a BDD style class hierarchy. The idea is that we:
- Create one inner class per tested method. This class can contain a setup method, tests methods, and other inner classes. In our case, the name of this inner class is RegisterNewUserAccount.
- Create the class hierarchy that describes the state under test. We can do this by adding inner classes to the RegisterNewUserAccount class (and to its inner classes). We can name these inner classes by using the following syntax: When[StateUnderTest]. We can add this class hierarchy to our test class by following these steps:
- Because the user is registering a user account by using social sign in, we have to add the WhenUserUsesSocialSignIn class to the RegisterNewUserAccount class.
- Because we have to cover two different situations, we have to add two inner classes (WhenUserAccountIsFoundWithEmailAddress and WhenEmailAddressIsUnique) to the WhenUserUsesSocialSignIn class.
- Add the actual test methods to the correct inner classes. Because the class hierarchy describes the tested method and the state under test, the name of each unit test must only describe the expected behavior. One way to do this is to name each test method by using the prefix: should.
After we have created the class hierarchy, the source code of our test class looks as follows:
1import com.nitorcreations.junit.runners.NestedRunner
2import org.junit.Before;
3import org.junit.Test;
4import org.junit.runner.RunWith;
5import org.mockito.ArgumentCaptor;
6import org.mockito.Mock;
7import org.mockito.invocation.InvocationOnMock;
8import org.mockito.runners.MockitoJUnitRunner;
9import org.mockito.stubbing.Answer;
10import org.springframework.security.crypto.password.PasswordEncoder;
11
12import static com.googlecode.catchexception.CatchException.catchException;
13import static com.googlecode.catchexception.CatchException.caughtException;
14import static org.assertj.core.api.Assertions.assertThat;
15import static org.mockito.Matchers.isA;
16import static org.mockito.Mockito.mock;
17import static org.mockito.Mockito.never;
18import static org.mockito.Mockito.times;
19import static org.mockito.Mockito.verify;
20import static org.mockito.Mockito.verifyZeroInteractions;
21import static org.mockito.Mockito.when;
22
23@RunWith(NestedRunner.class)
24public class RepositoryUserServiceTest {
25
26 private static final String REGISTRATION_EMAIL_ADDRESS = "john.smith@gmail.com";
27 private static final String REGISTRATION_FIRST_NAME = "John";
28 private static final String REGISTRATION_LAST_NAME = "Smith";
29 private static final SocialMediaService SOCIAL_SIGN_IN_PROVIDER = SocialMediaService.TWITTER;
30
31 private RepositoryUserService registrationService;
32
33 private PasswordEncoder passwordEncoder;
34
35 private UserRepository repository;
36
37 @Before
38 public void setUp() {
39 passwordEncoder = mock(PasswordEncoder.class);
40 repository = mock(UserRepository.class);
41
42 registrationService = new RepositoryUserService(passwordEncoder, repository);
43 }
44
45 public class RegisterNewUserAccount {
46
47 public class WhenUserUsesSocialSignIn {
48
49 public class WhenUserAccountIsFoundWithEmailAddress {
50
51 @Test
52 public void shouldThrowException() throws DuplicateEmailException {
53 RegistrationForm registration = new RegistrationFormBuilder()
54 .email(REGISTRATION_EMAIL_ADDRESS)
55 .firstName(REGISTRATION_FIRST_NAME)
56 .lastName(REGISTRATION_LAST_NAME)
57 .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER)
58 .build();
59
60 when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(new User());
61
62 catchException(registrationService).registerNewUserAccount(registration);
63
64 assertThat(caughtException()).isExactlyInstanceOf(DuplicateEmailException.class);
65 }
66
67 @Test
68 public void shouldNotSaveNewUserAccount() throws DuplicateEmailException {
69 RegistrationForm registration = new RegistrationFormBuilder()
70 .email(REGISTRATION_EMAIL_ADDRESS)
71 .firstName(REGISTRATION_FIRST_NAME)
72 .lastName(REGISTRATION_LAST_NAME)
73 .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER)
74 .build();
75
76 when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(new User());
77
78 catchException(registrationService).registerNewUserAccount(registration);
79
80 verify(repository, never()).save(isA(User.class));
81 }
82 }
83
84 public class WhenEmailAddressIsUnique {
85
86 @Test
87 public void shouldSaveNewUserAccount() throws DuplicateEmailException {
88 RegistrationForm registration = new RegistrationFormBuilder()
89 .email(REGISTRATION_EMAIL_ADDRESS)
90 .firstName(REGISTRATION_FIRST_NAME)
91 .lastName(REGISTRATION_LAST_NAME)
92 .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER)
93 .build();
94
95 when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(null);
96
97 registrationService.registerNewUserAccount(registration);
98
99 ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class);
100 verify(repository, times(1)).save(isA(User.class));
101 }
102
103 @Test
104 public void shouldSetCorrectEmailAddress() throws DuplicateEmailException {
105 RegistrationForm registration = new RegistrationFormBuilder()
106 .email(REGISTRATION_EMAIL_ADDRESS)
107 .firstName(REGISTRATION_FIRST_NAME)
108 .lastName(REGISTRATION_LAST_NAME)
109 .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER)
110 .build();
111
112 when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(null);
113
114 registrationService.registerNewUserAccount(registration);
115
116 ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class);
117 verify(repository, times(1)).save(userAccountArgument.capture());
118
119 User createdUserAccount = userAccountArgument.getValue();
120
121 assertThatUser(createdUserAccount)
122 .hasEmail(REGISTRATION_EMAIL_ADDRESS);
123 }
124
125 @Test
126 public void shouldSetCorrectFirstAndLastName() throws DuplicateEmailException {
127 RegistrationForm registration = new RegistrationFormBuilder()
128 .email(REGISTRATION_EMAIL_ADDRESS)
129 .firstName(REGISTRATION_FIRST_NAME)
130 .lastName(REGISTRATION_LAST_NAME)
131 .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER)
132 .build();
133
134 when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(null);
135
136 registrationService.registerNewUserAccount(registration);
137
138 ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class);
139 verify(repository, times(1)).save(userAccountArgument.capture());
140
141 User createdUserAccount = userAccountArgument.getValue();
142
143 assertThatUser(createdUserAccount)
144 .hasFirstName(REGISTRATION_FIRST_NAME)
145 .hasLastName(REGISTRATION_LAST_NAME)
146 }
147
148 @Test
149 public void shouldCreateRegisteredUser() throws DuplicateEmailException {
150 RegistrationForm registration = new RegistrationFormBuilder()
151 .email(REGISTRATION_EMAIL_ADDRESS)
152 .firstName(REGISTRATION_FIRST_NAME)
153 .lastName(REGISTRATION_LAST_NAME)
154 .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER)
155 .build();
156
157 when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(null);
158
159 registrationService.registerNewUserAccount(registration);
160
161 ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class);
162 verify(repository, times(1)).save(userAccountArgument.capture());
163
164 User createdUserAccount = userAccountArgument.getValue();
165
166 assertThatUser(createdUserAccount)
167 .isRegisteredUser()
168 }
169
170 @Test
171 public void shouldSetSignInProvider() throws DuplicateEmailException {
172 RegistrationForm registration = new RegistrationFormBuilder()
173 .email(REGISTRATION_EMAIL_ADDRESS)
174 .firstName(REGISTRATION_FIRST_NAME)
175 .lastName(REGISTRATION_LAST_NAME)
176 .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER)
177 .build();
178
179 when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(null);
180
181 registrationService.registerNewUserAccount(registration);
182
183 ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class);
184 verify(repository, times(1)).save(userAccountArgument.capture());
185
186 User createdUserAccount = userAccountArgument.getValue();
187
188 assertThatUser(createdUserAccount)
189 .isRegisteredByUsingSignInProvider(SOCIAL_SIGN_IN_PROVIDER);
190 }
191
192 @Test
193 public void shouldNotCreateEncodedPasswordForUser() throws DuplicateEmailException {
194 RegistrationForm registration = new RegistrationFormBuilder()
195 .email(REGISTRATION_EMAIL_ADDRESS)
196 .firstName(REGISTRATION_FIRST_NAME)
197 .lastName(REGISTRATION_LAST_NAME)
198 .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER)
199 .build();
200
201 when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(null);
202
203 registrationService.registerNewUserAccount(registration);
204
205 verifyZeroInteractions(passwordEncoder);
206 }
207
208 @Test
209 public void shouldReturnCreatedUserAccount() throws DuplicateEmailException {
210 RegistrationForm registration = new RegistrationFormBuilder()
211 .email(REGISTRATION_EMAIL_ADDRESS)
212 .firstName(REGISTRATION_FIRST_NAME)
213 .lastName(REGISTRATION_LAST_NAME)
214 .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER)
215 .build();
216
217 when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(null);
218 when(repository.save(isA(User.class))).thenAnswer(new Answer<User>() {
219 @Override
220 public User answer(InvocationOnMock invocation) throws Throwable {
221 Object[] arguments = invocation.getArguments();
222 return (User) arguments[0];
223 }
224 });
225
226 User returnedUserAccount = registrationService.registerNewUserAccount(registration);
227
228 ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class);
229 verify(repository, times(1)).save(userAccountArgument.capture());
230
231 User createdUserAccount = userAccountArgument.getValue();
232
233 assertThat(returnedUserAccount)
234 .isEqualTo(createdUserAccount);
235 }
236 }
237
238 }
239 }
240}
We have now replaced the long test method names with a nested class hierarchy, but the downside of this solution is that we added a lot of duplicate code. Let’s get rid of that code.
Removing Duplicate Code
We can remove all duplicate code from our test class by moving it to the setup methods that are placed to the “correct” inner classes. Before we can identify the “correct” inner classes, we have to understand the execution order of setup and test methods. The best way to understand this is to use a simple example:
1@RunWith(NestedRunner.class)
2public class TestClass {
3
4 /**
5 * This setup method is invoked before the test and setup methods
6 * found from the inner classes of this class.
7
8 * This is a good place for configuration that is shared by all
9 * test methods found from this test class.
10 */
11 @Before
12 public void setUpTestClass() {}
13
14 public class MethodA {
15
16 /**
17 * This setup method is invoked before the test methods found from
18 * this class and before the test and setup methods found from the
19 * inner classes of this class.
20 *
21 * This is a good place for configuration that is shared by all test
22 * methods which ensure that the methodA() is working correctly.
23 */
24 @Before
25 public void setUpMethodA() {}
26
27 @Test
28 public void shouldFooBar() {}
29
30 public class WhenFoo {
31
32 /**
33 * This setup method is invoked before the test methods found from
34 * this class and before the test and setup methods found from the
35 * inner classes of this class.
36 *
37 * This is a good place for configuration which ensures that the methodA()
38 * working correctly when foo is 'true'.
39 */
40 @Before
41 public void setUpWhenFoo() {}
42
43 @Test
44 public void shouldBar() {}
45 }
46
47 public class WhenBar {
48
49 @Test
50 public shouldFoo() {}
51 }
52 }
53}
In other words, before a test method is invoked, NestedRunner invokes the setup methods by navigating to the test method from the root class of the class hierarchy and invoking all setup methods. Let’s go through the test methods found from our example:
- Before the test method shouldFooBar() is invoked, NestedRunner invokes the setUpTestClass() and setUpMethodA() methods.
- Before the test method shouldBar() is invoked, NestedRunner invokes the setUpTestClass(), setUpMethodA(), and setUpWhenFoo() methods.
- Before the test method shouldFoo() is invoked, NestedRunner invokes the setUpTestClass() and setUpMethodA() methods.
We can now make the necessary modifications to the RepositoryUserServiceTest class by following these steps:
- Add a setUp() method to the WhenUserUsesSocialSignIn class and implement it by creating a new RegistrationForm object. This is the right place to do this because all unit tests give a RegistrationForm object as an input to the tested method.
- Add a setUp() method to the WhenUserAccountIsFoundWithEmailAddress class and configure our repository mock to return a User object when its findByEmail() method is invoked by using the email address that was entered to the registration form. This is the right place for this code because every unit test that is found from the WhenUserAccountIsFoundWithEmailAddress class assumes that the email address given during the registration is not unique.
- Add a setUp() method to the WhenEmailAddressIsUnique class and configure our repository mock to 1) return null when its findByEmail() method is invoked by using the email address that was entered to the registration form and 2) return the User object given as a method parameter when its save() method is invoked. This is the right place for this code because every every unit test that is found from the WhenEmailAddressIsUnique class assumes that the email address given during the registration is unique and that the information of the created user account is returned.
After we have done these changes, the source code of our test class looks as follows:
1import com.nitorcreations.junit.runners.NestedRunner
2import org.junit.Before;
3import org.junit.Test;
4import org.junit.runner.RunWith;
5import org.mockito.ArgumentCaptor;
6import org.mockito.Mock;
7import org.mockito.invocation.InvocationOnMock;
8import org.mockito.runners.MockitoJUnitRunner;
9import org.mockito.stubbing.Answer;
10import org.springframework.security.crypto.password.PasswordEncoder;
11
12import static com.googlecode.catchexception.CatchException.catchException;
13import static com.googlecode.catchexception.CatchException.caughtException;
14import static org.assertj.core.api.Assertions.assertThat;
15import static org.mockito.BDDMockito.given;
16import static org.mockito.Matchers.isA;
17import static org.mockito.Mockito.mock;
18import static org.mockito.Mockito.never;
19import static org.mockito.Mockito.times;
20import static org.mockito.Mockito.verify;
21import static org.mockito.Mockito.verifyZeroInteractions;
22
23@RunWith(NestedRunner.class)
24public class RepositoryUserServiceTest {
25
26 private static final String REGISTRATION_EMAIL_ADDRESS = "john.smith@gmail.com";
27 private static final String REGISTRATION_FIRST_NAME = "John";
28 private static final String REGISTRATION_LAST_NAME = "Smith";
29 private static final SocialMediaService SOCIAL_SIGN_IN_PROVIDER = SocialMediaService.TWITTER;
30
31 private RepositoryUserService registrationService;
32
33 private PasswordEncoder passwordEncoder;
34
35 private UserRepository repository;
36
37 @Before
38 public void setUp() {
39 passwordEncoder = mock(PasswordEncoder.class);
40 repository = mock(UserRepository.class);
41
42 registrationService = new RepositoryUserService(passwordEncoder, repository);
43 }
44
45 public class RegisterNewUserAccount {
46
47 public class WhenUserUsesSocialSignIn {
48
49 private RegistrationForm registration;
50
51 @Before
52 public void setUp() {
53 RegistrationForm registration = new RegistrationFormBuilder()
54 .email(REGISTRATION_EMAIL_ADDRESS)
55 .firstName(REGISTRATION_FIRST_NAME)
56 .lastName(REGISTRATION_LAST_NAME)
57 .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER)
58 .build();
59 }
60
61 public class WhenUserAccountIsFoundWithEmailAddress {
62
63 @Before
64 public void setUp() {
65 given(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).willReturn(new User());
66 }
67
68 @Test
69 public void shouldThrowException() throws DuplicateEmailException {
70 catchException(registrationService).registerNewUserAccount(registration);
71 assertThat(caughtException()).isExactlyInstanceOf(DuplicateEmailException.class);
72 }
73
74 @Test
75 public void shouldNotSaveNewUserAccount() throws DuplicateEmailException {
76 catchException(registrationService).registerNewUserAccount(registration);
77 verify(repository, never()).save(isA(User.class));
78 }
79 }
80
81 public class WhenEmailAddressIsUnique {
82
83 @Before
84 public void setUp() {
85 given(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).willReturn(null);
86
87 given(repository.save(isA(User.class))).willAnswer(new Answer<User>() {
88 @Override
89 public User answer(InvocationOnMock invocation) throws Throwable {
90 Object[] arguments = invocation.getArguments();
91 return (User) arguments[0];
92 }
93 });
94 }
95
96 @Test
97 public void shouldSaveNewUserAccount() throws DuplicateEmailException {
98 registrationService.registerNewUserAccount(registration);
99
100 ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class);
101 verify(repository, times(1)).save(isA(User.class));
102 }
103
104 @Test
105 public void shouldSetCorrectEmailAddress() throws DuplicateEmailException {
106 registrationService.registerNewUserAccount(registration);
107
108 ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class);
109 verify(repository, times(1)).save(userAccountArgument.capture());
110 User createdUserAccount = userAccountArgument.getValue();
111
112 assertThatUser(createdUserAccount)
113 .hasEmail(REGISTRATION_EMAIL_ADDRESS);
114 }
115
116 @Test
117 public void shouldSetCorrectFirstAndLastName() throws DuplicateEmailException {
118 registrationService.registerNewUserAccount(registration);
119
120 ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class);
121 verify(repository, times(1)).save(userAccountArgument.capture());
122 User createdUserAccount = userAccountArgument.getValue();
123
124 assertThatUser(createdUserAccount)
125 .hasFirstName(REGISTRATION_FIRST_NAME)
126 .hasLastName(REGISTRATION_LAST_NAME)
127 }
128
129 @Test
130 public void shouldCreateRegisteredUser() throws DuplicateEmailException {
131 registrationService.registerNewUserAccount(registration);
132
133 ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class);
134 verify(repository, times(1)).save(userAccountArgument.capture());
135 User createdUserAccount = userAccountArgument.getValue();
136
137 assertThatUser(createdUserAccount)
138 .isRegisteredUser()
139 }
140
141 @Test
142 public void shouldSetSignInProvider() throws DuplicateEmailException {
143 registrationService.registerNewUserAccount(registration);
144
145 ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class);
146 verify(repository, times(1)).save(userAccountArgument.capture());
147 User createdUserAccount = userAccountArgument.getValue();
148
149 assertThatUser(createdUserAccount)
150 .isRegisteredByUsingSignInProvider(SOCIAL_SIGN_IN_PROVIDER);
151 }
152
153 @Test
154 public void shouldNotCreateEncodedPasswordForUser() throws DuplicateEmailException {
155 registrationService.registerNewUserAccount(registration);
156
157 verifyZeroInteractions(passwordEncoder);
158 }
159
160 @Test
161 public void shouldReturnCreatedUserAccount() throws DuplicateEmailException {
162 User returnedUserAccount = registrationService.registerNewUserAccount(registration);
163
164 ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class);
165 verify(repository, times(1)).save(userAccountArgument.capture());
166 User createdUserAccount = userAccountArgument.getValue();
167
168 assertThat(returnedUserAccount)
169 .isEqualTo(createdUserAccount);
170 }
171 }
172
173 }
174 }
175}
Notes:
- This test class uses BDDMockito, but you can use the “standard” Mockito API as well.
- Our test class does still have some duplicate code that is used to capture the User object given as a method parameter to the save() method of the UserRepository. I left this code to the test methods because I didn’t want to make “too many” changes to the original source code. This code should be moved to a private method that is named properly.
Our test class is looking pretty clean, but we can still make it a little cleaner. Let’s find out how we can do that.
Linking Constants With the Test Methods
One problem that we face when we replace magic numbers with constants is that we have to add these constants to the beginning of our test class. This means that it is hard to link these constants with the test cases that use them.
If we take look at our unit test class, we notice that we use constants when we create a new RegistrationForm object. Because this happens in the setUp() method of the RegisterNewUserAccount class, we can solve our problem by moving the constants from the beginning of the RepositoryUserServiceTest class to beginning of the RegisterNewUserAccount class.
After we have do this, our test class looks as follows:
1import com.nitorcreations.junit.runners.NestedRunner
2import org.junit.Before;
3import org.junit.Test;
4import org.junit.runner.RunWith;
5import org.mockito.ArgumentCaptor;
6import org.mockito.Mock;
7import org.mockito.invocation.InvocationOnMock;
8import org.mockito.runners.MockitoJUnitRunner;
9import org.mockito.stubbing.Answer;
10import org.springframework.security.crypto.password.PasswordEncoder;
11
12import static com.googlecode.catchexception.CatchException.catchException;
13import static com.googlecode.catchexception.CatchException.caughtException;
14import static org.assertj.core.api.Assertions.assertThat;
15import static org.mockito.BDDMockito.given;
16import static org.mockito.Matchers.isA;
17import static org.mockito.Mockito.mock;
18import static org.mockito.Mockito.never;
19import static org.mockito.Mockito.times;
20import static org.mockito.Mockito.verify;
21import static org.mockito.Mockito.verifyZeroInteractions;
22
23@RunWith(NestedRunner.class)
24public class RepositoryUserServiceTest {
25
26 private RepositoryUserService registrationService;
27
28 private PasswordEncoder passwordEncoder;
29
30 private UserRepository repository;
31
32 @Before
33 public void setUp() {
34 passwordEncoder = mock(PasswordEncoder.class);
35 repository = mock(UserRepository.class);
36
37 registrationService = new RepositoryUserService(passwordEncoder, repository);
38 }
39
40 public class RegisterNewUserAccount {
41
42 private final String REGISTRATION_EMAIL_ADDRESS = "john.smith@gmail.com";
43 private final String REGISTRATION_FIRST_NAME = "John";
44 private final String REGISTRATION_LAST_NAME = "Smith";
45 private final SocialMediaService SOCIAL_SIGN_IN_PROVIDER = SocialMediaService.TWITTER;
46
47 public class WhenUserUsesSocialSignIn {
48
49 private RegistrationForm registration;
50
51 @Before
52 public void setUp() {
53 RegistrationForm registration = new RegistrationFormBuilder()
54 .email(REGISTRATION_EMAIL_ADDRESS)
55 .firstName(REGISTRATION_FIRST_NAME)
56 .lastName(REGISTRATION_LAST_NAME)
57 .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER)
58 .build();
59 }
60
61 public class WhenUserAccountIsFoundWithEmailAddress {
62
63 @Before
64 public void setUp() {
65 given(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).willReturn(new User());
66 }
67
68 @Test
69 public void shouldThrowException() throws DuplicateEmailException {
70 catchException(registrationService).registerNewUserAccount(registration);
71 assertThat(caughtException()).isExactlyInstanceOf(DuplicateEmailException.class);
72 }
73
74 @Test
75 public void shouldNotSaveNewUserAccount() throws DuplicateEmailException {
76 catchException(registrationService).registerNewUserAccount(registration);
77 verify(repository, never()).save(isA(User.class));
78 }
79 }
80
81 public class WhenEmailAddressIsUnique {
82
83 @Before
84 public void setUp() {
85 given(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).willReturn(null);
86
87 given(repository.save(isA(User.class))).willAnswer(new Answer<User>() {
88 @Override
89 public User answer(InvocationOnMock invocation) throws Throwable {
90 Object[] arguments = invocation.getArguments();
91 return (User) arguments[0];
92 }
93 });
94 }
95
96 @Test
97 public void shouldSaveNewUserAccount() throws DuplicateEmailException {
98 registrationService.registerNewUserAccount(registration);
99
100 ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class);
101 verify(repository, times(1)).save(isA(User.class));
102 }
103
104 @Test
105 public void shouldSetCorrectEmailAddress() throws DuplicateEmailException {
106 registrationService.registerNewUserAccount(registration);
107
108 ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class);
109 verify(repository, times(1)).save(userAccountArgument.capture());
110 User createdUserAccount = userAccountArgument.getValue();
111
112 assertThatUser(createdUserAccount)
113 .hasEmail(REGISTRATION_EMAIL_ADDRESS);
114 }
115
116 @Test
117 public void shouldSetCorrectFirstAndLastName() throws DuplicateEmailException {
118 registrationService.registerNewUserAccount(registration);
119
120 ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class);
121 verify(repository, times(1)).save(userAccountArgument.capture());
122 User createdUserAccount = userAccountArgument.getValue();
123
124 assertThatUser(createdUserAccount)
125 .hasFirstName(REGISTRATION_FIRST_NAME)
126 .hasLastName(REGISTRATION_LAST_NAME)
127 }
128
129 @Test
130 public void shouldCreateRegisteredUser() throws DuplicateEmailException {
131 registrationService.registerNewUserAccount(registration);
132
133 ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class);
134 verify(repository, times(1)).save(userAccountArgument.capture());
135 User createdUserAccount = userAccountArgument.getValue();
136
137 assertThatUser(createdUserAccount)
138 .isRegisteredUser()
139 }
140
141 @Test
142 public void shouldSetSignInProvider() throws DuplicateEmailException {
143 registrationService.registerNewUserAccount(registration);
144
145 ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class);
146 verify(repository, times(1)).save(userAccountArgument.capture());
147 User createdUserAccount = userAccountArgument.getValue();
148
149 assertThatUser(createdUserAccount)
150 .isRegisteredByUsingSignInProvider(SOCIAL_SIGN_IN_PROVIDER);
151 }
152
153 @Test
154 public void shouldNotCreateEncodedPasswordForUser() throws DuplicateEmailException {
155 registrationService.registerNewUserAccount(registration);
156
157 verifyZeroInteractions(passwordEncoder);
158 }
159
160 @Test
161 public void shouldReturnCreatedUserAccount() throws DuplicateEmailException {
162 User returnedUserAccount = registrationService.registerNewUserAccount(registration);
163
164 ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class);
165 verify(repository, times(1)).save(userAccountArgument.capture());
166 User createdUserAccount = userAccountArgument.getValue();
167
168 assertThat(returnedUserAccount)
169 .isEqualTo(createdUserAccount);
170 }
171 }
172
173 }
174 }
175}
It is now clear that these constants are relevant for the unit tests that are found from the RegisterNewUserAccount inner class and from its inner classes. This might seem like a small tweak, but I have noticed that small things can make a huge difference.
Let’s move on and summarize what we learned from this blog post.
Summary
This blog post has taught us that
- We can replace long method names with a BDD style class hierarchy.
- We can remove duplicate code by moving that code to setup methods and putting these methods to the correct inner classes.
- We can link the constants with test cases that use them by declaring the constants in the correct inner class.
Update: Some redditors argue that this solution is not cleaner than the old one. I agree that the new unit tests look very different than “regular” JUnit tests, and it can be hard to read them (at first).
However, if you use IntelliJ IDEA, working with unit tests becomes a pleasure. Let’s take a look at few screenshots (if you want to see the full size image, click the thumbnail):
We can “close” the inner classes that don’t interest us and concentrate on the interesting test cases:
When we run our unit tests, we can navigate the test class hierarchy by using the IDEA’s Test Runner tab:
In other words, if we use IDEA and NestedRunner, it is very easy to figure out the requirements of the tested method. I think that this is a huge improvement over the “traditional” way (a long list of test methods which have long and inaccurate method names).
P.S. I recommend that you read a blog post titled: Three Steps to Code Quality via TDD. It is an excellent blog post and you can use its lessons even if you don’t use TDD.

