Tinashe
5 min readSep 8, 2023

--

Developers write tests: #unit-testing; #quality;

I’ve had the privilege of being in the testing space for a little over 6 years now. Looking back, and at the present, I’ve observed that the most performant product, with the least amount of bugs, is a product that has a wide coverage of unit tests. Add E2E integration and UI tests to that mix and you’ve got a team that can deploy willy-nilly without breaking a sweat.

Tests have a far-reaching impact across the entire team; from the developers, to QA, to product managers, Scrum master and the sponsor. When there are little to no issues associated with each increment, the team can ideate, experiment and release features without the constant fear of; will this change break anything, will customers continue having a seamless experience etc.

To highlight the beauty of even the simplest of unit tests, I’ll go through a common use case. Let’s say we are building a user registration service as part of a microservice architecture. In the beginning, the product manager recommends an onboarding experience that has the least number of steps possible for our prospective customers. With that in mind, the requirement becomes:

  • As a customer, I want to provide a phone number only, so that I can create an account.

The developer starts off with the below function:

public Users registerUser(Users user){
if (user.getPhoneNumber() == null || user.getPhoneNumber().isEmpty()){
throw new IllegalArgumentException("Phone number cannot be empty.");
}
user.setCreated(LocalDateTime.ofInstant(Instant.now(), ZoneOffset.UTC));
return userRepository.save(user);
}jj

All this does is to check if the phone number has been provided (seasoned developers can already see why this cannot be production-ready code, but play along). The above is fair game based on the requirement. The QA comes in and puts in an invalid number, manages to register and raises a bug with the development team.

To address the bug, the registration function is refactored and leverages a popular library that validates phone numbers. On top of addressing the number validation, the PM decides we need the user to supply a password as part of registration. This is a typical flow in the product development lifecycle.

The registration function is refactored and includes the below block whilst the user entity no longer accepts empty/null values for the password.

Additional changes to the registration function

...
PhoneNumberUtil phoneNumberUtil = PhoneNumberUtil.getInstance();
Phonenumber.PhoneNumber zwNumber;
try {
zwNumber = phoneNumberUtil.parse(user.getPhoneNumber(), user.getPhoneNumber().substring(0, 4));
if (phoneNumberUtil.isValidNumber(zwNumber)){
// create the user
user.setCreated(LocalDateTime.ofInstant(Instant.now(), ZoneOffset.UTC));
return userRepository.save(user);
}
} catch (NumberParseException e) {
throw new RuntimeException(e);
}
throw new IllegalArgumentException("Invalid number, please check the number.");
...

The updated Users entity

@Entity
public class Users {
@Id
@GeneratedValue (strategy = GenerationType.IDENTITY)
private Integer id;
private String firstName, lastName;
@Nonnull @Column(nullable = false)
private String password;
@Nonnull @Column(unique = true, nullable = false)
private String phoneNumber;
@Column (updatable = false)
@JsonSerialize (using = CustomDateSerieliser.class)
@CreatedDate
private LocalDateTime created;
@JsonSerialize (using = CustomDateSerieliser.class)
@LastModifiedDate
private LocalDateTime modifiedDate;
....
}

Without unit tests, the developer cannot be confident that the changes they’ve made work as expected. This will likely lead to a back-and-forth with the QA as they test and push the ticket back to the development team if any issues are noted. Let’s rewind and start off with a basic test the developer could have written.

The Service test: This calls the user service function logic, mocks the registration and returns the user

@Test
public void userRegistrationReturnsUserTest(){
// given, arrange the data
Users user = Users.builder().
firstName("Test").
lastName("Users").
phoneNumber("+2637123456").
build();
// when, act on the data
when(userRepository.save(any(Users.class))).thenReturn(user);
// then, assert the outcome of the action
Users registeredUser = userRegistrationService.registerUser(user);
assertThat(registeredUser.getFirstName(), is(equalTo(user.getFirstName())));
}

In this initial test, the developer builds a user with an invalid phone number who doesn’t have a password. Following the update to the registration service, the function now requires a valid phone number — keep that in mind.

The Repository test: The test checks whether we can persist the user that we get from the service.

@Test
public void saveUserTest(){
// given, arrange the data
Users testUser = Users.builder().
firstName("Test").
phoneNumber("123456").
build();
// when, act on the data
entityManager.persistAndFlush(testUser);
Users savedUser = userRepository.findByPhoneNumber(testUser.getPhoneNumber());
// then, assert the result of the action
assert(savedUser.getFirstName().equals(testUser.getFirstName()));
}

Assuming the above is what the initial test looks like prior to the QA report, the above saves the user and gets the user based on the first name attribute. Following the changes requested by the product and the bug report, the user entity has been updated to ensure both phone number and password are not null or empty values when persisted to the DB.

Getting back to the latest changes, the refactored function and entity would definitely break the tests. Minor changes it would seem, but without tests, these changes may not be obvious to pick up (especially when multiple developers are working on the same repo — which is the essence of a team really).

On the Service test, the test would fail and highlight that the user provided does not have a valid phone number. On the Repository test, the test would fail and highlight that no password has been supplied. Fixing the failing tests helps the developer to think about the implementation they’ve worked on. This process also serves as a way of documenting the requirements within the repository. Updating the tests to ensure they pass and including other tests allows the developer to move ahead with confidence in the changes they’ve made.

In future, as the process matures, the developers should start by writing a failing test with the implementation written to make the test pass. This is possible when teams are given the autonomy to plan, build and deploy without the constant push to deliver on unrealistic timelines.

To close, if you want to move and deploy with confidence, you need to ensure that developers are writing unit tests. Given you’ve got a code base that is difficult to write tests for, this speaks to design and architectural flaws that need to be addressed as early as possible before the monolith grows out of control.

The full source code for this post can be found here on my GitHub page.

--

--