Skip to main content

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 -
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 -
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 -
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 -
@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 -
interface SaveAccountPort {

  void saveAccount(Account account);
}
The AddNewAccountService will be injected with an implementation of this interface via constructor injection -
@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 -
@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 -
@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 -
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 -
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 -
@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 -
@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 -
@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 -
@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 -
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 -
@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 -
  1. Add a failing test while making sure existing tests keep passing
  2. Make the failing test pass
  3. 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 -
@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 -
@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 -
@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 -
@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 -
@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 -
@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 -
  1. We created an inner class and then marked the inner class with JUnit 5's @Nested annotation.
  2. 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.
  3. We moved related setup code and fields/constants into this inner class.
  4. 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 -
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 -
@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 -
public interface FindAccountPort {

  Account findAccountByName(String accountName);
}
and inject it into our AddNewAccountService -
@RequiredArgsConstructor
class AddNewAccountService {

  private final SaveAccountPort saveAccountPort;
  private final FindAccountPort findAccountPort;
  
  // Rest of the code
}
and modify our test -
@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 -
@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 -
  1. 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.
  2. 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.
  3. 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 -
@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 -
  1. We have written down a use case that we would like to implement
  2. We have added a failing test, labelling it with a human-readable name
  3. We have added the minimal amount of code needed to make the failing test pass
  4. 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
  5. 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 -
    1. The @DisplayName annotation to assign human-readable names to our tests
    2. Used @Nested to group related tests in a hierarchical structure, reflecting our use case setup
    3. 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!

Comments

  1. Nice article! I think having 2 example would have been enough as I think you could see all the important things in 2 of them but overall I like your approach of creating tests and then starting to improve your code a lot. Also I learned some new things about JUnit 5 (e.g. @DisplayName) so thanks for that!
    Keep on doing this please.
    Dennis

    ReplyDelete

Post a Comment