A good unit test should fail for only one reason. This means that a proper unit test tests only one logical concept.
If we want to write clean tests, we have to identify those logical concepts, and write only one test case per logical concept.
This blog post describes how we can identify the logical concepts found from our tests, and split an existing unit test into multiple unit tests.
Pretty Clean Isn’t Good Enough
Let’s start by taking a look at the source code of our unit test which ensures that the registerNewUserAccount(RegistrationForm userAccountData) method of the RepositoryUserService class works as expected when a new user account is created by using a unique email address and a social sign in provider.
The source code of this unit test looks as follows:
1import org.junit.Before;
2import org.junit.Test;
3import org.junit.runner.RunWith;
4import org.mockito.Mock;
5import org.mockito.invocation.InvocationOnMock;
6import org.mockito.runners.MockitoJUnitRunner;
7import org.mockito.stubbing.Answer;
8import org.springframework.security.crypto.password.PasswordEncoder;
9
10import static org.junit.Assert.assertEquals;
11import static org.junit.Assert.assertNull;
12import static org.mockito.Matchers.isA;
13import static org.mockito.Mockito.times;
14import static org.mockito.Mockito.verify;
15import static org.mockito.Mockito.verifyNoMoreInteractions;
16import static org.mockito.Mockito.verifyZeroInteractions;
17import static org.mockito.Mockito.when;
18
19@RunWith(MockitoJUnitRunner.class)
20public class RepositoryUserServiceTest {
21
22 private static final String REGISTRATION_EMAIL_ADDRESS = "john.smith@gmail.com";
23 private static final String REGISTRATION_FIRST_NAME = "John";
24 private static final String REGISTRATION_LAST_NAME = "Smith";
25 private static final SocialMediaService SOCIAL_SIGN_IN_PROVIDER = SocialMediaService.TWITTER;
26
27 private RepositoryUserService registrationService;
28
29 @Mock
30 private PasswordEncoder passwordEncoder;
31
32 @Mock
33 private UserRepository repository;
34
35 @Before
36 public void setUp() {
37 registrationService = new RepositoryUserService(passwordEncoder, repository);
38 }
39
40 @Test
41 public void registerNewUserAccount_SocialSignInAndUniqueEmail_ShouldCreateNewUserAccountAndSetSignInProvider() throws DuplicateEmailException {
42 RegistrationForm registration = new RegistrationFormBuilder()
43 .email(REGISTRATION_EMAIL_ADDRESS)
44 .firstName(REGISTRATION_FIRST_NAME)
45 .lastName(REGISTRATION_LAST_NAME)
46 .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER)
47 .build();
48
49 when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(null);
50
51 when(repository.save(isA(User.class))).thenAnswer(new Answer<User>() {
52 @Override
53 public User answer(InvocationOnMock invocation) throws Throwable {
54 Object[] arguments = invocation.getArguments();
55 return (User) arguments[0];
56 }
57 });
58
59 User createdUserAccount = registrationService.registerNewUserAccount(registration);
60
61 assertThatUser(createdUserAccount)
62 .hasEmail(REGISTRATION_EMAIL_ADDRESS)
63 .hasFirstName(REGISTRATION_FIRST_NAME)
64 .hasLastName(REGISTRATION_LAST_NAME)
65 .isRegisteredUser()
66 .isRegisteredByUsingSignInProvider(SOCIAL_SIGN_IN_PROVIDER);
67
68 verify(repository, times(1)).findByEmail(REGISTRATION_EMAIL_ADDRESS);
69 verify(repository, times(1)).save(createdUserAccount);
70 verifyNoMoreInteractions(repository);
71 verifyZeroInteractions(passwordEncoder);
72 }
73}
This unit test is pretty clean. After all, our test class, test method, and the local variables created inside the test method have descriptive names. We have also replaced magic numbers with constants and created domain-specific languages for creating new objects and writing assertions.
And yet, we can make this test even better.
The problem of this unit test is that it can fail for more than one reason. It can fail if
- Our service method doesn’t check that the email address entered to the registration form is not found from our database.
- The information of the persisted User object doesn’t match with the information entered to the registration form.
- The information of the returned User object isn’t correct.
- Our service method creates a password for the user by using the PasswordEncoder object.
In other words, this unit test tests four different logical concepts, and this causes the following problems:
- If this test fails, we don’t necessarily know why it failed. This means that we have to read the source code of the unit test.
- The unit test is a bit long which makes it somewhat hard to read.
- It is hard to describe the expected behavior. This means that it is very hard to figure great names for our test methods.
That is why we need to split this test into four unit tests.
One Test, One Point of Failure
Our next step is to split our unit test into four new unit tests and ensure that each of them tests a single logical concept. We can do this by writing the following unit tests:
- We need to ensure that our service method checks that the email address given by the user is unique.
- We need to verify that information of the persisted User object is correct.
- We need to ensure that the information of the returned User object is correct.
- We need to verify that our service method doesn’t create an encoded password for a user who uses social sign in provider.
After we have written these unit tests, the source code of our test class looks as follows:
1import org.junit.Before;
2import org.junit.Test;
3import org.junit.runner.RunWith;
4import org.mockito.ArgumentCaptor;
5import org.mockito.Mock;
6import org.mockito.invocation.InvocationOnMock;
7import org.mockito.runners.MockitoJUnitRunner;
8import org.mockito.stubbing.Answer;
9import org.springframework.security.crypto.password.PasswordEncoder;
10
11import static net.petrikainulainen.spring.social.signinmvc.user.model.UserAssert.assertThat;
12import static org.mockito.Matchers.isA;
13import static org.mockito.Mockito.times;
14import static org.mockito.Mockito.verify;
15import static org.mockito.Mockito.verifyZeroInteractions;
16import static org.mockito.Mockito.when;
17
18@RunWith(MockitoJUnitRunner.class)
19public class RepositoryUserServiceTest {
20
21 private static final String REGISTRATION_EMAIL_ADDRESS = "john.smith@gmail.com";
22 private static final String REGISTRATION_FIRST_NAME = "John";
23 private static final String REGISTRATION_LAST_NAME = "Smith";
24 private static final SocialMediaService SOCIAL_SIGN_IN_PROVIDER = SocialMediaService.TWITTER;
25
26 private RepositoryUserService registrationService;
27
28 @Mock
29 private PasswordEncoder passwordEncoder;
30
31 @Mock
32 private UserRepository repository;
33
34 @Before
35 public void setUp() {
36 registrationService = new RepositoryUserService(passwordEncoder, repository);
37 }
38
39 @Test
40 public void registerNewUserAccount_SocialSignInAndUniqueEmail_ShouldCheckThatEmailIsUnique() throws DuplicateEmailException {
41 RegistrationForm registration = new RegistrationFormBuilder()
42 .email(REGISTRATION_EMAIL_ADDRESS)
43 .firstName(REGISTRATION_FIRST_NAME)
44 .lastName(REGISTRATION_LAST_NAME)
45 .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER)
46 .build();
47
48 when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(null);
49
50 registrationService.registerNewUserAccount(registration);
51
52 verify(repository, times(1)).findByEmail(REGISTRATION_EMAIL_ADDRESS);
53 }
54
55 @Test
56 public void registerNewUserAccount_SocialSignInAndUniqueEmail_ShouldSaveNewUserAccountAndSetSignInProvider() throws DuplicateEmailException {
57 RegistrationForm registration = new RegistrationFormBuilder()
58 .email(REGISTRATION_EMAIL_ADDRESS)
59 .firstName(REGISTRATION_FIRST_NAME)
60 .lastName(REGISTRATION_LAST_NAME)
61 .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER)
62 .build();
63
64 when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(null);
65
66 registrationService.registerNewUserAccount(registration);
67
68 ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class);
69 verify(repository, times(1)).save(userAccountArgument.capture());
70
71 User createdUserAccount = userAccountArgument.getValue();
72
73 assertThatUser(createdUserAccount)
74 .hasEmail(REGISTRATION_EMAIL_ADDRESS)
75 .hasFirstName(REGISTRATION_FIRST_NAME)
76 .hasLastName(REGISTRATION_LAST_NAME)
77 .isRegisteredUser()
78 .isRegisteredByUsingSignInProvider(SOCIAL_SIGN_IN_PROVIDER);
79 }
80
81 @Test
82 public void registerNewUserAccount_SocialSignInAndUniqueEmail_ShouldReturnCreatedUserAccount() throws DuplicateEmailException {
83 RegistrationForm registration = new RegistrationFormBuilder()
84 .email(REGISTRATION_EMAIL_ADDRESS)
85 .firstName(REGISTRATION_FIRST_NAME)
86 .lastName(REGISTRATION_LAST_NAME)
87 .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER)
88 .build();
89
90 when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(null);
91
92 when(repository.save(isA(User.class))).thenAnswer(new Answer<User>() {
93 @Override
94 public User answer(InvocationOnMock invocation) throws Throwable {
95 Object[] arguments = invocation.getArguments();
96 return (User) arguments[0];
97 }
98 });
99
100 User createdUserAccount = registrationService.registerNewUserAccount(registration);
101
102 assertThatUser(createdUserAccount)
103 .hasEmail(REGISTRATION_EMAIL_ADDRESS)
104 .hasFirstName(REGISTRATION_FIRST_NAME)
105 .hasLastName(REGISTRATION_LAST_NAME)
106 .isRegisteredUser()
107 .isRegisteredByUsingSignInProvider(SOCIAL_SIGN_IN_PROVIDER);
108 }
109
110 @Test
111 public void registerNewUserAccount_SocialSignInAnUniqueEmail_ShouldNotCreateEncodedPasswordForUser() throws DuplicateEmailException {
112 RegistrationForm registration = new RegistrationFormBuilder()
113 .email(REGISTRATION_EMAIL_ADDRESS)
114 .firstName(REGISTRATION_FIRST_NAME)
115 .lastName(REGISTRATION_LAST_NAME)
116 .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER)
117 .build();
118
119 when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(null);
120
121 registrationService.registerNewUserAccount(registration);
122
123 verifyZeroInteractions(passwordEncoder);
124 }
125}
The obvious benefit of writing unit tests which test only one logical concept is that it easy to know why the test failed. However, this approach has two other benefits as well:
- It is easy to specify the expected behavior. This means that it is easier to figure out good names for our test methods.
- Because these unit tests are considerably shorter than the original unit test, it is easier to figure out the requirements of the tested method / component. This helps us to transform our tests into executable specifications.
Let’s move on and summarize what we learned from this blog post.
Summary
We have now successfully splitted our unit test into four smaller unit tests which test a single logical concept. This blog post has taught us two things:
- We learned that we can identify the logical concepts covered by a single unit test by identifying the situations when that test will fail.
- We learned that writing unit tests which test only one logical concept helps us to write transform our test cases into executable specifications, which identity the requirements of the tested method / component.