Clean Unit Tests with ArgumentsAggregator

He′s like a king without a crown
He wears it like a clown
Watch him disappear
I wish he would come down

Bottle Living – Dave Gahan

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.

Leave a Comment