skip to content

Explain implicit and explicit argument conversion in @ParameterizedTest, and when you would use @ConvertWith or aggregate arguments with @AggregateWith / ArgumentsAccessor.

level: seniorimportance: should knowfreq 30%

answer

  1. Implicit: String→type (primitives, enums, java.time, factory/ctor-of-String)
  2. @ConvertWith + ArgumentConverter for custom formats
  3. SimpleArgumentConverter / TypedArgumentConverter base classes
  4. ArgumentsAccessor = pull columns by index
  5. @AggregateWith + ArgumentsAggregator = row → domain object

basics

~20 s

JUnit automatically converts string source values (like from @CsvSource) into the parameter's type — that's implicit conversion. When the built-in rules can't do it, you supply a converter with @ConvertWith. To bundle many columns into one object, you use an ArgumentsAccessor or a custom aggregator via @AggregateWith.

solid answer

~40 s

Sources like @CsvSource provide string values; JUnit applies **implicit conversion** to coerce each string into the declared parameter type — primitives and wrappers, enums by name, common JDK types (BigDecimal, UUID, java.time, File/Path), and factory-method/constructor-based conversion for types with a single String parameter. When the built-in rules don't fit — a custom format, a domain type, a non-ISO date — you attach `@ConvertWith(MyConverter.class)` to the parameter, implementing `ArgumentConverter` (usually by extending `SimpleArgumentConverter` or `TypedArgumentConverter`). For wide rows, instead of declaring many parameters you can take a single `ArgumentsAccessor` and pull typed columns (`accessor.getInteger(0)`, `getString(1)`), or write an `ArgumentsAggregator` and apply it with `@AggregateWith` to fold several columns into one domain object. Conversion keeps source data declarative; aggregation keeps method signatures clean when rows are wide or map naturally onto an object.

code

java · 34 lines
java
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.aggregator.ArgumentsAccessor;
import org.junit.jupiter.params.converter.ConvertWith;
import org.junit.jupiter.params.converter.TypedArgumentConverter;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertTrue;

class ConversionDemo {

    static class SlashDate extends TypedArgumentConverter<String, LocalDate> {
        SlashDate() { super(String.class, LocalDate.class); }
        protected LocalDate convert(String s) {
            return LocalDate.parse(s, DateTimeFormatter.ofPattern("dd/MM/yyyy"));
        }
    }

    // explicit conversion of a non-ISO date column
    @ParameterizedTest
    @CsvSource({"01/05/1990, 1990"})
    void explicitConvert(@ConvertWith(SlashDate.class) LocalDate dob, int year) {
        assertTrue(dob.getYear() == year);
    }

    // accessor instead of many parameters
    @ParameterizedTest
    @CsvSource({"Jane, 30, true"})
    void viaAccessor(ArgumentsAccessor args) {
        assertTrue(args.getString(0).length() > 0);
        assertTrue(args.getInteger(1) > 0);
        assertTrue(args.getBoolean(2));
    }
}

go deeper

for a junior

Aware that @CsvSource strings somehow become typed parameters (implicit conversion) without manual parsing.

for a middle

Names the implicit conversions (primitives, enums, java.time) and knows @ConvertWith exists for custom formats.

for a senior

Implements ArgumentConverter (Simple/Typed), uses ArgumentsAccessor for wide rows, and applies @AggregateWith to map rows to domain objects; chooses conversion vs @MethodSource appropriately.

for a principal

Defines reusable converters/aggregators and composed annotations as team conventions, and weighs declarative CSV+conversion against programmatic @MethodSource for clarity and maintenance at scale.

## The setup: string-based sources need typing Sources such as `@CsvSource`, `@CsvFileSource`, and `@ValueSource(strings = …)` hand JUnit **strings**, but your test method declares **typed** parameters (`int`, `LocalDate`, `Status`, a domain class). JUnit bridges this with **argument conversion**. ## Implicit conversion (automatic) If no converter is specified, JUnit attempts **implicit conversion** from `String` to the target type using built-in rules, including: - **Primitives/wrappers**: `"42"`→`int`/`Integer`, `"true"`→`boolean`, `"3.14"`→`double`. - **Enums**: the string must equal a constant name → that constant. - **Common JDK types**: `BigDecimal`, `BigInteger`, `UUID`, `Charset`, `File`, `Path`, `URI`, `URL`, and `java.time` types (`LocalDate`, `LocalDateTime`, `Duration`, `Instant`, …) parsed from their ISO/standard textual forms. - **Fallback conversion**: a target type that has a **static factory** taking a single `String` (e.g. `valueOf(String)` / `of(String)`) or a **constructor** taking a single `String` is used automatically. So a class `class Point { Point(String csv){…} }` can be converted implicitly. Implicit conversion is why `@CsvSource({"2020-01-01, 5"})` can feed a `(LocalDate d, int n)` method with no extra code. ## Explicit conversion — @ConvertWith When the built-in rules don't cover the format (a custom date pattern, a coded value like `"R"`→`Color.RED`, parsing into a type you don't control), annotate the **parameter** with `@ConvertWith(YourConverter.class)`: ```java void t(@ConvertWith(SlashDateConverter.class) LocalDate date) { ... } ``` You implement an **`ArgumentConverter`**. The easy paths: - Extend **`SimpleArgumentConverter`** and override `convert(Object source, Class<?> targetType)`. - Extend **`TypedArgumentConverter<S, T>`** for a single fixed source/target type (less casting). ```java class SlashDateConverter extends TypedArgumentConverter<String, LocalDate> { SlashDateConverter() { super(String.class, LocalDate.class); } protected LocalDate convert(String s) { return LocalDate.parse(s, DateTimeFormatter.ofPattern("dd/MM/yyyy")); } } ``` Conversion is **per-parameter** — different columns can use different converters. ## Aggregation — many columns into one Sometimes a row has many columns and listing each as a parameter is noisy, or the columns naturally form an object. Two tools: ### ArgumentsAccessor Declare a single parameter of type **`ArgumentsAccessor`** and JUnit passes the whole row; you pull columns by index with typed getters: ```java @ParameterizedTest @CsvSource({"Jane, Doe, 1990-05-01"}) void t(ArgumentsAccessor args) { String first = args.getString(0); LocalDate dob = args.get(2, LocalDate.class); } ``` `getString`, `getInteger`, `get(index, Type.class)`, `size()`, etc. — each getter applies conversion. ### @AggregateWith + ArgumentsAggregator For reuse, implement **`ArgumentsAggregator`** (`aggregateArguments(ArgumentsAccessor, ParameterContext)`) to fold the row into a **domain object**, and apply it with `@AggregateWith(PersonAggregator.class)` on a single parameter: ```java void t(@AggregateWith(PersonAggregator.class) Person p) { ... } ``` This keeps the signature to one meaningful object and centralizes the row→object mapping. A custom **composed annotation** (e.g. `@CsvToPerson`) can wrap `@AggregateWith` for readability. ## When to use which | Situation | Tool | |---|---| | String maps to a standard type | implicit conversion (nothing to do) | | Custom/foreign single-value format | `@ConvertWith` + `ArgumentConverter` | | Wide row, ad-hoc column access | `ArgumentsAccessor` | | Row maps onto a reusable domain object | `@AggregateWith` + `ArgumentsAggregator` | | Per-row objects with logic | often simpler with `@MethodSource` | ## Trade-offs and gotchas - Implicit conversion **fails loudly** (`ArgumentConversionException`) if the string isn't parseable — good, but watch enum name exactness and date formats. - A converter/aggregator adds indirection; if the mapping is trivial, prefer plain parameters or `@MethodSource` (which lets you build the object directly in Java). - `ArgumentsAccessor` getters are 0-based; off-by-one column indices are a frequent bug. - Conversion runs **per invocation**, so keep converters cheap and stateless.

  • When does JUnit use a type's constructor or static factory for conversion?
    In implicit (fallback) conversion: if the target type has a constructor taking a single String, or a static factory method like valueOf(String)/of(String) taking a single String, JUnit uses it automatically to convert the source string.
  • How is @AggregateWith different from just declaring many parameters?
    @AggregateWith folds the whole row into one object via an ArgumentsAggregator, keeping the method signature to a single meaningful parameter and centralizing the row→object mapping for reuse, rather than spreading raw columns across many parameters.

saying these in an interview costs you the question

  • Believing every conversion needs a custom converter (most are implicit)
  • Forgetting ArgumentsAccessor getters are 0-based
  • Putting mutable state in a converter/aggregator
  • Reaching for @ConvertWith when @MethodSource (building the object in Java) is simpler

context