Get started Unit Testing using JUnit
Get started Unit Testing using JUnit
According to a Cambridge University study, around 50% of a developer’s time is spent debugging. One thing that can help reduce this effort is Unit Testing.
Unit testing involves testing smaller software components to see if they are working according to the specification. This is done before being integrated with other components of the software. The cost to fix a bug increases substantially with every phase (as shown in the diagram below) and hence it’s critical to find bugs as early as possible in the software life cycle. As unit testing is performed in the development stage, by the developers, the cost required to fix defects is minimal. The defects caught unit testing are easier to locate and relatively easier to fix.
Understand the need for unit testing
Understand basic unit testing with JUnit
Understand different JUnit methods
Understand JUnit annotations
Understand unit testing best practices
Get started Unit Testing using JUnit
According to a Cambridge University study, around 50% of a developer’s time is spent debugging. One thing that can help reduce this effort is Unit Testing.
Unit testing involves testing smaller software components to see if they are working according to the specification. This is done before being integrated with other components of the software. The cost to fix a bug increases substantially with every phase (as shown in the diagram below) and hence it’s critical to find bugs as early as possible in the software life cycle. As unit testing is performed in the development stage, by the developers, the cost required to fix defects is minimal. The defects caught unit testing are easier to locate and relatively easier to fix.
Understand the need for unit testing
Understand basic unit testing with JUnit
Understand different JUnit methods
Understand JUnit annotations
Understand unit testing best practices
~/workspace/bytes/
directory and cd
to it
mkdir -p ~/workspace/bytes/
cd ~/workspace/bytes/
~/workspace/bytes/
directory from here using one of the following commands:
git clone https://gitlab.crio.do/crio_bytes/me_junit.git
git clone git@gitlab.crio.do:crio_bytes/me_junit.git
Right click inside the App.java file and select Run, or
Click on Run on top of the main
method in VS Code, or
chmod +x ./gradlew
the first time to give executable permissions.
./gradlew clean run
You’ll see a similar output on the command line upon running the application. If the Selected option: text is shown, your application successfully started and now is waiting for input (1/2/3/4/9). Type 9 and hit Enter to exit the application.
testNewAdHandlerCreatesEmptyAdList()
) to run that unit test only./gradlew test --info
in the terminal from the project root directory to run all the tests under the src/test directoryUsing the ./gradlew
command will print the test output on the terminal. For the tests run using the other methods, a Java Test Report window will open. This shows you which tests failed/passed. Click on the failed test name to show the error stack trace.
JUnit 5 is used for the purpose of this Byte
The provided code is that of an OLX like application to view/post advertisements and allow buyers to schedule meetings with the seller. Let’s understand the project files better. Note that all attributes have getter and setter methods.
App.java - The starting point. It fetches input from users for now till our frontend team comes up with a GUI
main()
- run to start the application
showInitialMenu()
- displays menu options and reads input from user
addNewAd()
- uses AdHandler
class to add a new Ad
bookMeeting()
- uses AdHandler
class to book a new meeting
AdHandler.java - handler class which deals with adding new Ads and saving meetings
Has 4 attributes - list of all Ads, list of all scheduled meetings, a map of sellers (seller ID to seller object), list of supported Ad types
addNewAd()
- used to add a new Ad
addNewMeeting()
- used to add a new meeting
isValidMeetingTime()
- check if the meeting time provided by user falls in the specified meeting hours
readAdTypes()
- reads Ad types list from file
Seller.java - class used to handle info related to an Ad seller
Has 2 attributes - a unique ID and seller name
readSellerList()
- reads seller list from file
AdData.java - class used to handle information related to an Ad
Meeting.java - class used to handle info related to a scheduled meeting with the seller
AdHandlerTest.java - contains tests for methods in AdHandler.java
SellerTest.java - contains tests for methods in Seller.java
Allows to make changes to code easily as the tests will spot any bugs that new changes might introduce
Improves code quality as tests are written to check edge cases and writing tests also forces you to think about better implementation
Since unit testing is performed in the development stage itself, bugs can be found and resolved very early
Reduces development cost due to finding bugs before they reach production
Reduces time required to ship new code because previously written tests can be re-used
We'll look at some of these advantages practically in the next couple of sections.
Let’s look at how unit tests save debugging time.
Run the application and manually check if
Ads list is empty when the application starts (Enter 1 to View all Ads)
Ads list contains one entry when one new Ad is added (Enter 2 to Add new Ad)
Ads list gets populated correctly on adding multiple Ads (Try adding three Ads in total)
You’ll find that more than one Ad isn’t getting saved. Check the addNewAd()
method in the AdHandler.java file. You’ll find the Ad list is getting replaced by a new list every time a new Ad is added. Replace the below lines of code with this.allAdsList.add(adData);
List<AdData> list = new ArrayList<>();
list.add(adData);
this.allAdsList = list;
Run the application again and confirm that multiple Ads get saved successfully.
What did we just do here?
In order to test the implementation, we needed to run the application and manually enter all the details to check the functionality. Doing this manually every time is not possible.
./gradlew test --info
The three tests testNewAdHandlerCreatesEmptyAdList()
, testAddingSingleAdCreatesOneAd()
, testAddingMultipleAdsCreatesMultipleAd()
checks for the exact three cases you manually checked earlier. Run the test class to run all the tests.
You’ll get a Test Report window showing one test failed. If you use the Gradle command, a similar stack trace will be present in the terminal output.
The testAddingMultipleAdsCreatesMultipleAd
test has failed. The test expected an output of 3 (3 Ads) but found only 1. Now, make the required fix in the addNewAd()
method again and run the tests.
You will find that debugging was much faster and less tedious with unit tests in place.
Use Ctrl+P
to search files in VS code
Errors to previously functioning code due to new changes are called Regression errors.
Let’s add a new functionality to upload images related to an Ad. We’ll use an interface to support more file types like PDF, Doc etc. in the future. Create files Scanner.java and ImageScanner.java with the following code.
// File: Scanner.java
package project;
public interface Scanner {
public String uploadData();
}
// File: ImageScanner.java
package project;
public class ImageScanner implements Scanner {
public String uploadData() {
return "Uploaded Ad image";
}
}
Make these changes to the AdHandler.java file
package project;
line)
import project.Scanner;
import project.ImageScanner;
addImageToAd()
which uses the ImageScanner
class
public static String addImageToAd(String imageURI) {
Scanner imageScanner = new ImageScanner();
return imageScanner.uploadData();
}
Uncomment the testAddImageToAd()
method in AdHandlerTest.java which tests the new addImageToAd()
method. Run the tests now. Select Proceed if you get the below pop-up.
The Test Report will look like this. (If you used ./gradlew test
to run the tests, the tests won’t be run due to the error)
All the tests that had passed earlier are now failing. As you’d have found out by now, there’s a conflict between the in-built Scanner
class used for reading data and the new Scanner
interface added. Without unit tests in place, we may skip the testing for older methods and test only the newly added methods. Scenarios like these are pretty common when multiple developers work together on a project as not everyone knows about all the classes.
With unit tests, we can implement new functionality or refactor code without worrying about breaking existing functionality unintentionally. This is because the existing functionality will get tested and any regression errors will be caught. We can fix the errors before delivering the refactored code.
Delete the Scanner.java, ImageScanner.java files. Also, remove the newly added imports in AdHandler.java, delete/comment out the AdHandler::addImageToAd()
and AdHandlerTest::testAddImageToAd()
methods. We won’t need this for the following tasks.
Unit testing is a crucial part of the software development lifecycle. Though it might feel like an additional burden at first, you’ll soon find it to be a life-saver.
The isValidMeetingTime()
method in the AdHandler.java file checks if the time passed in as an argument falls in these valid intervals → 10AM - 12PM, 2PM-3PM and 6PM-8PM. Let’s check the unit test for this method. In AdHandlerTest.java, uncomment the test5PMIsValidMeetingTime()
method. You’ll see the test has
JUnit @Test
annotation, which is used to mark the test5PMIsValidMeetingTime()
method as a test
Setup required to call the isValidMeetingTime()
method - create an AdHandler()
instance and to check if time is valid
A call to the method to test ( isValidMeetingTime()
) and a way to save the output
The assertEquals
method to check whether the output matches the required value - here false
@Test
public void test5PMIsValidMeetingTime() {
// * - For meetings hours: 10AM - 12PM, 2PM-3PM, 6PM-8PM
// Given
adHandler = new AdHandler();
LocalTime datetime = LocalTime.of(17, 0);
// When
boolean actual = adHandler.isValidMeetingTime(datetime);
// Then
assertEquals(false, actual);
}
Click on the Run option on top of test5PMIsValidMeetingTime()
to run this test only. The Test Report window won’t open if the test passed. If the test failed, the Report window will open up. (If you executed ./gradlew test
to run the tests, you’ll find a line TEST RESULT: SUCCESS in the test log on the terminal)
TODO - Similarly, create a couple of new tests to validate other meeting times, at least one should be a valid meeting time (eg: 7:59PM). Run and ensure all tests pass.
To uncomment multiple lines of code, select code to uncomment and enter "Ctrl + /"
The current isValidMeetingTime()
method implementation has repetitive code in the if-else conditions. Refactoring the code will improve the readability and code quality.
TODO -
Comment out the existing isValidMeetingTime()
method
Uncomment the isValidMeetingTimeRefactored()
and isBetween()
methods
Rename isValidMeetingTimeRefactored()
method to isValidMeetingTime()
The code looks much cleaner after refactoring. Run the tests to ensure the existing code didn’t break.
You’ll see the test5PMIsValidMeetingTime()
test failing.
TODO - Debug the issue to pass the tests (Refer to the hints if you get stuck)
TODO - Run the testSellersList()
test in SellerTest.java and fix the bug in readSellerList()
method
String
or the access modifier to private
. Does this make any difference? (Try running the tests without using gradle command)The assertEquals()
method is used in the tests to check the output of the method under test.
The above line of code checks if the value 3 matches the value returned by adHandler.getAllAdsList().size()
and fails the test if it doesn’t match.
It is a convention to provide the first argument as the expected value and the second the actual value returned by the method. The error message also makes more sense if this convention is followed.
JUnit provides assert statements other than the assertEquals()
used in the tests till now.
TODO -
Use assertTrue()
in the test5PMIsValidMeetingTime()
method instead of assertEquals()
to check if output is true
. Similarly use assertTrue()
/ assertFalse()
in any other test methods you’ve added to test the isValidMeetingTime()
method
Uncomment the testNewAdHandlerInitialisesMeetingList()
test in AdHandlerTest.java. Use assertNotNull()
method to check that the Meetings list is not null
Uncomment the testInvalidAdTypeReturnsException()
test in AdHandlerTest.java. Use assertThrows()
to verify that AdHandler::addNewAd()
throws a RuntimeException
when called with an invalid Ad type. Use assertNotEquals()
to check the error message length is not zero. (Comment out the type validation code in addNewAd()
to see the test failing if RuntimeException
isn’t thrown)
Find out how assertEquals
and assertSame
differ. In addition, find a scenario where assertSame
can be used.
How would you assert if some method completes execution within some predefined time?
Take a look at the tests in the AdHandlerTest.java file. Do you find anything common between the tests?
Apart from the JUnit constructs like the @Test
annotation, all the test methods contain this line at the beginning
adHandler = new AdHandler();
Each of the tests create a new AdHandler instance. Comment out the above line in these tests - testNewAdHandlerCreatesEmptyAdList()
, testAddingSingleAdCreatesOneAd()
, testAddingMultipleAdsCreatesMultipleAd()
and update the first line of code in the class to
private static AdHandler adHandler = new AdHandler();
Run the tests and you’ll see some of them failing. This is because the Ads added in one test get carried over to the others. So, a common AdHandler
initialisation wouldn’t work in this case.
JUnit provides the
@BeforeEach
annotation to mark methods to be run before each test method is run.
@AfterEach
annotation to mark methods to be run after each test method is run.
@BeforeAll
annotation to mark methods to be run once before starting to run the first test of the test class.
@AfterAll
annotation to mark methods to be run once after completing running the last test of the test class.
TODO -
Change the first line of code in the class back to private static AdHandler adHandler()
. Write a new method setup() in AdHandlerTest.java
annotate it with @BeforeEach
annotation
include the line adHandler = new AdHandler();
inside the method
verify if tests are passing now.
Similarly in SellerTest.java, ensure the Scanner
resource gets closed after all the tests are completed using the @AfterAll
annotation.
In JUnit 4, these annotations are named differently. For example,@AfterAll
will be @AfterClass
in JUnit 4
When we have a large number of tests, sometimes, we need to skip some unit tests that take a longer time. How would you do this using JUnit annotations?
You wrote multiple variants of the test5PMIsValidMeetingTime()
test earlier. If you check, the difference among these tests is just a single value, the input time. How would you use JUnit annotations to reuse the same test with different parameter values?
If the method to be tested contains system-dependent parameters like the current system time,
Tests can randomly pass or fail - If you check how the addNewAd()
method creates a new AdData
object, it uses the LocalDateTime.now()
method within it to fetch the current system date and time. Assume there’s some unresolved bug in the AdData
constructor. An exception is thrown due to this bug when the provided time is between 12 AM - 11:59 AM. Due to this, the tests will pass if run between 12 PM-11:59 PM but fails if the time the developer ran the test falls between 12 AM-11:59 AM.
Not all scenarios can be tested - To check the addNewAd()
method with some date and time of 3 AM, either the developer shouldn’t sleep or the system time needs to be changed back and forth when this test is to be run. This makes it very difficult to test, either manually or automatically.
public void addNewAd(String description, String type, int sellerID) throws FileNotFoundException {
// code
// more code…
AdData adData = new AdData(description, type, seller, LocalDateTime.now());
this.allAdsList.add(adData);
}
A better approach is to pass the date-time as parameter to the method. See the addNewAdInputLocalDateTime()
method which is commented out in the AdHandler
class.
public void addNewAdInputLocalDateTime(String description, String type, int sellerID, LocalDateTime localDateTime) throws FileNotFoundException {
// code
// more code…
AdData adData = new AdData(description, type, seller, localDateTime);
this.allAdsList.add(adData);
}
Find another example code here
When a test for the current addNewAd()
fails, what could be the reasons?
Test could fail due to
Description length check
Type check
Or the rest of the logic
This is because the description validation and type validation code isn’t tested separately. Due to how the code is written, this isn’t possible. If the method is refactored to include these validation checks as separate methods, both of them can be tested.
Is there some reason why the application logic is under the src/main directory and tests are under src/test directory? (Try moving the test file somewhere else)
In the upcoming version, we’ll be using the Maps API (costly service) to find the location of a user. How would you test the methods that depend on the API response?
Unit testing helps with the software development cycle by spotting bugs earlier as well as reducing testing time.
It is recommended to have one Test class with unit tests for every Java class that needs testing.
JUnit is a popular library for Unit Testing in Java
The test methods are annotated with the @Test
annotation
JUnit provides different assert statements like assertEquals
, assertTrue
, assertNull
to check if correct values are returned by methods
Annotations like BeforeAll
, BeforeEach
, AfterAll
, AfterEach
can be used to run setup code once per class or once per each test
Passing system specific code as parameters and breaking down code to smaller components helps improve the quality of unit tests.
Find the
Further Reading
You understand the need for Unit Testing
Practical know-how of the Java JUnit library
Write Unit Tests for your projects
Use the JUnit library with ease
Use JUnit annotations to improve tests
Utilise unit testing best practices to write better quality of code