How do you take full control of how one argument of a JUnit 5 parameterized test is turned into the parameter's type, using @ConvertWith — and what does the TypedArgumentConverter base class save you compared with implementing ArgumentConverter directly?
answer
- @ConvertWith(X.class) on the parameter
- ArgumentConverter: convert(Object, ParameterContext)
- TypedArgumentConverter<S,T>: type check + null + typed method
- no-arg constructor, static nested or top-level
- throw ArgumentConversionException, never default
basics
~20 sAnnotate the parameter @ConvertWith(MyConverter.class). Implement ArgumentConverter — convert(Object source, ParameterContext context) — or extend TypedArgumentConverter<S,T>, which takes the source and target classes in its constructor and does the type check and null handling, leaving you one typed convert(S) method.
solid answer
~50 s`@ConvertWith(MyConverter.class)` on a parameter replaces implicit conversion for that argument entirely: the built-in table and the factory-method fallback are not consulted. The raw interface is: ```java Object convert(Object source, ParameterContext context) throws ArgumentConversionException; ``` You must check the source type yourself, handle `null`, and cast the result. `TypedArgumentConverter<S, T>` removes that boilerplate: its constructor takes the source and target classes, it verifies the source type, passes `null` through, and leaves you a single typed method: ```java class ToSlug extends TypedArgumentConverter<String, Slug> { ToSlug() { super(String.class, Slug.class); } @Override protected Slug convert(String source) { return Slug.of(source); } } ``` The converter class needs a no-arg constructor and must be top-level or static nested. Throw `ArgumentConversionException` for bad input so the failure is attributed to the data. As with aggregation, wrap it in a composed annotation — `@ConvertWith` plus a meaningful name — so signatures read as intent. JUnit ships one such annotation already: `@JavaTimeConversionPattern`.
code
java · 29 linespublic class SlashyDateConverter extends TypedArgumentConverter<String, LocalDate> {
private static final DateTimeFormatter FMT =
DateTimeFormatter.ofPattern("dd/MM/yyyy");
protected SlashyDateConverter() {
super(String.class, LocalDate.class);
}
@Override
protected LocalDate convert(String source) {
try {
return LocalDate.parse(source, FMT);
} catch (DateTimeParseException ex) {
throw new ArgumentConversionException("not a dd/MM/yyyy date: " + source, ex);
}
}
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@ConvertWith(SlashyDateConverter.class)
public @interface SlashyDate {}
@ParameterizedTest
@ValueSource(strings = {"20/05/1990", "02/11/1985"})
void parsesSlashyDates(@SlashyDate LocalDate date) {
assertTrue(date.getYear() < 2000);
}go deeper
Know the annotation and the shape: @ConvertWith(MyConverter.class) on the parameter, a class that turns the source value into the target type.
Contrast ArgumentConverter with TypedArgumentConverter, name the no-arg-constructor requirement, and show a composed annotation.
Emphasise precedence over implicit conversion, deliberate null handling, failing loudly with ArgumentConversionException, and using the built-in @JavaTimeConversionPattern before writing your own.
Argue for keeping converters as dumb translation, guard against test data re-implementing production logic, and standardise a small set of shared converters for the team's data formats.
## When you need an explicit converter Implicit conversion handles the JDK types and any target with a single-`String` factory. You need `@ConvertWith` when: - the textual format is not canonical — `"20.05.1990"` rather than ISO-8601, `"12,50"` rather than `12.50`; - the target type has no suitable factory and you do not want to add one to production code just for tests; - the conversion needs context — a lookup, normalisation, a default time zone; - the source is not a `String` at all (a `@MethodSource` supplying an id you want to expand into an object). ## The interface `ArgumentConverter` (in `org.junit.jupiter.params.converter`) declares: ```java Object convert(Object source, ParameterContext context) throws ArgumentConversionException; ``` - `source` is whatever the argument source produced — possibly `null`. - `context` is the `ParameterContext` for the parameter being converted: index, declared `Parameter`, declaring executable, and `findAnnotation(...)`. That last method is how a converter reads configuration from an annotation on the same parameter — exactly how `@JavaTimeConversionPattern` passes its pattern to its converter. - Return a value assignable to the parameter's declared type; anything else fails the invocation. A converter class must be non-abstract with a no-argument constructor, and top-level or a *static* nested class, because JUnit instantiates it reflectively. There is no injection, so keep it stateless. ## TypedArgumentConverter Writing the raw interface means repeating the same three lines in every converter: reject unexpected source types, decide what `null` means, cast. `TypedArgumentConverter<S, T>` is an abstract base that does it for you. You call `super(sourceType, targetType)` and implement `protected T convert(S source)`. It verifies the source is an instance of `S` (throwing `ArgumentConversionException` when it is not), passes `null` through rather than calling your method with it, and gives you compile-time types on both ends. Use the raw interface only when you genuinely need the `ParameterContext` (annotation-driven configuration) or must accept several source types. ## Composed annotations `@ConvertWith(SlashyDateConverter.class) LocalDate date` in a signature is plumbing. Define: ```java @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.PARAMETER) @ConvertWith(SlashyDateConverter.class) public @interface SlashyDate {} ``` and the signature becomes `void t(@SlashyDate LocalDate date)`. JUnit's own `@JavaTimeConversionPattern("dd.MM.yyyy")` is precisely this pattern with a configuration attribute, and it is worth knowing because it removes the need to hand-write date converters at all. ## Precedence and interaction - `@ConvertWith` on a parameter **wins**: implicit conversion and the factory fallback are not attempted for that argument. - It converts exactly **one** argument. It does not combine with `@AggregateWith`, which consumes the whole row; per-column parsing inside an aggregated object happens inside the aggregator. - Inside an `ArgumentsAccessor`, `get(index, Type)` uses the *default* converter, not your `@ConvertWith` converter — annotations apply to parameters, not to accessor reads. ## Error handling Throw `ArgumentConversionException` with a message naming the offending value, and wrap the underlying cause: ```java try { return LocalDate.parse(source, DE); } catch (DateTimeParseException ex) { throw new ArgumentConversionException("not a dd.MM.yyyy date: " + source, ex); } ``` The cardinal sin is returning a default (`null`, epoch, zero) on a parse failure: the test then runs against a value nobody intended and quietly passes. A conversion failure must fail the invocation. Decide `null` deliberately too. With `TypedArgumentConverter` null passes through, which is fine for a reference parameter and fatal for a primitive one — JUnit refuses to bind null to a primitive. If a blank column should mean "absent", declare the parameter as the wrapper type or a domain `Optional`-like type and make that explicit. ## Keeping converters honest A converter is test infrastructure, so it should stay dumb: parse and map, nothing else. Converters that reach into a database, build half the object graph, or apply business defaults turn test data into a second, untested implementation of production behaviour — and a test that passes because the converter and the production code share a bug proves nothing. Keep them small, keep them near the tests, name the composed annotation for the format it consumes, and prefer JUnit's built-ins where they exist.
- When would you implement ArgumentConverter directly rather than extending TypedArgumentConverter?When you need the ParameterContext — typically to read a configuration annotation on the same parameter, the way @JavaTimeConversionPattern passes its pattern — or when the converter must accept more than one source type. Otherwise TypedArgumentConverter is preferable: it performs the source-type check, passes null through, and gives you compile-time types on both sides.
- Does a @ConvertWith converter also apply to reads made through an ArgumentsAccessor?No. @ConvertWith is a parameter annotation, so it governs only the argument bound to that parameter. An accessor's get(index, Type) uses the framework's default converter, meaning the built-in table and the factory fallback. If an aggregator needs the custom logic, call the parsing code (or the converter) explicitly inside aggregateArguments.
saying these in an interview costs you the question
- Returning null or a default value from a converter when parsing fails, so tests pass on bad data
- Expecting implicit conversion to still run as a fallback when @ConvertWith is present
- Writing the converter as a non-static inner class or with a constructor that takes arguments
- Hand-rolling a date converter instead of using the built-in @JavaTimeConversionPattern
- Putting lookups or business logic in a converter so the test data duplicates production behaviour