Testing is one of the most critical steps in a software development lifecycle where we check that a software or an application does what it is supposed to do. Let us begin from the bottom of testing pyramid and dive into the world of unit testing.
So many developers still think that unit tests are a waste of time and cannot see their actual benefits. This is why I will show you how writing good tests makes your code more resilient to changes and more immune to bugs and discuss the best practices and recommendations as well as how to have a maintainable test and apply them to test java code.
Before we start, let's first see what the added value of unit testing is.
Why invest time in testing?
- 💰 To save resources:
The most obvious reason is to avoid bugs, or at least detect them in time because it is proven that the cost of fixing bugs increases exponentially with detection time.
Most defects end up costing more than it would have cost to prevent them. Defects are expensive when they occur, both the direct costs of fixing the defects and the indirect costs because of damaged relationships, lost business, and lost development time. — Kent Beck, Extreme Programming Explained
A study by the IBM Science Institute found that defects discovered during maintenance, already in production, costs 100 times more than if they were detected in design, compared with 15 times more during the testing phase.
- ☔ To have immunity to changes:
As developers, we are often tempted to improve the quality of our code, the implementation strategy of the features, or even to change the libraries used without changing the existing behavior of the application. However, we can never be sure that we haven't introduced regressions. This is why we need tests as warnings to alert us when the new code breaks the existing one, especially when we are dealing with a huge codebase.
- 😎 To feel confident:
A code well tested gives us more confidence since it implies that it works properly, doesn't contain bugs, and there is no use case that we haven't addressed.
- 📑 To provide documentation:
We can spend time writing the application's specifications and updating them. However, they will still become outdated, incomplete, or not too detailed, unlike the tests written at the time of implementation of the features, which are more reliable and a source of truth because they represent the proper behavior of the application.
- ⏱ To save time:
The most crucial advantage of unit tests over other types of tests is that they are fast. Since they are totally isolated, there is no need to wait minutes for them to be executed. Therefore, the duration of the pipeline is minimized. On the other hand, testing units is much less complex, saving us time when writing them and maintaining them.
Now that we tackled the why, let's define a good unit test in the next part.
How can we write better tests?
1- Test a single use case
A test needs to verify the behavior of one scenario. Indeed, if it fails, we automatically know where the problem comes from and fix it instead of having many potential reasons for failure. There is also an essential reason besides keeping your test simple and clean and it's the fact that you can never be sure your results are independent.
2- Well named test
// examples of bad namings
public void test() { ... }
public void testMethodName() { ... }
Tests, like variables and methods, need good naming to help us understand their purpose and maintain them without digging through the code. A good name explicitly declares the use case being tested as well as the expected result.
// example of a good naming
public void testMethodName_ShouldReturnXWhenY() { ... }
3- Follow AAA pattern
AAA: "arrange, act and assert" is a way to structure a test and break it down into three steps to make it readable and easy to maintain:
- Arrange step (setup): is where you set up the use case's requirements.
- Act step (operation): is where you call what you are testing.
- Assert step (result): is where you check that the test outputs are what you were expecting.
{
// arrange
User user = new User();
user.setId(56);
user.setActivated(true);
// act
User actual = service.deactivateUser(user);
// assert
assertFalse(actual.getActivated());
}
4- Avoid tests dependencies
A test should never depend on other tests to pass or fail because of them. Every test must run correctly alone or in a test suite, whatever the order of execution is. Therefore, they shouldn't write on the same objects.
{
// The tests are sharing counter
// The first that's going to be executed will pass
// The second will fail
private static int counter = 10;
@Test
public void testIncrementShouldAddOneToCounter(){
counter = incrementCounter(counter);
assertEquals(counter, 11);
}
@Test
public void testDecrementShouldSubstractOneFromCounter(){
counter = decrementCounter(counter);
assertEquals(counter, 9);
}
}
5- Test a maximum of cases
The point of writing tests is to make sure that the implementation works as expected under all circumstances. As a result, testing only the typical scenario is not considered to be proof of good functioning. This is why we need to test the edge scenarios as well, or at least, all business use cases. You can use the coverage to know the percentage of the code that has been tested.
6- Write a reliable test
// This test will fail if we execute it on the weekend
@Test
public void testIsDateAWeekendDayShouldReturnFalseWhenDayIsWeekday(){
Date date = new Date();
assertFalse(service.isDateAWeekendDay(date));
}
For a test to be meaningful, it is required that its result is trustworthy. In the same conditions, it should always have a similar outcome. In this regard, we need to eliminate any external dependency that can make our test non-deterministic (database connection, external API calls, other method invocation, ..).
// To make the test independent:
// Specify the prerequisites of the test or mock them
@Test
public void testIsDateAWeekendDayShouldReturnFalseWhenDayIsWeekday(){
Date date = new Date(2020, 11, 16); // we know it is a thursday
assertFalse(service.isDateAWeekendDay(date));
}
7- No logic in a test
No logic means no treatments must figure in the implementation of a test. And this rule implies reproducing a part of the method you are testing or even other treatments that are done to set up the test's prerequisites. There is a good chance the bug is also reproduced in the first case if the method contains a bug. In the second case, we might introduce a new bug in the added logic, which may hide the real reason for test failure. Always choose the KISS principle while testing (keep it simple, stupid!)
@Test
void testDeactivateUserAndCancelHisMeetings() {
User user = new User();
user.setId(10);
Meeting meeting = new Meeting();
meeting.setId(3);
meeting.setMeetingDate(new Date());
// setting the meeting status to CANCELED is part of the method job
meeting.setStatus("CANCELED");
List<Meeting> meetings = new ArrayList<>();
meetings.add(meeting);
user.setMeetings(meetings);
User actualUser = service.deactivateUserAndCancelHisMeetings(user);
assertFalse(user.isActivated());
// this assertion will always be true
// the meeting was canceled before the call of the method
assertEquals(user.getMeetings.get(1).getStatus(), "CANCELED");
}
8- Write tests while writing code
Test-driven development (TDD) is a software development technique in which we write tests before writing code to ensure that the written tests are independent of the implementation. If you're not a TDD fan, at least you should test your code while or just after writing it. Why? Since you still have the details of the feature in mind, you know exactly what to test without forgetting to address some use cases.
9- Test only public methods
So many times developers ask: how can we test a private method? Well, we don't. At least not directly. We actually pass through the public methods calling them. I know, you're often tempted to make them public to test them, but don't forget that private methods are details of implementation of the public ones.
To see all these recommendations in practice, we will test a simple java method using JUnit 5.
Unit testing with JUnit 5
Let's start with a simple example of a method that counts the occurrence of a substring in a string:
int countStringOccurrence(String string, String substring);
Let's start with the normal scenarios when the substring has occurred or is not found. Don't forget to give your tests an appropriate naming to be easy to read and maintain. If the name is too long, you can use the annotation @DisplayName.
@Test
public void countStringOccurrenceShouldReturn1WhenSubstringIsFoundOnce(){
String substring = "world";
String string = "hello world!";
int actual = StringService.countStringOccurrence(string, substring);
assertEquals(1, actual);
}
@Test
public void countStringOccurrenceShouldReturn0WhenSubstringIsNotFound(){
String substring = "error";
String string = "hello world!";
int actual = StringService.countStringOccurrence(string, substring);
assertEquals(0, actual);
}
For the sake of this article, we assume that the method has to be case insensitive, so this case should be tested as well:
@Test
public void countStringOccurrenceShouldReturnCountWhenCaseIsDifferent(){
String substring = "WorLd";
String string = "hello world!";
int actual = StringService.countStringOccurrence(string, substring);
assertEquals(1, actual);
}
Usually, methods throw exceptions for validation purposes or to reflect business rules. Like we said before, these edge cases need to be tested as well. In our case, the method countStringOccurrence we are testing should throw an IllegalArgumentException when one of the arguments is null, hence let's see how we can test this case.
@Test
public void countStringOccurrenceShouldThrowExceptionWhenSubstringIsNull(){
String substring = null;
String string = "hello world!";
assertThrows(IllegalArgumentException.class, () -> StringService.countStringOccurrence(string, substring));
}
Here assertThrows method makes sure that the execution of the given lambda expression throws an exception assignable to the expected type.
Conclusion
Now that we saw examples of unit testing methods, I hope you did notice that writing unit tests is not a waste of time or as complicated as it seems.
In fact, with little effort, the quality and stability of the product will remarkably increase since it is a proven way to detect defects much earlier. Which reduces the cost of their fixes, especially when dealing with a huge code base and complex features. This is why it is essential to adopt good practices while writing them and keep them as simple as possible to make them efficient, fast, and maintainable.