“He′s like a king without a crown
Bottle Living – Dave Gahan
He wears it like a clown
Watch him disappear
I wish he would come down“
In previous articles on JUnit 5, various mechanisms were presented with which the JUnit tests could be made clearer. In this article, the ArgumentsAggregator
is presented as an additional helpful mechanism.
JUnit tests should be concise and clear because they significantly improve the readability and maintainability of the code. If tests are concise and clearly structured, developers can understand more quickly what is being tested and which conditions must be met. This makes troubleshooting easier, as potential sources of errors can be identified more easily. In addition, a clear test structure promotes the reusability of test cases and helps to avoid redundancies.
I like to use ParameterResolvers
in my JUnit tests because they allow me to centralize the generation of test data and auxiliary constructs. The advantage of ParameterResolvers
over custom helper methods, utility classes or self-designed libraries is their cleaner and more elegant integration into the overall framework of JUnit5. The mechanisms do not need to be explained or extensively documented, as this can all be found in the JUnit5 documentation.
The use of ParameterResolver
unfortunately has a weakness, it does not work together with @ParameterizedTest
. The test methods annotated with @ParameterizedTest
are tests that are run with any number of data sets. So instead of writing several structurally identical tests, a single test with different parameter sets is sufficient.
@Test void generatedTaxUS() { assertEquals(25, taxCalculator.forProduct("Jack Daniels Old No.7", Locale.US, "1111"); } @Test void generatedTaxIR() { assertEquals(0, taxCalculator.forProduct("Bushmills 14 Years Malaga Cask Finish", new Locale("en-IR"), "2222"); } @Test void generatedTaxJP() { assertEquals(0, taxCalculator.forProduct("Akashi Red no age", Locale.JP, "3333"); }
Here are three current tests on the subject of duties on whisky. Some colleagues write all three tests in a single method. This is not a particularly good idea, because in the event of an error it is not immediately clear which tests all fail. However, to make the code a little more compact, you can write the tests as parameterized tests
@ParameterizedTest @CsvSource({ "25,Jack Daniels Old No.7, en-US, 1111", "0,Bushmills 14 Years Malaga Cask Finish, en-IR, 2222", "0,Akashi Red no age, ja-JP, 333" }) void generatedTax(int tax, String productName, Locale country, String orderNumber) { assertEquals(tax, taxCalculator.forProduct(productName, orderNumber); }
It becomes difficult when the TaxCalculator
requires parameters that are not supported by the parameterized test. In this case, the instances must be created in the test method from the test parameters.
@ParameterizedTest @CsvSource({ "25,Jack Daniels Old No.7, en-US, 1111", "0,Bushmills 14 Years Malaga Cask Finish, en-IR, 2222", "0,Akashi Red no age, ja-JP, 333" }) void generatedTax(int tax, String productName, Locale country, String orderNumber) { Product product = new Product(productName, country, orderNumber); assertEquals(tax, taxCalculator.forProduct(product); }
In this case, it is a fairly simple mapping, but wouldn’t it be nice if this code didn’t have to be replicated in many test methods? The ArgumentsAggregator
in JUnit5, mentioned at the beginning, also provides a solution to this problem. This class maps the arguments for the individual calls of the test to a parameter of the test method. The test changes as follows.
@ParameterizedTest @CsvSource({ "25,Jack Daniels Old No.7, en-US, 1111", "0,Bushmills 14 Years Malaga Cask Finish, en-IR, 2222", "0,Akashi Red no age, ja-JP, 333" }) void generatedTax(int tax, @AggregateWith(ProductAggregator.class) Product product) { assertEquals(tax, taxCalculator.forProduct(product); }
To make it less writing, you can also create your own annotation.
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.PARAMETER) @AggregateWith(ProductAggregator.class) public @interface CsvToProduct { }
The annotation @CsvToProduct
for parameters is itself annotated with @AggregateWith(ProductAggregator.class) replaces this annotation on the test.
@ParameterizedTest @CsvSource({ "25,Jack Daniels Old No.7, en-US, 1111", "0,Bushmills 14 Years Malaga Cask Finish, en-IR, 2222", "0,Akashi Red no age, ja-JP, 333" }) void generatedTax(int tax, @CsvToProduct Product product) { assertEquals(tax, taxCalculator.forProduct(product); }
The implementation of the ArgumentAggregator
for products is still missing and the first variant produces a product from arguments one to four.
public class ProductAggregator implements ArgumentAggregator { @Override public Product aggregateArguments(ArgumentsAccessor arguments, ParameterContext context) { int offset = 1; String name = arguments.getString(offset); Locale country = arguments.get(offset + 1, Locale.class); String orderNumber = arguments.getString(offset + 2); String supplierNumber = arguments.getString(offset + 3); return new Product(name, country, "", orderNumber, supplierNumber); } }
This is where the disadvantage of the ArgumentAggregator
mechanism comes to light. In this implementation, the arguments for the product must always be in positions one to four. This may be sufficient for some tests, but it is not satisfactory. An improvement is the following ProductAggregator
, which inherits from our own AbstractOffsetArgumentAggregator
class.
public class ProductAggregator extends AbstractOffsetArgumentAggregator { @Override protected Product aggregateArguments(ArgumentsAccessor arguments, ParameterContext context, int offset) { String name = arguments.getString(offset); Locale country = arguments.get(offset + 1, Locale.class); String orderNumber = arguments.getString(offset + 2); String supplierNumber = arguments.getString(offset + 3); return new Product(name, country, "", orderNumber, supplierNumber); } }
An aggregateArguments
method is implemented here, which knows the offset of the product arguments within the arguments. The base class knows the offset because it is defined via an annotation.
public abstract class AbstractOffsetArgumentAggregator implements ArgumentsAggregator { @Override public final Object aggregateArguments(ArgumentsAccessor argumentsAccessor, ParameterContext parameterContext) throws ArgumentsAggregationException { int offset = parameterContext.findAnnotation(CsvOffset.class).map(CsvOffset::value).orElse(0); return aggregateArguments(argumentsAccessor, parameterContext, offset); } protected abstract Object aggregateArguments(ArgumentsAccessor argumentsAccessor, ParameterContext parameterContext, int offset); }
With this variant, you can use an additional annotation @CsvOffset
to determine the offset for the product in the arguments. This can also be used to define two products in the test, for example.
@ParameterizedTest @CsvSource({ "25,Jack Daniels Old No.7, en-US, 1111, Jack Daniels Old No.8, en-US, 1112", "0,Bushmills 14 Years Malaga Cask Finish, en-IR, 2222, Jameson Irish Whiskey, en-IR, 2223", "0,Akashi Red no age, ja-JP, 3333, Kawasaki Kamikaze, ja-JP, 3334" }) void generatedTax(int tax, @CsvOffset(1) @CsvToProduct Product product1, @CsvOffset(4) @CsvToProduct Product product2) { assertEquals(tax, taxCalculator.forProducts(product1, products2); }
If most products start at position one, then you can also write the @CsvOffset
annotation to the @CsvToProduct
annotation and then omit it from the parameter.