How do you make a JUnit 5 parameterized test method take a single domain object built from several columns of the argument row, using @AggregateWith and a custom ArgumentsAggregator?
answer
- implements ArgumentsAggregator
- aggregateArguments(accessor, parameterContext)
- static nested + no-arg constructor
- @AggregateWith on the parameter, or a composed @CsvToPerson
- ArgumentsAggregationException for bad data
basics
~10 sWrite a class implementing ArgumentsAggregator: its aggregateArguments(ArgumentsAccessor, ParameterContext) reads the row positionally and returns the object. Give it a no-arg constructor, then annotate the test parameter @AggregateWith(MyAggregator.class). JUnit passes the built object.
solid answer
~50 sAn `ArgumentsAggregator` is a reusable, named version of positional row-reading. You implement one method: ```java public class PersonAggregator implements ArgumentsAggregator { @Override public Person aggregateArguments(ArgumentsAccessor a, ParameterContext ctx) { return new Person(a.getString(0), a.getString(1), a.get(2, LocalDate.class)); } } ``` The class must be top-level or a *static* nested class with a no-arg constructor, and JUnit instantiates it fresh per use. Then the test method declares `@AggregateWith(PersonAggregator.class) Person person` — the parameter consumes no single column; the aggregator sees the whole row. Two refinements matter in practice. First, wrap the annotation in a composed annotation (`@CsvToPerson`) so the test signature reads as domain language instead of framework plumbing. Second, throw `ArgumentsAggregationException` for bad input so failures are attributed to the data rather than looking like a bug in the code under test. You can declare several aggregator parameters; each receives the same complete row, which lets one row produce, say, an input object and an expected-result object.
code
java · 24 linespublic class PersonAggregator implements ArgumentsAggregator {
@Override
public Person aggregateArguments(ArgumentsAccessor a, ParameterContext ctx) {
if (a.size() < 4) {
throw new ArgumentsAggregationException("expected 4 columns, got " + a.size());
}
return new Person(a.getString(0), a.getString(1),
a.get(2, Gender.class), a.get(3, LocalDate.class));
}
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@AggregateWith(PersonAggregator.class)
public @interface CsvToPerson {}
@ParameterizedTest
@CsvSource({
"Jane, Doe, F, 1990-05-20",
"John, Roe, M, 1985-11-02"
})
void fullNameIsFirstThenLast(@CsvToPerson Person person) {
assertTrue(person.fullName().contains(" "));
}go deeper
Be able to name the pieces: implement ArgumentsAggregator, return the object from aggregateArguments, annotate the parameter with @AggregateWith.
Add the implementation constraints (static nested, no-arg constructor), the fact that the accessor sees the whole row, and the composed-annotation trick.
Discuss when aggregation earns its indirection versus plain indexed parameters, plus ArgumentsAggregationException so data problems are reported as data problems.
Treat it as test-data architecture: shared aggregators as a small internal DSL, the maintenance risk of column-order coupling, and conventions for where aggregators live.
## Why aggregate at all Reading a row through an `ArgumentsAccessor` inside the test body works, but the index knowledge then lives in the test — repeated in every method that consumes the same shape, and invisible in the signature. `@AggregateWith` moves that extraction into a named, reusable class so the test method's signature states what it is really about: `void discountApplies(@CsvToOrder Order order, BigDecimal expected)`. ## The contract `ArgumentsAggregator` (in `org.junit.jupiter.params.aggregator`) is a single-method interface: ```java Object aggregateArguments(ArgumentsAccessor accessor, ParameterContext context) throws ArgumentsAggregationException; ``` - `accessor` is the same positional view over the **entire** argument array described for `ArgumentsAccessor`: zero-based, absolute indices, typed getters that apply implicit conversion, `size()`/`toList()`. - `context` is the `ParameterContext` for the parameter being resolved. It exposes the `Parameter`, its index, the declaring executable, and `findAnnotation(...)`. That is what lets one aggregator behave differently for different parameters — for example an `@Column(prefix = "billing")` annotation telling the aggregator which slice of the row to read. - The return value must be assignable to the parameter's declared type; if it is not, the invocation fails. Implementation requirements: the class must be top-level or a **static** nested class (an inner class cannot be instantiated without its enclosing instance), non-abstract, and must declare a no-argument constructor. JUnit instantiates it reflectively; you get no dependency injection and should treat the aggregator as stateless. ## Wiring it to a parameter ```java @ParameterizedTest @CsvSource({"Jane, Doe, 1990-05-20", "John, Roe, 1985-11-02"}) void buildsPerson(@AggregateWith(PersonAggregator.class) Person person) { … } ``` The annotated parameter is an *aggregator parameter*: like `ArgumentsAccessor`, it consumes no single column. The ordering rule applies — indexed arguments first, aggregator parameters next, extension-resolved parameters (`TestInfo`, `TestReporter`, …) last. Multiple aggregator parameters are allowed and each sees the whole row. A common pattern is one aggregator producing the input object and a second producing the expected result, both reading disjoint index ranges of the same CSV line. ## Composed annotations `@AggregateWith(PersonAggregator.class)` in a signature is noise. Jupiter's annotations are meta-annotation aware, so you can define your own: ```java @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.PARAMETER) @AggregateWith(PersonAggregator.class) public @interface CsvToPerson {} ``` and write `void buildsPerson(@CsvToPerson Person person)`. This is the idiomatic form in real codebases: the framework mechanism is declared once, and every test reads as domain vocabulary. It also gives you one place to change if the aggregator implementation is replaced. ## Error handling `ArgumentsAggregationException` exists so that failures in aggregation are reported as *test-configuration/data* problems rather than as assertion failures or random `NullPointerException`s from the production code. Validate in the aggregator and throw it with a message naming the offending column: ```java if (accessor.size() < 3) { throw new ArgumentsAggregationException("expected 3 columns, got " + accessor.size()); } ``` Failures from the accessor's typed getters (`ArgumentAccessException`) surface with the index and target type already in the message, so you rarely need to wrap them. ## Aggregation versus other options - **Indexed parameters** — best while the row is narrow and each column is a distinct concept. No indirection, full compile-time typing. - **`ArgumentsAccessor` in the test body** — good for a one-off wide row or a row whose arity varies. - **`ArgumentsAggregator`** — best when the same row shape recurs, when construction logic is non-trivial (defaults, validation, building a nested object graph), or when you want the signature to speak domain language. The honest cost is indirection: a reader of the test must open the aggregator to learn which column is which, and a change to the CSV column order breaks the aggregator silently at runtime rather than at compile time. Keep aggregators tiny, keep them next to the tests that use them, and give the composed annotation a name that says exactly which source shape it expects (`@CsvToPerson`, not `@Aggregated`). ## Interaction with conversion An aggregator parameter is not converted by `@ConvertWith` — the two mechanisms operate at different granularity: conversion adapts one indexed argument, aggregation consumes the row. If a single column needs custom parsing on its way into the aggregated object, do that parsing inside `aggregateArguments`, either via `accessor.get(i, TargetType.class)` for implicit conversion or by calling your own parsing code directly.
- What is the ParameterContext argument of aggregateArguments actually useful for?It describes the parameter currently being resolved: its index, the declaring method, and any annotations on it via findAnnotation. That lets one aggregator serve several parameters with different behaviour — for example reading columns 0-3 when the parameter carries @Slice(0) and columns 4-7 for @Slice(1) — instead of writing a near-duplicate aggregator class per parameter.
- Can an ArgumentsAggregator be an inner (non-static) class or take constructor arguments?No. JUnit instantiates it reflectively with a no-argument constructor, so it must be a top-level or static nested, non-abstract class. There is no injection into aggregators, so any configuration has to come from the row itself or from annotations read through the ParameterContext.
saying these in an interview costs you the question
- Thinking @AggregateWith consumes only the column at that parameter's position rather than the whole row
- Writing the aggregator as a non-static inner class or giving it a constructor with arguments, then being confused by the instantiation failure
- Assuming @ConvertWith can be stacked on an @AggregateWith parameter to convert its columns
- Letting production parsing logic creep into the aggregator so a test can pass for the wrong reason
- Holding state in the aggregator across invocations