Clean Code from the Trenches – Writing Executable Specifications with JUnit 5, Mockito, and AssertJ
Executable Specifications are tests that can also serve as design specifications. They enable technical and business teams to get on the same page by enabling the use of a common language (in DDD-world this is also known as Ubiquitous Language). They function as documentations for the future maintainers of the code.
In this article we will see an opinionated way of writing automated tests which could also function as Executable Specifications.
Let’s start with an example. Suppose we are creating an accounting system for a business. The system will allow its users to record incomes and expenses into different accounts. Before users can start recording incomes and expenses, they should be able to add new accounts into the system. Suppose that the specification for the “Add New Account” use case looks like below –
Scenario 1
Given account does not exist
When user adds a new account
Then added account has the given name
Then added account has the given initial balance
Then added account has user’s id
Scenario 2
Given account does not exist
When user adds a new account with negative initial balance
Then add new account fails
Scenario 3
Given account with the same name exists
When user adds a new account
Then add new account fails
In order to create a new account the user needs to enter an account name and an initial balance into the system. The system will then create the account if no account with the given name already exists and the given initial balance is positive.
We will first write down a test which will capture the first “Given-When-Then” part of the first scenario. This is how it looks like –
1 2 3 4 5 6 7 8 | class AddNewAccountTest { @Test @DisplayName ( "Given account does not exist When user adds a new account Then added account has the given name" ) void accountAddedWithGivenName() { } } |
The @DisplayName annotation was introduced in JUnit 5. It assigns a human-readable name to a test. This is the label that we would see when we execute this test e.g., in an IDE like IntelliJ IDEA.
We will now create a class which will be responsible for adding the account
1 2 3 4 5 6 | class AddNewAccountService { void addNewAccount(String accountName) { } } |
The class defines a single method which accepts the name of an account and will be responsible for creating it i.e., saving it to a persistent data store. Since we decided to call this class AddNewAccountService, we will also rename our test to AddNewAccountServiceTest to follow the naming convention used in the JUnit world.
We can now proceed with writing our test –
01 02 03 04 05 06 07 08 09 10 11 12 | class AddNewAccountServiceTest { @Test @DisplayName ( "Given account does not exist When user adds a new account Then added account has the given name" ) void accountAddedWithGivenName() { AddNewAccountService accountService = new AddNewAccountService(); accountService.addNewAccount( "test account" ); // What to test? } } |
What should we test/verify to ensure that the scenario is properly implemented? If we read our specification again, it is clear that we want to create an “Account” with a user-given name, hence this is what we should try to test here. In order to do this, we will have to first create a class which will represent an Account –
1 2 3 4 | @AllArgsConstructor class Account { private String name; } |
The Account class has only one property called name. It will have other fields like user id and balance, but we are not testing those at the moment, hence we will not add them to the class right away.
Now that we have created the Account class, how do we save it, and more importantly, how do we test that the account being saved has the user-given name? There are many approaches to do this, and my preferred one is to define an interface which will encapsulate this saving action. Let’s go ahead and create it –
1 2 3 4 | interface SaveAccountPort { void saveAccount(Account account); } |
The AddNewAccountService will be injected with an implementation of this interface via constructor injection –
1 2 3 4 5 6 7 8 | @RequiredArgsConstructor class AddNewAccountService { private final SaveAccountPort saveAccountPort; void addNewAccount(String accountName) { } } |
For testing purposes we will create a mock implementation with the help of Mockito so that we don’t have to worry about the actual implementation details –
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 | @ExtendWith (MockitoExtension. class ) class AddNewAccountServiceTest { @Mock private SaveAccountPort saveAccountPort; @Test @DisplayName ( "Given account does not exist When user adds a new account Then added account has the given name" ) void accountAddedWithGivenName() { AddNewAccountService accountService = new AddNewAccountService(saveAccountPort); accountService.addNewAccount( "test account" ); // What to test? } } |
Our test setup is now complete. We now expect our method under test, the addNewAccount method of the AddNewAccountService class, to invoke the saveAccount method of the SaveAccountPort, with an Account object whose name is set to the one passed to the method. Let’s codify this in our test –
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 | @ExtendWith (MockitoExtension. class ) class AddNewAccountServiceTest { @Mock private SaveAccountPort saveAccountPort; @Captor private ArgumentCaptor<Account> accountArgumentCaptor; @Test @DisplayName ( "Given account does not exist When user adds a new account Then added account has the given name" ) void accountAddedWithGivenName() { AddNewAccountService accountService = new AddNewAccountService(saveAccountPort); accountService.addNewAccount( "test account" ); BDDMockito.then(saveAccountPort).should().saveAccount(accountArgumentCaptor.capture()); BDDAssertions.then(accountArgumentCaptor.getValue().getName()).isEqualTo( "test account" ); } } |
The line below –
1 | BDDMockito.then(saveAccountPort).should().saveAccount(accountArgumentCaptor.capture()); |
verifies that the saveAccount method of the SaveAccountPort is invoked once the method under test is invoked. We also capture the account argument that is passed to the saveAccount method with our argument captor. The next line –
1 | BDDAssertions.then(accountArgumentCaptor.getValue().getName()).isEqualTo( "test account" ); |
then verifies that the captured account argument has the same name as the one that was passed in the test.
In order to make this test pass, the minimal code that is needed in our method under test is as follows –
1 2 3 4 5 6 7 8 | @RequiredArgsConstructor class AddNewAccountService { private final SaveAccountPort saveAccountPort; void addNewAccount(String accountName) { saveAccountPort.saveAccount( new Account(accountName)); } } |
With that, our test starts to pass!
Let’s move on to the second “Then” part of the first scenario, which says –
Then added account has the given initial balance
Let’s write another test which will verify this part –
01 02 03 04 05 06 07 08 09 10 11 | @Test @DisplayName ( "Given account does not exist When user adds a new account Then added account has the given initial balance" ) void accountAddedWithGivenInitialBalance() { AddNewAccountService accountService = new AddNewAccountService(saveAccountPort); accountService.addNewAccount( "test account" , "56.0" ); BDDMockito.then(saveAccountPort).should().saveAccount(accountArgumentCaptor.capture()); BDDAssertions.then(accountArgumentCaptor.getValue().getBalance()) .isEqualTo( new BigDecimal( "56.0" )); } |
We have modified our addNewAccount method to accept the initial balance as the second argument. We have also added a new field, called balance, in our Account object which is able to store the account balance –
1 2 3 4 5 6 | @AllArgsConstructor @Getter class Account { private String name; private BigDecimal balance; } |
Since we have changed the signature of the addNewAccount method, we will also have to modify our first test –
01 02 03 04 05 06 07 08 09 10 | @Test @DisplayName ( "Given account does not exist When user adds a new account Then added account has the given name" ) void accountAddedWithGivenName() { AddNewAccountService accountService = new AddNewAccountService(saveAccountPort); accountService.addNewAccount( "test account" , "1" ); BDDMockito.then(saveAccountPort).should().saveAccount(accountArgumentCaptor.capture()); BDDAssertions.then(accountArgumentCaptor.getValue().getName()).isEqualTo( "test account" ); } |
If we run our new test now it will fail as we haven’t implemented the functionality yet. Let’s do that now –
1 2 3 | void addNewAccount(String accountName, String initialBalance) { saveAccountPort.saveAccount( new Account(accountName, new BigDecimal(initialBalance))); } |
Both of our tests should pass now.
As we already have a couple of tests in place, it’s time to take a look at our implementation and see if we can make it better. Since our AddNewAccountService is as simple as it can be, we don’t have to do anything there. As for our tests, we could eliminate the duplication in our test setup code – both tests are instantiating an instance of the AddNewAccountService and invoking the addNewAccount method on it in the same way. Whether to remove or keep this duplication depends on our style of writing tests – if we want to make each test as independent as possible, then let’s leave them as they are. If we, however, are fine with having a common test setup code, then we could change the tests as follows
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | @ExtendWith (MockitoExtension. class ) @DisplayName ( "Given account does not exist When user adds a new account" ) class AddNewAccountServiceTest { private static final String ACCOUNT_NAME = "test account" ; private static final String INITIAL_BALANCE = "56.0" ; @Mock private SaveAccountPort saveAccountPort; @Captor private ArgumentCaptor<Account> accountArgumentCaptor; @BeforeEach void setup() { AddNewAccountService accountService = new AddNewAccountService(saveAccountPort); accountService.addNewAccount(ACCOUNT_NAME, INITIAL_BALANCE); } @Test @DisplayName ( "Then added account has the given name" ) void accountAddedWithGivenName() { BDDMockito.then(saveAccountPort).should().saveAccount(accountArgumentCaptor.capture()); BDDAssertions.then(accountArgumentCaptor.getValue().getName()).isEqualTo(ACCOUNT_NAME); } @Test @DisplayName ( "Then added account has the given initial balance" ) void accountAddedWithGivenInitialBalance() { BDDMockito.then(saveAccountPort).should().saveAccount(accountArgumentCaptor.capture()); BDDAssertions.then(accountArgumentCaptor.getValue().getBalance()) .isEqualTo( new BigDecimal(INITIAL_BALANCE)); } } |
Notice that we have also extracted the common part of the @DisplayName and put this on top of the test class. If we are not comfortable doing this, we could also leave them as they are.
Since we have more than one passing tests, from now on every time we make a failing test pass we will stop for a moment, take a look at our implementation, and will try to improve it. To summarise, our implementation process will now consist of the following steps –
- Add a failing test while making sure existing tests keep passing
- Make the failing test pass
- Pause for a moment and try to improve the implementation (both the code and the tests)
Moving on, we now need to store user ids with the created account. Following our method we will first write a failing test to capture this and then add the minimal amount of code needed to make the failing test pass. This is how the implementation looks like once the failing test starts to pass
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 | @ExtendWith (MockitoExtension. class ) @DisplayName ( "Given account does not exist When user adds a new account" ) class AddNewAccountServiceTest { private static final String ACCOUNT_NAME = "test account" ; private static final String INITIAL_BALANCE = "56.0" ; private static final String USER_ID = "some id" ; private Account savedAccount; @BeforeEach void setup() { AddNewAccountService accountService = new AddNewAccountService(saveAccountPort); accountService.addNewAccount(ACCOUNT_NAME, INITIAL_BALANCE, USER_ID); BDDMockito.then(saveAccountPort).should().saveAccount(accountArgumentCaptor.capture()); savedAccount = accountArgumentCaptor.getValue(); } // Other tests..... @Test @DisplayName ( "Then added account has user's id" ) void accountAddedWithUsersId() { BDDAssertions.then(accountArgumentCaptor.getValue().getUserId()).isEqualTo(USER_ID); } } @RequiredArgsConstructor class AddNewAccountService { private final SaveAccountPort saveAccountPort; void addNewAccount(String accountName, String initialBalance, String userId) { saveAccountPort.saveAccount( new Account(accountName, new BigDecimal(initialBalance), userId)); } } @AllArgsConstructor @Getter class Account { private String name; private BigDecimal balance; private String userId; } |
Since all the tests are now passing, it’s improvement time! Notice that the addNewAccount method accepts three argument already. As we introduce more and more account properties its argument list will also start to increase. We could introduce a parameter object to avoid that
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 | @RequiredArgsConstructor class AddNewAccountService { private final SaveAccountPort saveAccountPort; void addNewAccount(AddNewAccountCommand command) { saveAccountPort.saveAccount( new Account( command.getAccountName(), new BigDecimal(command.getInitialBalance()), command.getUserId() ) ); } @Builder @Getter static class AddNewAccountCommand { private final String userId; private final String accountName; private final String initialBalance; } } @ExtendWith (MockitoExtension. class ) @DisplayName ( "Given account does not exist When user adds a new account" ) class AddNewAccountServiceTest { // Fields..... @BeforeEach void setup() { AddNewAccountService accountService = new AddNewAccountService(saveAccountPort); AddNewAccountCommand command = AddNewAccountCommand.builder() .accountName(ACCOUNT_NAME) .initialBalance(INITIAL_BALANCE) .userId(USER_ID) .build(); accountService.addNewAccount(command); BDDMockito.then(saveAccountPort).should().saveAccount(accountArgumentCaptor.capture()); savedAccount = accountArgumentCaptor.getValue(); } // Remaining Tests..... } |
If I now run the tests in my IDEA, this is what I see –
When we try to read the test descriptions in this view we can already get a good overview of the Add New Account use case and the way it works.
Right, let’s move on the the second scenario of our use case, which is a validation rule
Given account does not exist
When user adds a new account with negative initial balance
Then add new account fails
Let’s write a new test which tries to capture this –
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 | @ExtendWith (MockitoExtension. class ) @DisplayName ( "Given account does not exist When user adds a new account" ) class AddNewAccountServiceTest { // Other tests @Test @DisplayName ( "Given account does not exist When user adds a new account with negative initial balance Then add new account fails" ) void addNewAccountFailsWithNegativeInitialBalance() { AddNewAccountService accountService = new AddNewAccountService(saveAccountPort); AddNewAccountCommand command = AddNewAccountCommand.builder().initialBalance( "-56.0" ).build(); accountService.addNewAccount(command); BDDMockito.then(saveAccountPort).shouldHaveNoInteractions(); } } |
There are several ways we can implement validations in our service. We could throw an exception detailing the validation failures, or we could return an error object which would contain the error details. For this example we will throw exceptions if validation fails –
01 02 03 04 05 06 07 08 09 10 11 | @Test @DisplayName ( "Given account does not exist When user adds a new account with negative initial balance Then add new account fails" ) void addNewAccountFailsWithNegativeInitialBalance() { AddNewAccountService accountService = new AddNewAccountService(saveAccountPort); AddNewAccountCommand command = AddNewAccountCommand.builder().initialBalance( "-56.0" ).build(); assertThatExceptionOfType(IllegalArgumentException. class ) .isThrownBy(() -> accountService.addNewAccount(command)); BDDMockito.then(saveAccountPort).shouldHaveNoInteractions(); } |
This test verifies that an exception is thrown when the addNewAccount method is invoked with a negative balance. It also ensures that in such cases our code does not invoke any method of the SaveAccountPort. Before we can start modifying our service to make this test pass, we have to refactor our test setup code a bit. This is because during one of our previous refactoring we moved our common test setup code into a single method which now runs before each test –
01 02 03 04 05 06 07 08 09 10 11 12 | @BeforeEach void setup() { AddNewAccountService accountService = new AddNewAccountService(saveAccountPort); AddNewAccountCommand command = AddNewAccountCommand.builder() .accountName(ACCOUNT_NAME) .initialBalance(INITIAL_BALANCE) .userId(USER_ID) .build(); accountService.addNewAccount(command); BDDMockito.then(saveAccountPort).should().saveAccount(accountArgumentCaptor.capture()); savedAccount = accountArgumentCaptor.getValue(); } |
This setup code is now in direct conflict with the new test that we’ve just added – before each test it will always invoke the addNewAccount method with a valid command object, resulting in an invocation of the saveAccount method of the SaveAccountPort, causing our new test to fail.
In order to fix this, we will create a nested class within our test class where we will move our existing setup code and the passing tests –
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 | @ExtendWith (MockitoExtension. class ) @DisplayName ( "Given account does not exist" ) class AddNewAccountServiceTest { @Mock private SaveAccountPort saveAccountPort; private AddNewAccountService accountService; @BeforeEach void setUp() { accountService = new AddNewAccountService(saveAccountPort); } @Nested @DisplayName ( "When user adds a new account" ) class WhenUserAddsANewAccount { private static final String ACCOUNT_NAME = "test account" ; private static final String INITIAL_BALANCE = "56.0" ; private static final String USER_ID = "some id" ; private Account savedAccount; @Captor private ArgumentCaptor<Account> accountArgumentCaptor; @BeforeEach void setUp() { AddNewAccountCommand command = AddNewAccountCommand.builder() .accountName(ACCOUNT_NAME) .initialBalance(INITIAL_BALANCE) .userId(USER_ID) .build(); accountService.addNewAccount(command); BDDMockito.then(saveAccountPort).should().saveAccount(accountArgumentCaptor.capture()); savedAccount = accountArgumentCaptor.getValue(); } @Test @DisplayName ( "Then added account has the given name" ) void accountAddedWithGivenName() { BDDAssertions.then(savedAccount.getName()).isEqualTo(ACCOUNT_NAME); } @Test @DisplayName ( "Then added account has the given initial balance" ) void accountAddedWithGivenInitialBalance() { BDDAssertions.then(savedAccount.getBalance()).isEqualTo( new BigDecimal(INITIAL_BALANCE)); } @Test @DisplayName ( "Then added account has user's id" ) void accountAddedWithUsersId() { BDDAssertions.then(accountArgumentCaptor.getValue().getUserId()).isEqualTo(USER_ID); } } @Test @DisplayName ( "When user adds a new account with negative initial balance Then add new account fails" ) void addNewAccountFailsWithNegativeInitialBalance() { AddNewAccountCommand command = AddNewAccountCommand.builder() .initialBalance( "-56.0" ) .build(); assertThatExceptionOfType(IllegalArgumentException. class ) .isThrownBy(() -> accountService.addNewAccount(command)); BDDMockito.then(saveAccountPort).shouldHaveNoInteractions(); } } |
Here are the refactoring steps that we took –
- We created an inner class and then marked the inner class with JUnit 5’s @Nested annotation.
- We broke down the @DisplayName label of the outermost test class and moved the “When user adds a new account” part to the newly introduced inner class. The reason we did this is because this inner class will contain the group of tests that will verify/validate behaviours related to a valid account creation scenario.
- We moved related setup code and fields/constants into this inner class.
- We have removed the “Given account does not exist” part from our new test. This is because the @DisplayName on the outermost test class already includes this, hence no point including it here again.
This is how the tests now look like when I run them in my IntelliJ IDEA –
As we can see from the screenshot, our test labels are also grouped and indented nicely following the structure that we created in our test code. Let’s modify our service now to make the failing test pass –
01 02 03 04 05 06 07 08 09 10 11 12 13 | void addNewAccount(AddNewAccountCommand command) { BigDecimal initialBalance = new BigDecimal(command.getInitialBalance()); if (initialBalance.compareTo(BigDecimal.ZERO) < 0 ) { throw new IllegalArgumentException( "Initial balance of an account cannot be negative" ); } saveAccountPort.saveAccount( new Account( command.getAccountName(), initialBalance, command.getUserId() ) ); } |
With that all of our tests start passing again. Next step is to look for ways to improve the existing implementation if possible. If not, then we will move on to the implementation of the final scenario which is also a validation rule –
Given account with the same name exists
When user adds a new account
Then add new account fails
As always, let’s write a test to capture this –
01 02 03 04 05 06 07 08 09 10 11 12 13 | @Test @DisplayName ( "Given account with the same name exists When user adds a new account Then add new account fails" ) void addNewAccountFailsForDuplicateAccounts() { AddNewAccountCommand command = AddNewAccountCommand.builder() .accountName( "existing name" ) .build(); AddNewAccountService accountService = new AddNewAccountService(saveAccountPort); assertThatExceptionOfType(IllegalArgumentException. class ) .isThrownBy(() -> accountService.addNewAccount(command)); BDDMockito.then(saveAccountPort).shouldHaveNoInteractions(); } |
First thing we have to figure out now is to how to find an existing account. Since this will involve querying our persistent data store, we will introduce an interface –
1 2 3 4 | public interface FindAccountPort { Account findAccountByName(String accountName); } |
and inject it into our AddNewAccountService –
1 2 3 4 5 6 7 8 | @RequiredArgsConstructor class AddNewAccountService { private final SaveAccountPort saveAccountPort; private final FindAccountPort findAccountPort; // Rest of the code } |
and modify our test –
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 | @Test @DisplayName ( "Given account with the same name exists When user adds a new account Then add new account fails" ) void addNewAccountFailsForDuplicateAccounts() { String existingAccountName = "existing name" ; AddNewAccountCommand command = AddNewAccountCommand.builder() .initialBalance( "0" ) .accountName(existingAccountName) .build(); given(findAccountPort.findAccountByName(existingAccountName)).willReturn(mock(Account. class )); AddNewAccountService accountService = new AddNewAccountService(saveAccountPort, findAccountPort); assertThatExceptionOfType(IllegalArgumentException. class ) .isThrownBy(() -> accountService.addNewAccount(command)); BDDMockito.then(saveAccountPort).shouldHaveNoInteractions(); } |
The last change to our AddNewAccountService will also require changes to our existing tests, mainly the place where we were instantiating an instance of that class. We will, however, change a bit more than that –
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 | @ExtendWith (MockitoExtension. class ) class AddNewAccountServiceTest { @Mock private SaveAccountPort saveAccountPort; @Mock private FindAccountPort findAccountPort; @Nested @DisplayName ( "Given account does not exist" ) class AccountDoesNotExist { private AddNewAccountService accountService; @BeforeEach void setUp() { accountService = new AddNewAccountService(saveAccountPort, findAccountPort); } @Nested @DisplayName ( "When user adds a new account" ) class WhenUserAddsANewAccount { private static final String ACCOUNT_NAME = "test account" ; private static final String INITIAL_BALANCE = "56.0" ; private static final String USER_ID = "some id" ; private Account savedAccount; @Captor private ArgumentCaptor<Account> accountArgumentCaptor; @BeforeEach void setUp() { AddNewAccountCommand command = AddNewAccountCommand.builder() .accountName(ACCOUNT_NAME) .initialBalance(INITIAL_BALANCE) .userId(USER_ID) .build(); accountService.addNewAccount(command); BDDMockito.then(saveAccountPort).should().saveAccount(accountArgumentCaptor.capture()); savedAccount = accountArgumentCaptor.getValue(); } @Test @DisplayName ( "Then added account has the given name" ) void accountAddedWithGivenName() { BDDAssertions.then(savedAccount.getName()).isEqualTo(ACCOUNT_NAME); } @Test @DisplayName ( "Then added account has the given initial balance" ) void accountAddedWithGivenInitialBalance() { BDDAssertions.then(savedAccount.getBalance()).isEqualTo( new BigDecimal(INITIAL_BALANCE)); } @Test @DisplayName ( "Then added account has user's id" ) void accountAddedWithUsersId() { BDDAssertions.then(accountArgumentCaptor.getValue().getUserId()).isEqualTo(USER_ID); } } @Test @DisplayName ( "When user adds a new account with negative initial balance Then add new account fails" ) void addNewAccountFailsWithNegativeInitialBalance() { AddNewAccountCommand command = AddNewAccountCommand.builder() .initialBalance( "-56.0" ) .build(); assertThatExceptionOfType(IllegalArgumentException. class ) .isThrownBy(() -> accountService.addNewAccount(command)); BDDMockito.then(saveAccountPort).shouldHaveNoInteractions(); } } @Test @DisplayName ( "Given account with the same name exists When user adds a new account Then add new account fails" ) void addNewAccountFailsForDuplicateAccounts() { String existingAccountName = "existing name" ; AddNewAccountCommand command = AddNewAccountCommand.builder() .initialBalance( "0" ) .accountName(existingAccountName) .build(); given(findAccountPort.findAccountByName(existingAccountName)).willReturn(mock(Account. class )); AddNewAccountService accountService = new AddNewAccountService(saveAccountPort, findAccountPort); assertThatExceptionOfType(IllegalArgumentException. class ) .isThrownBy(() -> accountService.addNewAccount(command)); BDDMockito.then(saveAccountPort).shouldHaveNoInteractions(); } } |
Here’s what we did –
- We created another inner class, marked it as @Nested, and moved our existing passing tests into this. This group of tests test the behaviour of adding a new account when no account with the given name already exists.
- We have moved our test set up code into the newly introduced inner class as they are also related to the “no account with the given name already exists” case.
- For the same reason as above, we have also moved our @DisplayName annotation from the top level test class to the newly introduced inner class.
After our refactoring we quickly run our tests to see if everything is working as expected (failing test failing, passing tests passing), and then move on to modify our service –
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | @RequiredArgsConstructor class AddNewAccountService { private final SaveAccountPort saveAccountPort; private final FindAccountPort findAccountPort; void addNewAccount(AddNewAccountCommand command) { BigDecimal initialBalance = new BigDecimal(command.getInitialBalance()); if (initialBalance.compareTo(BigDecimal.ZERO) < 0 ) { throw new IllegalArgumentException( "Initial balance of an account cannot be negative" ); } if (findAccountPort.findAccountByName(command.getAccountName()) != null ) { throw new IllegalArgumentException( "An account with given name already exists" ); } saveAccountPort.saveAccount( new Account( command.getAccountName(), initialBalance, command.getUserId() ) ); } @Builder @Getter static class AddNewAccountCommand { private final String userId; private final String accountName; private final String initialBalance; } } |
All of our tests are now green –
Since our use case implementation is now complete, we will look at our implementation for one last time and see if we can improve anything. If not, our use case implementation is now complete!
To summarise, this is what we did throughout this article –
- We have written down a use case that we would like to implement
- We have added a failing test, labelling it with a human-readable name
- We have added the minimal amount of code needed to make the failing test pass
- As soon as we had more than one passing tests, after we made each failing test pass, we looked at our implementation and tried to improve it
- When writing the tests we tried writing them in such a way so that our use case specifications are reflected in the test code. For this we have used –
- The @DisplayName annotation to assign human-readable names to our tests
- Used @Nested to group related tests in a hierarchical structure, reflecting our use case setup
- Used BDD-driven API from Mockito and AssertJ to verify the expected behaviours
When should we follow this style of writing automated tests? The answer to this question is the same as every other usage questions in Software Engineering – it depends. I personally prefer this style when I am working with an application which has complex business/domain rules, which is intended to be maintained over a long period, for which a close collaboration with the business is required, and many other factors (i.e., application architecture, team adoption etc.).
As always, the full working example has been pushed to Github.
Until next time!
Published on Java Code Geeks with permission by Sayem Ahmed, partner at our JCG program. See the original article here: Clean Code from the Trenches – Writing Executable Specifications with JUnit 5, Mockito, and AssertJ Opinions expressed by Java Code Geeks contributors are their own. |