Writing Extremely Readable JUnit Tests in Java
Imagine: you’ve written a JUnit test for a Java class and followed several best practices:
- Divide the test into a setup, execute and verify phase.
- Choose perfect names for methods and variables.
- Refactored the code to make it more testable.
- Applied separation of concerns and only test one thing in a unit test.
- Use mocks to limit the scope of the test.
And yet, when you return after a couple of weeks you find the test unreadable and need to spend several minutes trying to understand it.
In this case consider creating a micro DSL (Domain Specific Language) for your unit test. The DSL needs to use common words and verbs defined as methods which can be chained to create a readable sentence which describes a case in the unit test.
The general form of such a micro DSL will look like:
Creating chainable words and verbs can be achieved by creating private methods in the test class that return ‘this’.
Example
The provided example is written in Java17 and JUnit5. The maven compiler plugin is configured to compile towards Java17 an junit-jupiter is added as dependency in the pom.xml.
Note: The micro DSL approach for writing JUnit tests can be applied to other Java versions and testing frameworks as well.
Use case: Logic needs to be created for a plant watering system that decides if plants needs water based on the current time and humidity:
Plants should only be watered after 7PM and only if the humidity has dropped below 50%.
The logic is contained in a class PlantWateringSystem on which a method can be invoked: boolean plantsNeedWater() which indicates if plants need to be watered.
To keep things simple the current time and humidity can be set on the system with setters setHour(int) and setHumidity(float)
Edge cases are tested with 2 tests:
- test 1 will verify if plants are only watered after 7PM.
- test 2 will verify if plants are only watered if the humidity drops below 50%.
Each test starts with when(), the implementation of this method can be used to reset the system. In case mocks are used this is the time to recreate them to ensure no state is left from a previous test. A new instance of PlantWateringSystem is created to ensure no state is left from a previous test.
Methods hourIs(), andHumidityIs() are setting the current values for the hour and humidity and store them in the global instance of PlantWateringSystem.
The method then() will execute the method plantsNeedWater()and store the result as a global variable plantsAreWatered.
The method plantsAreWatered(boolean) will verify if the result corresponds to the expected value. If desired multiple methods can be crafted to check for a correct outcome.
Considerations when to use and not to use micro DSL in JUnit tests
Not all code should be tested with micro DSL in JUnit tests.
Use it in cases where:
- The logic depends on multiple input criteria.
- When multiple output states need to be tested.
- A multitude of edge cases need to be tested.