skip to content

Inside a JUnit 5 ArgumentsAggregator you need one CSV column as a java.time.LocalDate and another as a custom value type. How does argument conversion work inside aggregation, and can one parameter be both annotated @ConvertWith and @AggregateWith?

level: seniorimportance: should knowfreq 20%

answer

  1. conversion = one argument; aggregation = whole row
  2. accessor.get(i, Type) runs implicit conversion
  3. don't stack @ConvertWith with @AggregateWith
  4. odd formats: parse inside the aggregator
  5. null → primitive read fails; use wrapper type

basics

~20 s

Inside an aggregator use accessor.get(index, LocalDate.class) — typed getters run JUnit's implicit conversion. For anything implicit conversion can't handle, call your own parsing (or reuse a converter class) in the aggregator. @ConvertWith and @AggregateWith work at different granularity and are not combined on one parameter.

solid answer

~50 s

The two mechanisms sit at different granularity. `@ConvertWith` adapts **one indexed argument** on its way to one parameter. `@AggregateWith` (and `ArgumentsAccessor`) consume the **whole row** and return one object. A parameter is either an indexed argument that may be converted, or an aggregator parameter — combining both annotations on the same parameter is meaningless, so don't. Conversion still happens *inside* aggregation, through the accessor: - `accessor.get(3, LocalDate.class)` applies JUnit's implicit conversion — `String` to enums, `UUID`, `Path`, `BigDecimal`, `java.time` types, primitives, and the fallback to a target type's single-`String` factory method or constructor. - The typed shortcuts `getInteger(2)`, `getBoolean(4)` and so on do the same for common types. - For a format implicit conversion cannot handle (a `dd.MM.yyyy` date, a domain type without a `String` factory), parse it in the aggregator — plain code, or by instantiating and calling your `ArgumentConverter` yourself. Failures should be reported as data problems: let the accessor's `ArgumentAccessException` propagate, or throw `ArgumentsAggregationException` with the offending column named.

code

java · 18 lines
java
public class OrderAggregator implements ArgumentsAggregator {

    private static final DateTimeFormatter DE =
            DateTimeFormatter.ofPattern("dd.MM.yyyy");

    @Override
    public Order aggregateArguments(ArgumentsAccessor a, ParameterContext ctx) {
        if (a.size() < 5) {
            throw new ArgumentsAggregationException("expected 5 columns, got " + a.size());
        }
        UUID id = a.get(0, UUID.class);              // implicit
        Sku sku = a.get(1, Sku.class);               // single-String factory
        BigDecimal price = a.get(2, BigDecimal.class); // implicit
        Integer qty = a.get(3, Integer.class);       // wrapper: tolerates null
        LocalDate settled = LocalDate.parse(a.getString(4), DE); // odd format
        return new Order(id, sku, price, qty, settled);
    }
}

go deeper

for a junior

Know that inside an aggregator you read typed values with accessor.get(index, Type) and that this parses strings for you.

for a middle

Explain the granularity difference — conversion is per argument, aggregation is per row — and that the two annotations are not combined on one parameter.

for a senior

Cover odd formats handled inside the aggregator, reuse of an existing converter, null-versus-primitive reads, and failing loudly with the column named.

for a principal

Set the team rule for where test-data parsing lives so it is defined once, and call out silent defaulting as a correctness risk in data-driven suites.

## Two mechanisms, two granularities JUnit Jupiter gives parameterized tests two distinct hooks between the argument source and the test method: - **Conversion** adapts a *single* argument to a *single* parameter's type. It is either implicit (JUnit's built-in `String`→target conversion, applied automatically when the declared type differs) or explicit (`@ConvertWith(MyConverter.class)` on the parameter). - **Aggregation** consumes the *entire* argument row and produces one object. It is triggered by an `ArgumentsAccessor` parameter or by `@AggregateWith(MyAggregator.class)`. Because an aggregator parameter does not correspond to any single argument, there is nothing for `@ConvertWith` to convert on it. The two annotations are not designed to stack, and treating them as composable is the main misconception this question probes. If you find yourself wanting both, what you actually want is conversion *inside* the aggregator. ## Conversion inside the aggregator The accessor handed to `aggregateArguments` is the bridge. Its typed reads are not casts — they delegate to the framework's default argument converter, i.e. exactly the same implicit conversion applied to indexed parameters: ```java public Person aggregateArguments(ArgumentsAccessor a, ParameterContext ctx) { return new Person( a.getString(0), // raw String a.get(1, Gender.class), // enum via valueOf a.get(2, LocalDate.class), // ISO parse a.get(3, UUID.class)); // UUID.fromString } ``` That covers the built-in target types (primitives and wrappers, enums, `File`, `Path`, `URI`, `URL`, `Charset`, `Currency`, `Locale`, `UUID`, `BigDecimal`/`BigInteger`, the `java.time` types) plus the fallback rule: if the target type declares exactly one non-private static factory method taking a single `String`, or a non-private single-`String` constructor, JUnit uses it. So a value type such as `record Sku(String value)` converts for free with `a.get(4, Sku.class)`. ## When implicit conversion is not enough Two cases fall outside it: a non-standard textual format (a `dd.MM.yyyy` date, a currency amount written `"12,50 EUR"`), and a target type with no suitable factory. Inside an aggregator you are in ordinary Java, so just write the parsing: ```java LocalDate settled = LocalDate.parse(a.getString(5), DateTimeFormatter.ofPattern("dd.MM.yyyy")); ``` If that parsing already exists as an `ArgumentConverter` used by other tests, you can reuse it rather than duplicating logic — instantiate it and call `convert(source, context)`, passing the `ParameterContext` the aggregator received. This keeps one definition of "how our test data spells a date". It is a legitimate technique but worth a comment, because a reader does not expect a converter to be invoked by hand. The cleaner alternative, when the odd format applies to a column you assert on directly, is to keep that column as an *indexed* parameter with `@ConvertWith` (or the built-in `@JavaTimeConversionPattern`) and let the aggregator handle the rest of the row. Mixed signatures are allowed as long as the indexed parameters come first: ```java void test(@JavaTimeConversionPattern("dd.MM.yyyy") LocalDate settled, @CsvToOrder Order order) { … } ``` Remember the accessor's indices remain absolute, so the aggregator still sees column 0 even though column 0 was bound to the converted parameter. ## Errors and attribution Good aggregation code makes bad data look like bad data: - A failed typed read throws `ArgumentAccessException` naming the index and the target type — usually let it propagate; the message is already good. - A failed explicit conversion throws `ArgumentConversionException`. - Structural problems (wrong arity, a column that must be one of a set) deserve an explicit `ArgumentsAggregationException` with the column named. What you must avoid is swallowing a parse failure and substituting a default. A silently defaulted column produces a green test that proves nothing, and it is the single worst failure mode of heavy conversion-plus-aggregation setups. ## Null handling A null in the row (from a source configured to emit nulls) is passed through implicit conversion unchanged for reference targets, but a null reaching a *primitive* target fails with a conversion error stating that null cannot be converted to a primitive. Inside an aggregator this shows up as `a.getInteger(2)` blowing up on an empty column, so read such columns as the wrapper type (`a.get(2, Integer.class)`) and decide explicitly what a missing value means for the object you are building. ## Summary judgment Use implicit conversion via the accessor as the default; add hand-written parsing in the aggregator for odd formats; keep `@ConvertWith` for columns that stay indexed; never stack `@ConvertWith` with `@AggregateWith` on one parameter; and always fail loudly with a message that names the column.

  • Your aggregator needs the same non-ISO date parsing that an existing ArgumentConverter already implements. How do you avoid duplicating it?
    Either extract the parsing into a plain static helper that both the converter and the aggregator call, or instantiate the converter inside the aggregator and invoke convert(source, parameterContext) with the context the aggregator was given. The helper is usually cleaner because it keeps the converter a thin adapter and makes the shared logic obvious to a reader.
  • Why does a.getInteger(2) fail on an empty column while a.get(2, Integer.class) may not?
    The primitive-flavoured read targets int, and JUnit refuses to convert null to a primitive, reporting a conversion error. Targeting the wrapper type lets a null pass through as null, so the aggregator can decide explicitly whether a missing quantity means zero, a default, or an invalid row.

saying these in an interview costs you the question

  • Claiming you can stack @ConvertWith on an @AggregateWith parameter to convert its columns
  • Assuming accessor.get(index, Type) is a cast, so believing you must parse every String yourself
  • Catching parse failures inside the aggregator and substituting defaults, producing green tests that assert nothing
  • Expecting the aggregator's indices to shift when a preceding indexed parameter is converted

context