Writing Clean Tests – Small Is Beautiful
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.
If you have not read my blog post titled: Writing Clean Tests – Trouble in Paradise, you should read it before you continue reading this blog post. It describes these problems with more details and provides additional information that helps you to understand this blog post.
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:
<dependency> <groupId>com.nitorcreations</groupId> <artifactId>junit-runners</artifactId> <version>1.2</version> <scope>test</scope> </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.
You can also leave the @Mock annotations to the passwordEncoder and repository fields, and create the mock objects by invoking the static initMocks() method of the MockitoAnnotations class. I decided to use the manual approach here because it is a bit more straightforward.
The source code of the RepositoryUserServiceTest class looks as follows:
import com.nitorcreations.junit.runners.NestedRunner import org.junit.Before; import org.junit.runner.RunWith; import org.springframework.security.crypto.password.PasswordEncoder; import static org.mockito.Mockito.mock; @RunWith(NestedRunner.class) public class RepositoryUserServiceTest { private static final String REGISTRATION_EMAIL_ADDRESS = "john.smith@gmail.com"; private static final String REGISTRATION_FIRST_NAME = "John"; private static final String REGISTRATION_LAST_NAME = "Smith"; private static final SocialMediaService SOCIAL_SIGN_IN_PROVIDER = SocialMediaService.TWITTER; private RepositoryUserService registrationService; private PasswordEncoder passwordEncoder; private UserRepository repository; @Before public void setUp() { passwordEncoder = mock(PasswordEncoder.class); repository = mock(UserRepository.class); registrationService = new RepositoryUserService(passwordEncoder, repository); } }
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:
import com.nitorcreations.junit.runners.NestedRunner import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.invocation.InvocationOnMock; import org.mockito.runners.MockitoJUnitRunner; import org.mockito.stubbing.Answer; import org.springframework.security.crypto.password.PasswordEncoder; import static com.googlecode.catchexception.CatchException.catchException; import static com.googlecode.catchexception.CatchException.caughtException; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Matchers.isA; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyZeroInteractions; import static org.mockito.Mockito.when; @RunWith(NestedRunner.class) public class RepositoryUserServiceTest { private static final String REGISTRATION_EMAIL_ADDRESS = "john.smith@gmail.com"; private static final String REGISTRATION_FIRST_NAME = "John"; private static final String REGISTRATION_LAST_NAME = "Smith"; private static final SocialMediaService SOCIAL_SIGN_IN_PROVIDER = SocialMediaService.TWITTER; private RepositoryUserService registrationService; private PasswordEncoder passwordEncoder; private UserRepository repository; @Before public void setUp() { passwordEncoder = mock(PasswordEncoder.class); repository = mock(UserRepository.class); registrationService = new RepositoryUserService(passwordEncoder, repository); } public class RegisterNewUserAccount { public class WhenUserUsesSocialSignIn { public class WhenUserAccountIsFoundWithEmailAddress { @Test public void shouldThrowException() throws DuplicateEmailException { RegistrationForm registration = new RegistrationFormBuilder() .email(REGISTRATION_EMAIL_ADDRESS) .firstName(REGISTRATION_FIRST_NAME) .lastName(REGISTRATION_LAST_NAME) .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER) .build(); when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(new User()); catchException(registrationService).registerNewUserAccount(registration); assertThat(caughtException()).isExactlyInstanceOf(DuplicateEmailException.class); } @Test public void shouldNotSaveNewUserAccount() throws DuplicateEmailException { RegistrationForm registration = new RegistrationFormBuilder() .email(REGISTRATION_EMAIL_ADDRESS) .firstName(REGISTRATION_FIRST_NAME) .lastName(REGISTRATION_LAST_NAME) .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER) .build(); when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(new User()); catchException(registrationService).registerNewUserAccount(registration); verify(repository, never()).save(isA(User.class)); } } public class WhenEmailAddressIsUnique { @Test public void shouldSaveNewUserAccount() throws DuplicateEmailException { RegistrationForm registration = new RegistrationFormBuilder() .email(REGISTRATION_EMAIL_ADDRESS) .firstName(REGISTRATION_FIRST_NAME) .lastName(REGISTRATION_LAST_NAME) .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER) .build(); when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(null); registrationService.registerNewUserAccount(registration); ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class); verify(repository, times(1)).save(isA(User.class)); } @Test public void shouldSetCorrectEmailAddress() throws DuplicateEmailException { RegistrationForm registration = new RegistrationFormBuilder() .email(REGISTRATION_EMAIL_ADDRESS) .firstName(REGISTRATION_FIRST_NAME) .lastName(REGISTRATION_LAST_NAME) .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER) .build(); when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(null); registrationService.registerNewUserAccount(registration); ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class); verify(repository, times(1)).save(userAccountArgument.capture()); User createdUserAccount = userAccountArgument.getValue(); assertThatUser(createdUserAccount) .hasEmail(REGISTRATION_EMAIL_ADDRESS); } @Test public void shouldSetCorrectFirstAndLastName() throws DuplicateEmailException { RegistrationForm registration = new RegistrationFormBuilder() .email(REGISTRATION_EMAIL_ADDRESS) .firstName(REGISTRATION_FIRST_NAME) .lastName(REGISTRATION_LAST_NAME) .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER) .build(); when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(null); registrationService.registerNewUserAccount(registration); ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class); verify(repository, times(1)).save(userAccountArgument.capture()); User createdUserAccount = userAccountArgument.getValue(); assertThatUser(createdUserAccount) .hasFirstName(REGISTRATION_FIRST_NAME) .hasLastName(REGISTRATION_LAST_NAME) } @Test public void shouldCreateRegisteredUser() throws DuplicateEmailException { RegistrationForm registration = new RegistrationFormBuilder() .email(REGISTRATION_EMAIL_ADDRESS) .firstName(REGISTRATION_FIRST_NAME) .lastName(REGISTRATION_LAST_NAME) .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER) .build(); when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(null); registrationService.registerNewUserAccount(registration); ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class); verify(repository, times(1)).save(userAccountArgument.capture()); User createdUserAccount = userAccountArgument.getValue(); assertThatUser(createdUserAccount) .isRegisteredUser() } @Test public void shouldSetSignInProvider() throws DuplicateEmailException { RegistrationForm registration = new RegistrationFormBuilder() .email(REGISTRATION_EMAIL_ADDRESS) .firstName(REGISTRATION_FIRST_NAME) .lastName(REGISTRATION_LAST_NAME) .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER) .build(); when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(null); registrationService.registerNewUserAccount(registration); ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class); verify(repository, times(1)).save(userAccountArgument.capture()); User createdUserAccount = userAccountArgument.getValue(); assertThatUser(createdUserAccount) .isRegisteredByUsingSignInProvider(SOCIAL_SIGN_IN_PROVIDER); } @Test public void shouldNotCreateEncodedPasswordForUser() throws DuplicateEmailException { RegistrationForm registration = new RegistrationFormBuilder() .email(REGISTRATION_EMAIL_ADDRESS) .firstName(REGISTRATION_FIRST_NAME) .lastName(REGISTRATION_LAST_NAME) .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER) .build(); when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(null); registrationService.registerNewUserAccount(registration); verifyZeroInteractions(passwordEncoder); } @Test public void shouldReturnCreatedUserAccount() throws DuplicateEmailException { RegistrationForm registration = new RegistrationFormBuilder() .email(REGISTRATION_EMAIL_ADDRESS) .firstName(REGISTRATION_FIRST_NAME) .lastName(REGISTRATION_LAST_NAME) .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER) .build(); when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(null); when(repository.save(isA(User.class))).thenAnswer(new Answer<User>() { @Override public User answer(InvocationOnMock invocation) throws Throwable { Object[] arguments = invocation.getArguments(); return (User) arguments[0]; } }); User returnedUserAccount = registrationService.registerNewUserAccount(registration); ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class); verify(repository, times(1)).save(userAccountArgument.capture()); User createdUserAccount = userAccountArgument.getValue(); assertThat(returnedUserAccount) .isEqualTo(createdUserAccount); } } } } }
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:
@RunWith(NestedRunner.class) public class TestClass { /** * This setup method is invoked before the test and setup methods * found from the inner classes of this class. * This is a good place for configuration that is shared by all * test methods found from this test class. */ @Before public void setUpTestClass() {} public class MethodA { /** * This setup method is invoked before the test methods found from * this class and before the test and setup methods found from the * inner classes of this class. * * This is a good place for configuration that is shared by all test * methods which ensure that the methodA() is working correctly. */ @Before public void setUpMethodA() {} @Test public void shouldFooBar() {} public class WhenFoo { /** * This setup method is invoked before the test methods found from * this class and before the test and setup methods found from the * inner classes of this class. * * This is a good place for configuration which ensures that the methodA() * working correctly when foo is 'true'. */ @Before public void setUpWhenFoo() {} @Test public void shouldBar() {} } public class WhenBar { @Test public shouldFoo() {} } } }
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:
import com.nitorcreations.junit.runners.NestedRunner import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.invocation.InvocationOnMock; import org.mockito.runners.MockitoJUnitRunner; import org.mockito.stubbing.Answer; import org.springframework.security.crypto.password.PasswordEncoder; import static com.googlecode.catchexception.CatchException.catchException; import static com.googlecode.catchexception.CatchException.caughtException; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; import static org.mockito.Matchers.isA; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyZeroInteractions; @RunWith(NestedRunner.class) public class RepositoryUserServiceTest { private static final String REGISTRATION_EMAIL_ADDRESS = "john.smith@gmail.com"; private static final String REGISTRATION_FIRST_NAME = "John"; private static final String REGISTRATION_LAST_NAME = "Smith"; private static final SocialMediaService SOCIAL_SIGN_IN_PROVIDER = SocialMediaService.TWITTER; private RepositoryUserService registrationService; private PasswordEncoder passwordEncoder; private UserRepository repository; @Before public void setUp() { passwordEncoder = mock(PasswordEncoder.class); repository = mock(UserRepository.class); registrationService = new RepositoryUserService(passwordEncoder, repository); } public class RegisterNewUserAccount { public class WhenUserUsesSocialSignIn { private RegistrationForm registration; @Before public void setUp() { RegistrationForm registration = new RegistrationFormBuilder() .email(REGISTRATION_EMAIL_ADDRESS) .firstName(REGISTRATION_FIRST_NAME) .lastName(REGISTRATION_LAST_NAME) .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER) .build(); } public class WhenUserAccountIsFoundWithEmailAddress { @Before public void setUp() { given(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).willReturn(new User()); } @Test public void shouldThrowException() throws DuplicateEmailException { catchException(registrationService).registerNewUserAccount(registration); assertThat(caughtException()).isExactlyInstanceOf(DuplicateEmailException.class); } @Test public void shouldNotSaveNewUserAccount() throws DuplicateEmailException { catchException(registrationService).registerNewUserAccount(registration); verify(repository, never()).save(isA(User.class)); } } public class WhenEmailAddressIsUnique { @Before public void setUp() { given(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).willReturn(null); given(repository.save(isA(User.class))).willAnswer(new Answer<User>() { @Override public User answer(InvocationOnMock invocation) throws Throwable { Object[] arguments = invocation.getArguments(); return (User) arguments[0]; } }); } @Test public void shouldSaveNewUserAccount() throws DuplicateEmailException { registrationService.registerNewUserAccount(registration); ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class); verify(repository, times(1)).save(isA(User.class)); } @Test public void shouldSetCorrectEmailAddress() throws DuplicateEmailException { registrationService.registerNewUserAccount(registration); ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class); verify(repository, times(1)).save(userAccountArgument.capture()); User createdUserAccount = userAccountArgument.getValue(); assertThatUser(createdUserAccount) .hasEmail(REGISTRATION_EMAIL_ADDRESS); } @Test public void shouldSetCorrectFirstAndLastName() throws DuplicateEmailException { registrationService.registerNewUserAccount(registration); ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class); verify(repository, times(1)).save(userAccountArgument.capture()); User createdUserAccount = userAccountArgument.getValue(); assertThatUser(createdUserAccount) .hasFirstName(REGISTRATION_FIRST_NAME) .hasLastName(REGISTRATION_LAST_NAME) } @Test public void shouldCreateRegisteredUser() throws DuplicateEmailException { registrationService.registerNewUserAccount(registration); ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class); verify(repository, times(1)).save(userAccountArgument.capture()); User createdUserAccount = userAccountArgument.getValue(); assertThatUser(createdUserAccount) .isRegisteredUser() } @Test public void shouldSetSignInProvider() throws DuplicateEmailException { registrationService.registerNewUserAccount(registration); ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class); verify(repository, times(1)).save(userAccountArgument.capture()); User createdUserAccount = userAccountArgument.getValue(); assertThatUser(createdUserAccount) .isRegisteredByUsingSignInProvider(SOCIAL_SIGN_IN_PROVIDER); } @Test public void shouldNotCreateEncodedPasswordForUser() throws DuplicateEmailException { registrationService.registerNewUserAccount(registration); verifyZeroInteractions(passwordEncoder); } @Test public void shouldReturnCreatedUserAccount() throws DuplicateEmailException { User returnedUserAccount = registrationService.registerNewUserAccount(registration); ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class); verify(repository, times(1)).save(userAccountArgument.capture()); User createdUserAccount = userAccountArgument.getValue(); assertThat(returnedUserAccount) .isEqualTo(createdUserAccount); } } } } }
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:
import com.nitorcreations.junit.runners.NestedRunner import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.invocation.InvocationOnMock; import org.mockito.runners.MockitoJUnitRunner; import org.mockito.stubbing.Answer; import org.springframework.security.crypto.password.PasswordEncoder; import static com.googlecode.catchexception.CatchException.catchException; import static com.googlecode.catchexception.CatchException.caughtException; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; import static org.mockito.Matchers.isA; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyZeroInteractions; @RunWith(NestedRunner.class) public class RepositoryUserServiceTest { private RepositoryUserService registrationService; private PasswordEncoder passwordEncoder; private UserRepository repository; @Before public void setUp() { passwordEncoder = mock(PasswordEncoder.class); repository = mock(UserRepository.class); registrationService = new RepositoryUserService(passwordEncoder, repository); } public class RegisterNewUserAccount { private final String REGISTRATION_EMAIL_ADDRESS = "john.smith@gmail.com"; private final String REGISTRATION_FIRST_NAME = "John"; private final String REGISTRATION_LAST_NAME = "Smith"; private final SocialMediaService SOCIAL_SIGN_IN_PROVIDER = SocialMediaService.TWITTER; public class WhenUserUsesSocialSignIn { private RegistrationForm registration; @Before public void setUp() { RegistrationForm registration = new RegistrationFormBuilder() .email(REGISTRATION_EMAIL_ADDRESS) .firstName(REGISTRATION_FIRST_NAME) .lastName(REGISTRATION_LAST_NAME) .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER) .build(); } public class WhenUserAccountIsFoundWithEmailAddress { @Before public void setUp() { given(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).willReturn(new User()); } @Test public void shouldThrowException() throws DuplicateEmailException { catchException(registrationService).registerNewUserAccount(registration); assertThat(caughtException()).isExactlyInstanceOf(DuplicateEmailException.class); } @Test public void shouldNotSaveNewUserAccount() throws DuplicateEmailException { catchException(registrationService).registerNewUserAccount(registration); verify(repository, never()).save(isA(User.class)); } } public class WhenEmailAddressIsUnique { @Before public void setUp() { given(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).willReturn(null); given(repository.save(isA(User.class))).willAnswer(new Answer<User>() { @Override public User answer(InvocationOnMock invocation) throws Throwable { Object[] arguments = invocation.getArguments(); return (User) arguments[0]; } }); } @Test public void shouldSaveNewUserAccount() throws DuplicateEmailException { registrationService.registerNewUserAccount(registration); ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class); verify(repository, times(1)).save(isA(User.class)); } @Test public void shouldSetCorrectEmailAddress() throws DuplicateEmailException { registrationService.registerNewUserAccount(registration); ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class); verify(repository, times(1)).save(userAccountArgument.capture()); User createdUserAccount = userAccountArgument.getValue(); assertThatUser(createdUserAccount) .hasEmail(REGISTRATION_EMAIL_ADDRESS); } @Test public void shouldSetCorrectFirstAndLastName() throws DuplicateEmailException { registrationService.registerNewUserAccount(registration); ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class); verify(repository, times(1)).save(userAccountArgument.capture()); User createdUserAccount = userAccountArgument.getValue(); assertThatUser(createdUserAccount) .hasFirstName(REGISTRATION_FIRST_NAME) .hasLastName(REGISTRATION_LAST_NAME) } @Test public void shouldCreateRegisteredUser() throws DuplicateEmailException { registrationService.registerNewUserAccount(registration); ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class); verify(repository, times(1)).save(userAccountArgument.capture()); User createdUserAccount = userAccountArgument.getValue(); assertThatUser(createdUserAccount) .isRegisteredUser() } @Test public void shouldSetSignInProvider() throws DuplicateEmailException { registrationService.registerNewUserAccount(registration); ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class); verify(repository, times(1)).save(userAccountArgument.capture()); User createdUserAccount = userAccountArgument.getValue(); assertThatUser(createdUserAccount) .isRegisteredByUsingSignInProvider(SOCIAL_SIGN_IN_PROVIDER); } @Test public void shouldNotCreateEncodedPasswordForUser() throws DuplicateEmailException { registrationService.registerNewUserAccount(registration); verifyZeroInteractions(passwordEncoder); } @Test public void shouldReturnCreatedUserAccount() throws DuplicateEmailException { User returnedUserAccount = registrationService.registerNewUserAccount(registration); ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class); verify(repository, times(1)).save(userAccountArgument.capture()); User createdUserAccount = userAccountArgument.getValue(); assertThat(returnedUserAccount) .isEqualTo(createdUserAccount); } } } } }
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.
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.
If you want to write clean tests, you should read my Writing Clean Tests tutorial.
Reference: | Writing Clean Tests – Small Is Beautiful from our JCG partner Petri Kainulainen at the Petri Kainulainen blog. |