Unleash The True Potential Of JUnit5 And B.D.D.
Let’s write some JUnit5 tests in a B.D.D. (Behaviour-Driven Development) style. Hands-on, beginner-friendly article.
1. Overview
In this article, we’ll discuss some interesting features of JUnit5 that allow us to write tests in a BDD (Behaviour-Driven Development) style.
For the code examples in this article, we’ll implement a very simple BMI calculator. After that, we’ll see how to leverage JUnit5’s @ParameterizedTest, @CsvSource, and ArgumentConverter to test many scenarios in a very declarative way.
2. BMI Calculator Implementation
Firstly, let’s look at the implementation. We’ll have a BmiCalculator class with a static method for calculating the BMI:
public class BmiCalculator {
public static BmiHealthCategory calculate(Weight weight, Height height) {
// ...
}
}
As we can see from the function’s signature, it accepts as parameters a Weight and a Height object. They are small, immutable, objects that can expose factory methods for various units of measurement:
record Height(BigDecimal valueInMeters) {
private static final BigDecimal INCH_TO_METER = BigDecimal.valueOf(0.025d);
public static Height ofMeters(double valueInMeters) {
return new Height(BigDecimal.valueOf(valueInMeters));
}
public static Height ofPounds(double valueInInch) {
BigDecimal valueInKg = BigDecimal.valueOf(valueInInch).multiply(INCH_TO_METER);
return new Height(valueInKg);
}
}
Now, we can continue with the actual BMI calculation. The BMI can be calculated by dividing the value of the weight (in kg) by the square value of the height (in meters):
public static BmiHealthCategory calculate(Weight weight, Height height) {
BigDecimal squareHeight = height.valueInMeters().pow(2);
BigDecimal bmi = weight.valueInKg().divide(squareHeight, PRECISION, RoundingMode.CEILING);
return BmiHealthCategory.forBmi(bmi.doubleValue());
}
Finally, based on the BMI value, we can classify the subject into one of the 8 health categories. For this, we can use an enum:
@RequiredArgsConstructor
enum BmiHealthCategory {
SEVERE_THINNESS(Double.MIN_VALUE, 16d),
MODERATE_THINNESS(16d, 17d),
MILD_THINNESS(17d, 18.5d),
NORMAL(18.5d, 25d),
OVERWEIGHT(25d, 30d),
OBESE_CLASS_I(30d, 35d),
OBESE_CLASS_II(35d, 40d),
OBESE_CLASS_III(40d, Double.MAX_VALUE);
private final double min;
private final double max;
static BmiHealthCategory forBmi(double bmi) {
return stream(values())
.filter(category -> category.min < bmi)
.filter(category -> category.max > bmi)
.findFirst()
.orElseThrow();
}
}
PS: You can find the full source code at the bottom of the article.
3. JUnit5's Parameterized Tests
We can use JUnit5’s @ParameterizedTest to provide pairs of input values and expected outputs. This can be very useful for this kind of computation, where we need to check the outcomes for many different scenarios.
There are many ways to provide the parameters for the tests, but today we’ll focus on @CsvSource. By default, this allows us to specify an array of strings — each of these String objects will be used for a different test.
The Strings themselves should have comma-separated values. For example, if we want to use the @ParameterizedTest and @CsvValue to test the addition of two integer numbers, we can write something like this:
@ParameterizedTest
@CsvSource(value = {
"1,1,2",
"2,2,4",
"3,5,8",
"7,7,14"
})
void additionTest(int a, int b, int expectedSum) {
assertThat(a + b).isEqualTo(expectedSum);
}
Moreover, we can configure @CsvSource to accept a different character as the delimiter, instead of the “,”.
Let’s update the additionTest and change the delimiter to a “|”. This will make the test parameters look like a small table:
@ParameterizedTest
@CsvSource(value = {
"1|1|2",
"2|2|4",
"3|5|8",
"7|7|14"
}, delimiter = '|')
void additionTest(int a, int b, int expectedSum) {
assertThat(a + b).isEqualTo(expectedSum);
}
Finally, we can use the name property of the @ParameterizedTest to provide a nice description for the test. When specifying it, we can use placeholders to reference the parameters of the test:
@ParameterizedTest(name = "{0} plus {1} should be equal to {2}")
@CsvSource(value = {
"1|1|2",
"2|2|4",
"3|5|8",
"7|7|14"
}, delimiter = '|')
void additionTest(int a, int b, int expectedSum) {
assertThat(a + b).isEqualTo(expectedSum);
}
4. BDD Style Tests For The BMI Calculator
Let’s apply what we’ve learned to our BMI calculator for the following scenario:
GIVEN a subject with a height of 1.75m
WHEN weight is {input_weight} kg
THEN subject is {expected_category}
After a first try, we can come up with something like this:
@DisplayName("GIVEN a subject with a height of 1.75m")
@ParameterizedTest(name = "WHEN weight is {0}kg, THEN subject is {1}")
@CsvSource(value = {
"55|MILD_THINNESS",
"70|NORMAL",
"80|OVERWEIGHT"
}, delimiter = '|')
void shouldCalculateCategoryForAGivenHeight(
double weightInput, String expectedOutput) {
//given
var height = Height.ofMeters(1.75d);
//when
var weight = Weight.ofKg(weightInput);
var category = BmiCalculator.calculate(weight, height);
//then
BmiHealthCategory expectedCategory = BmiHealthCategory.valueOf(expectedOutput);
assertThat(category).isEqualTo(expectedCategory);
}
Though, we can ‘steal’ a few more ideas from the BDD approach. For instance, we can try to make the test less imperative.
At this point, the test knows how to do the conversions to Weight and BmiHealthCategory. Moreover, the specification is directly coupled to the BmiHealthCategory enum.
When using Behaviour Driven Development, specifications should know as little as possible (or nothing at all) about the implementation. They need to be declarative and use business-related terms.
We can break this direct coupling by using JUint5’s built-in feature, the ArgumentConverters.
5. Argument Converters
We can implement the ArgumentConverter interface to convert the parameters of our parameterized tests.
For example, the following converter maps the String input from the specification to an internal Weight object:
static class WeightInKgConverter implements ArgumentConverter {
@Override
public Object convert(Object source, ParameterContext context) throws ArgumentConversionException {
if (source instanceof String kgs) {
return Weight.ofKg(Double.valueOf(kgs));
}
throw new IllegalArgumentException("The argument should be a double: " + source);
}
}
To use it, we need to annotate the test parameter with @ConvertWith and provide the class name:
@DisplayName("GIVEN a subject with a height of 1.75m")
@ParameterizedTest(name = "WHEN weight is {0}kg, THEN subject is {1}")
@CsvSource(value = {
"55|MILD_THINNESS",
"70|NORMAL",
"80|OVERWEIGHT"
}, delimiter = '|')
void shouldCalculateCategoryForAGivenHeight(
@ConvertWith(WeightInKgConverter.class) Weight weight,
String expectedOutput)
// ....
}
As a result, if we want to use a more business-related term for the BmiHealthCategory in the specification, we can create a converter that knows how to map it to our internal enum. For demonstration purposes, let’s use title-case strings with no underscore:
static class BmiHealthCategoryConverter implements ArgumentConverter {
@Override
public Object convert(Object source, ParameterContext context) throws ArgumentConversionException {
if (source instanceof String category) {
String categoryName = category.replaceAll(" ", "_").toUpperCase();
return BmiHealthCategory.valueOf(categoryName);
}
throw new IllegalArgumentException("The argument should be a String: " + source);
}
}
6. Final Result And Conclusions
Finally, let’s put everything together and take a look at the result:
@DisplayName("GIVEN a subject with a height of 1.75m")
@ParameterizedTest(name = "WHEN weight is {0}kg, THEN subject is {1}")
@CsvSource(value = {
"40|Severe Thinness",
"50|Moderate Thinness",
"55|Mild Thinness",
"70|Normal",
"80|Overweight",
"100|Obese Class I",
"120|Obese Class II",
"140|Obese Class III"
}, delimiter = '|')
void shouldCalculateCategoryForAGivenHeight(
@ConvertWith(WeightInKgConverter.class) Weight weight,
@ConvertWith(BmiHealthCategoryConverter.class) BmiHealthCategory expectedCategory) {
//given
var givenHeight = Height.ofMeters(1.75d);
//when
var category = BmiCalculator.calculate(weight, givenHeight);
//then
assertThat(category).isEqualTo(expectedCategory);
}
In this article, we’ve discussed JUnit5’s features that allow us to write nice, parameterized tests. We’ve learned about the @ParameterizedTest annotation itself, about the @CsvSource, and how to create the custom ArgumentConverter.
Moreover, we touched on best practices when it comes to BDD specifications and we learned it’s best to keep the test agnostic from implementation details.
Thank You!
Thanks for reading the article and please let me know what you think! Any feedback is welcome.
If you want to read more about clean code, design, unit testing, functional programming, and many others, make sure to check out my other articles. Do you like the content? Consider following or subscribing to the email list.
Finally, if you consider becoming a Medium member and supporting my blog, here’s my referral.
Happy Coding!
As promised, here is the full source code:
public class BmiCalculatorTest {
@DisplayName("GIVEN a subject with a height of 1.75m")
@ParameterizedTest(name = "WHEN weight is {0}kg, THEN subject is {1}")
@CsvSource(value = {
"40|Severe Thinness",
"50|Moderate Thinness",
"55|Mild Thinness",
"70|Normal",
"80|Overweight",
"100|Obese Class I",
"120|Obese Class II",
"140|Obese Class III"
}, delimiter = '|')
void shouldCalculateCategoryForAGivenHeight(
@ConvertWith(WeightInKgConverter.class) Weight weight,
@ConvertWith(BmiHealthCategoryConverter.class) BmiHealthCategory expectedCategory) {
//given
var givenHeight = Height.ofMeters(1.75d);
//when
var category = BmiCalculator.calculate(weight, givenHeight);
//then
assertThat(category).isEqualTo(expectedCategory);
}
static class WeightInKgConverter implements ArgumentConverter {
@Override
public Object convert(Object source, ParameterContext context) throws ArgumentConversionException {
if (source instanceof String kgs) {
return Weight.ofKg(Double.valueOf(kgs));
}
throw new IllegalArgumentException("The argument should be a double: " + source);
}
}
static class BmiHealthCategoryConverter implements ArgumentConverter {
@Override
public Object convert(Object source, ParameterContext context) throws ArgumentConversionException {
if (source instanceof String category) {
String categoryName = category.replaceAll(" ", "_").toUpperCase();
return BmiHealthCategory.valueOf(categoryName);
}
throw new IllegalArgumentException("The argument should be a String: " + source);
}
}
}
public class BmiCalculator {
private final static int PRECISION = 3;
public static BmiHealthCategory calculate(Weight weight, Height height) {
BigDecimal squareHeight = height.valueInMeters().pow(2);
BigDecimal bmi = weight.valueInKg().divide(squareHeight, PRECISION, RoundingMode.CEILING);
return BmiHealthCategory.forBmi(bmi.doubleValue());
}
@RequiredArgsConstructor
enum BmiHealthCategory {
SEVERE_THINNESS(Double.MIN_VALUE, 16d),
MODERATE_THINNESS(16d, 17d),
MILD_THINNESS(17d, 18.5d),
NORMAL(18.5d, 25d),
OVERWEIGHT(25d, 30d),
OBESE_CLASS_I(30d, 35d),
OBESE_CLASS_II(35d, 40d),
OBESE_CLASS_III(40d, Double.MAX_VALUE);
private final double min;
private final double max;
static BmiHealthCategory forBmi(double bmi) {
return stream(values())
.filter(category -> category.min < bmi)
.filter(category -> category.max > bmi)
.findFirst()
.orElseThrow();
}
}
record Weight(BigDecimal valueInKg) {
private static final BigDecimal POUNDS_TO_KG = BigDecimal.valueOf(0.453d);
public static Weight ofKg(double valueInKg) {
return new Weight(BigDecimal.valueOf(valueInKg));
}
public static Weight ofPounds(double valueInPounds) {
BigDecimal valueInKg = BigDecimal.valueOf(valueInPounds).multiply(POUNDS_TO_KG);
return new Weight(valueInKg);
}
}
record Height(BigDecimal valueInMeters) {
private static final BigDecimal INCH_TO_METER = BigDecimal.valueOf(0.025d);
public static Height ofMeters(double valueInMeters) {
return new Height(BigDecimal.valueOf(valueInMeters));
}
public static Height ofPounds(double valueInInch) {
BigDecimal valueInKg = BigDecimal.valueOf(valueInInch).multiply(INCH_TO_METER);
return new Height(valueInKg);
}
}
}