Repository: junit-team/junit5 (language: java)

Issue:
`Locale` argument conversion not setting `language` and `country` properly
I am trying to write a parameterized test for a service that uses a resource bundle under the hood, loading the resource bundle with [`ResourceBundle.getBundle(String, Locale)`](https://docs.oracle.com/javase/8/docs/api/java/util/ResourceBundle.html#getBundle-java.lang.String-java.util.Locale-).


The test gets several `Locale` values in a `@CsvSource` annotation: 

The [documentation](https://junit.org/junit5/docs/current/user-guide/#writing-tests-parameterized-tests-argument-conversion-implicit) does not have an example for a country-based locale so I initially tried with:

```java
@CsvSource({
  "en_FR, value1",
  "en_GB, value2",
  "fr_FR, value3",
  "fr_MC, value4",
  "it_IT, value5",
  ...
})
void test(Locale locale, String expected) {
  // call service using the resource bundle

  // compare result with `expected`
}
```

All worked fine on Windows (local environment) but failed on Linux (CI).

After some digging, on Windows everything works because it's a case-insensitive file system, so for example the `en_FR` value is converted to a `en_fr` language-only locale (i.e., without country) and it successfully matches the file ending with `en_FR` because of case insensitivity.

Looking at the code, I think the problem is here:

https://github.com/junit-team/junit5/blob/4288cf1233b4af6002e922cb9ddd1b159fe2c9b3/junit-jupiter-params/src/main/java/org/junit/jupiter/params/converter/DefaultArgumentConverter.java#L265

which uses the [`Locale(String)`](https://docs.oracle.com/javase/8/docs/api/java/util/Locale.html#Locale-java.lang.String-) constructor. The constructor Javadoc also explains the different behavior based on case sensitivity:

> This constructor normalizes the language value to lowercase. 

Looking at the test cases:

https://github.com/junit-team/junit5/blob/4288cf1233b4af6002e922cb9ddd1b159fe2c9b3/junit-jupiter-params/src/test/java/org/junit/jupiter/params/converter/DefaultArgumentConverterTests.java#L222-L223

Probably the second one is invalid. I would have expected that to be:

```java
assertConverts("en_us", Locale.class, Locale.US);
```

which does not pass with the current implementation.

## Steps to reproduce

As the documentation does not cover conversion of locales with country, I am not sure what would be the "right" test case to demonstrate the issue.

Assuming the [IETF BCP 47 language tag format](https://docs.oracle.com/javase/8/docs/api/java/util/Locale.html#forLanguageTag-java.lang.String-) would be the right format for input values, this test fails already at the first assertion:

```java
  @ParameterizedTest
  @ValueSource(strings = { "en-US" })
  void test(Locale locale) {
    assertEquals("en", locale.getLanguage());
    assertEquals("US", locale.getCountry());
    assertEquals(Locale.US, locale);
  }
```

## Workaround

Currently, I'm using a custom `ArgumentConverter` which delegates the conversion to [`Locale::forLanguageTag`](https://docs.oracle.com/javase/8/docs/api/java/util/Locale.html#forLanguageTag-java.lang.String-).

## Context

 - Used versions (Jupiter/Vintage/Platform): 5.8.2
 - Build Tool/IDE: Intellij IDEA / Maven

## Deliverables

- [x] `DefaultArgumentConverter` properly converts IETF BCP 47 strings
- [x] The [documentation](https://junit.org/junit5/docs/current/user-guide/#writing-tests-parameterized-tests-argument-conversion-implicit) has an example using the IETF BCP 47 language tag format



Discussion / hints:
I propose to change the implementation at:

https://github.com/junit-team/junit5/blob/4288cf1233b4af6002e922cb9ddd1b159fe2c9b3/junit-jupiter-params/src/main/java/org/junit/jupiter/params/converter/DefaultArgumentConverter.java#L265

replacing `Locale::new` with [`Locale::forLanguageTag`](https://docs.oracle.com/javase/8/docs/api/java/util/Locale.html#forLanguageTag-java.lang.String-).

Happy to work on it if accepted.
@scordio IIUC that could potentially break existing tests that (wrongly or not) rely on the `Locale(String)` constructor being called, right?
@marcphilipp yes, but only if they specify a country and/or variant in the string. They would be broken like the second test case you currently have in the codebase.

However, such tests would have today a "wrong" `Locale` instance like the one in the reproducer and I'm not sure if they exist in reality. Up to you to judge if fixing this deserves a breaking change.

Tests that are specifying only a "plain" language (i.e., without country, variant, etc.) are safe from my point of view, which might be the most common use case.
Looking at Java 19, the [`Locale(String)`](https://docs.oracle.com/en/java/javase/19/docs/api/java.base/java/util/Locale.html#%3Cinit%3E(java.lang.String)) constructor has been deprecated together with the other constructors and `Locale::forLanguageTag` is one of the [suggested ways](https://docs.oracle.com/en/java/javase/19/docs/api/java.base/java/util/Locale.html#ObtainingLocale) to obtain a `Locale` object.
In case you prefer to keep the current behavior thus avoiding a breaking change, an alternative could be an opt-in argument converter like `@JavaTimeConversionPattern`.

A few ideas about naming:
* `@LanguageTag`
* `@LanguageTagConversion`
* `@LanguageTagLocale`

The implicit conversion could then be changed in the next major version of JUnit.
> Looking at Java 19, the [`Locale(String)`](https://docs.oracle.com/en/java/javase/19/docs/api/java.base/java/util/Locale.html#%3Cinit%3E(java.lang.String)) constructor has been deprecated together with the other constructors and `Locale::forLanguageTag` is one of the [suggested ways](https://docs.oracle.com/en/java/javase/19/docs/api/java.base/java/util/Locale.html#ObtainingLocale) to obtain a `Locale` object.

We (JUnit Pioneer) also changed our implementation as the old constructor is deprecated, see [JUnit Pioneer issue](https://github.com/junit-pioneer/junit-pioneer/issues/658) and [JUnit Pioneer PR](https://github.com/junit-pioneer/junit-pioneer/pull/662). Why do I note this here? Because some of the builder methods validates the input against a valid pattern - which the old constructor did not. So you might have the same discussion as we if you see this as a breaking change or not.
Thanks for the pointers, @Bukama!

As far as I can see, JUnit Pioneer [favored the builder](https://github.com/junit-pioneer/junit-pioneer/blob/cdfa6789c1913ba8546f4f7be2d20b49362e62ce/src/main/java/org/junitpioneer/jupiter/DefaultLocaleExtension.java#L50-L54) only when the fine-grained parameters are specified, otherwise `Locale::forLanguageTag` is used.

Do you see any issue with using `Locale::forLanguageTag` to solve the issue with the JUnit argument conversion?
> Do you see any issue with using `Locale::forLanguageTag` to solve the issue with the JUnit argument conversion?

I would not expect any issues as this method does not do a syntax check (as far as I understand the docs)


> In case you prefer to keep the current behavior thus avoiding a breaking change, an alternative could be an opt-in argument converter like `@JavaTimeConversionPattern`.
> 
> A few ideas about naming:
> 
>     * `@LanguageTag`
> 
>     * `@LanguageTagConversion`
> 
>     * `@LanguageTagLocale`
> 
> 
> The implicit conversion could then be changed in the next major version of JUnit.

As mentioned in https://github.com/junit-team/junit5/issues/2702#issuecomment-1518632314, a custom argument converter could be a candidate for JUnit Pioneer.
**Team decision:** Since the `Locale` constructor is not deprecated for removal, there's no urgency to change the existing behavior. Since switching from `Locale::new` to `Locale::forLanguageTag` would likely break existing tests, we would need to make it configurable one way or another. For now, we'll wait for additional interest from the community.
During the team discussion (resulting in the above team decision), I mentioned that Spring Framework dealt with this issue by introducing "lenient" Locale parsing in order to support both legacy locale formats as well as BCP 47 formats.

- https://github.com/spring-projects/spring-framework/issues/20736
- See [`StringUtils.parseLocale()`](https://github.com/spring-projects/spring-framework/blob/90ffa95b6a61be2e161404b054ccc9d47197961a/spring-core/src/main/java/org/springframework/util/StringUtils.java#L846) and `StringUtils.parseLocaleString()` for details on the solution.


Hi @marcphilipp and @sbrannen, I was going to submit a feature request to JUnit Pioneer for a custom argument converter, but stopped because I'm guessing that having the behavior configurable in JUnit would probably make the Pioneer converter obsolete.

I understand you're waiting for additional interest. I am definitely interested in having a solution, one way or another, but I also understand a single person's need doesn't count as a community interest 😅

I'm happy to help with the changes, but some initial design would of course be required.

In case you're open to discussing the solution further, here is my proposal: as I can hardly imagine test suites where you want to have a combination of the old and new conversion behavior for `Locale`, I would introduce a configuration parameter in the style of `junit.jupiter.tempdir.scope`, perhaps `junit.jupiter.params.arguments.conversion.locale=default|lenient`.

In case you don't want to invest more in this right now, that's totally fine and understood!
@scordio Wouldn't it rather be "ISO 639" vs. "BCP 47"?
@marcphilipp I guess you're referring to the values of the configuration property, right?

If the change would be the one I mentioned at https://github.com/junit-team/junit5/issues/3141#issuecomment-1410675040, yes, "ISO 639" vs. "BCP 47" would make more sense as property values.

In the previous comment, I proposed `default` vs. `lenient` following the example @sbrannen described at https://github.com/junit-team/junit5/issues/3141#issuecomment-1527426500 but probably it's not needed to have a mode supporting both at the same time (I actually mentioned the same in my previous comment... fighting with myself, it seems 🤦).

Just as a last comment, I was reading again the Javadoc of Spring's [`StringUtils.parseLocale()`](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/util/StringUtils.html#parseLocale(java.lang.String)). Overall, Spring supports three variants:

1. `Locale`'s `toString()` format (e.g., "en", "en_US")
2. `Locale`'s `toString()` format with spaces as separators (e.g., "en US")
3. BCP 47 (e.g. "en-US")

I tested how the two solutions would perform with such use cases:

|                                  | `Locale::getLanguage` | `Locale::getCountry` | `Locale::toString` |
|----------------------------------|-----------------------|----------------------|--------------------|
| `new Locale("en")`               | `en`                  | ` `                  | `en`               |
| `Locale.forLanguageTag("en")`    | `en`                  | ` `                  | `en`               |
| `new Locale("en_US")`            | `en_us`               | ` `                  | `en_us`            |
| `Locale.forLanguageTag("en_US")` | ` `                   | ` `                  | ` `                |
| `new Locale("en-US")`            | `en-us`               | ` `                  | `en-us`            |
| `Locale.forLanguageTag("en-US")` | `en`                  | `US`                 | `en_US`            |
| `new Locale("en US")`            | `en us`               | ` `                  | `en us`            |
| `Locale.forLanguageTag("en US")` | ` `                   | ` `                  | ` `                |

Neither of the underscore and space use cases are adequately supported by either new `Locale(String)` or `Locale.forLanguageTag(String)`. However, I don't know if JUnit should support these use cases at all.

My proposal would be to not support them as they are not backed by any standard.

Thoughts?
@marcphilipp if you don't have objections, I'll compose a PR in the next few days for this topic.
SGTM. Sorry for the delayed response. I was out for a few days. 
@scordio Do you have time to pick this up this week? If not, I can take a stab at it since we should include it in 5.13.
Right, I've been falling behind with it, sorry! I'll let you know if I cannot make good progress till Wednesday 🙂
I'm not yet ready to raise a PR, but here's a small preview of what I'm doing:
* New Jupiter configuration property: `junit.jupiter.params.arguments.conversion.locale.format` with value either `iso_639` (~default~) or `bcp_47` (default)
* Such property is consumed by the Jupiter `DefaultArgumentConverter`, which would have `Locale` specific logic before delegating to Platform Commons `ConversionSupport`
* `ConversionSupport` will have no way to fine-tune the implicit conversion of `Locale` values, which currently happens in `StringToCommonJavaTypesConverter`

The `Locale` special logic in `DefaultArgumentConverter` could then be dropped in JUnit 6, and `StringToCommonJavaTypesConverter` could be updated with the breaking change [previously proposed](https://github.com/junit-team/junit5/issues/3141#issuecomment-1410675040).

Would this make sense, or do you see a better strategy?

The downside of my proposal is that direct users of `ConversionSupport` wouldn't have a gentle migration path.
> * New Jupiter configuration property: `junit.jupiter.params.arguments.conversion.locale.format` with value either `iso_639` (default) or `bcp_47`

Shouldn't `bcp_47` be the default so we can drop the config param with 6.0?

> Would this make sense, or do you see a better strategy?

We could make `junit.jupiter.params.arguments.conversion.locale.format` a system property instead of a configuration parameter and put the logic into `ConversionSupport` right away. However, from a user's perspective it would be surprising that this can't be configured using `junit-platform.properties` etc. as usual. Therefore, I think we should stick to the plan you've proposed.

> The downside of my proposal is that direct users of `ConversionSupport` wouldn't have a gentle migration path.

Assuming 6.0 includes #853, we could document in its release notes (or some migration section in the User Guide) how to register a custom `ConversionService` that uses `Locale::new`, right?
> Shouldn't bcp_47 be the default so we can drop the config param with 6.0?

Yes, sorry, typo 😅 

> Therefore, I think we should stick to the plan you've proposed.

👍 

> Assuming 6.0 includes https://github.com/junit-team/junit5/issues/853, we could document in its release notes (or some migration section in the User Guide) how to register a custom ConversionService that uses Locale::new, right?

Yes, that would be a more reliable way forward for users who want the previous behavior back.

One more point: I think I must introduce a constructor parameter to `DefaultArgumentConverter` for accessing the new config parameter (likely, the `ExtensionContext`), which means it will pollute a bit the parts of the code that today rely on `DefaultArgumentConverter.INSTANCE`.

Fix patch:
diff --git a/documentation/src/docs/asciidoc/release-notes/release-notes-5.13.0-M3.adoc b/documentation/src/docs/asciidoc/release-notes/release-notes-5.13.0-M3.adoc
index 0c9907c5208d..f67c3702c006 100644
--- a/documentation/src/docs/asciidoc/release-notes/release-notes-5.13.0-M3.adoc
+++ b/documentation/src/docs/asciidoc/release-notes/release-notes-5.13.0-M3.adoc
@@ -98,7 +98,10 @@ to start reporting discovery issues.
     - Blank `@SentenceFragment` declarations
     - `@BeforeParameterizedClassInvocation` and `@AfterParameterizedClassInvocation`
       methods declared in non-parameterized test classes
-
+* `java.util.Locale` arguments are now converted according to the IETF BCP 47 language tag
+  format. See the
+  <<../user-guide/index.adoc#writing-tests-parameterized-tests-argument-conversion-implicit, User Guide>>
+  for details.
 
 [[release-notes-5.13.0-M3-junit-vintage]]
 === JUnit Vintage
diff --git a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ParameterizedInvocationContext.java b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ParameterizedInvocationContext.java
index 207817c69ebb..33964ced7b50 100644
--- a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ParameterizedInvocationContext.java
+++ b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ParameterizedInvocationContext.java
@@ -72,7 +72,7 @@ private void storeParameterInfo(ExtensionContext context) {
 		ParameterDeclarations declarations = this.declarationContext.getResolverFacade().getIndexedParameterDeclarations();
 		ClassLoader classLoader = getClassLoader(this.declarationContext.getTestClass());
 		Object[] arguments = this.arguments.getConsumedPayloads();
-		ArgumentsAccessor accessor = DefaultArgumentsAccessor.create(invocationIndex, classLoader, arguments);
+		ArgumentsAccessor accessor = DefaultArgumentsAccessor.create(context, invocationIndex, classLoader, arguments);
 		new DefaultParameterInfo(declarations, accessor).store(context);
 	}
 
diff --git a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ResolverFacade.java b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ResolverFacade.java
index 28eda4b8186a..a1cad8e98419 100644
--- a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ResolverFacade.java
+++ b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ResolverFacade.java
@@ -425,7 +425,7 @@ private static Converter createConverter(ParameterDeclaration declaration, Exten
 					.map(clazz -> ParameterizedTestSpiInstantiator.instantiate(ArgumentConverter.class, clazz, extensionContext))
 					.map(converter -> AnnotationConsumerInitializer.initialize(declaration.getAnnotatedElement(), converter))
 					.map(Converter::new)
-					.orElse(Converter.DEFAULT);
+					.orElseGet(() -> Converter.createDefault(extensionContext));
 		} // @formatter:on
 		catch (Exception ex) {
 			throw parameterResolutionException("Error creating ArgumentConverter", ex, declaration.getParameterIndex());
@@ -467,10 +467,12 @@ Object resolve(FieldContext fieldContext, ExtensionContext extensionContext, Eva
 
 	private static class Converter implements Resolver {
 
-		private static final Converter DEFAULT = new Converter(DefaultArgumentConverter.INSTANCE);
-
 		private final ArgumentConverter argumentConverter;
 
+		private static Converter createDefault(ExtensionContext context) {
+			return new Converter(new DefaultArgumentConverter(context));
+		}
+
 		Converter(ArgumentConverter argumentConverter) {
 			this.argumentConverter = argumentConverter;
 		}
diff --git a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/aggregator/DefaultArgumentsAccessor.java b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/aggregator/DefaultArgumentsAccessor.java
index 811a8abd0518..40bf7213e1c7 100644
--- a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/aggregator/DefaultArgumentsAccessor.java
+++ b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/aggregator/DefaultArgumentsAccessor.java
@@ -19,6 +19,7 @@
 import java.util.function.BiFunction;
 
 import org.apiguardian.api.API;
+import org.junit.jupiter.api.extension.ExtensionContext;
 import org.junit.jupiter.params.converter.DefaultArgumentConverter;
 import org.junit.platform.commons.util.ClassUtils;
 import org.junit.platform.commons.util.Preconditions;
@@ -40,10 +41,11 @@ public class DefaultArgumentsAccessor implements ArgumentsAccessor {
 	private final Object[] arguments;
 	private final BiFunction<Object, Class<?>, Object> converter;
 
-	public static DefaultArgumentsAccessor create(int invocationIndex, ClassLoader classLoader, Object[] arguments) {
+	public static DefaultArgumentsAccessor create(ExtensionContext context, int invocationIndex,
+			ClassLoader classLoader, Object[] arguments) {
 		Preconditions.notNull(classLoader, "ClassLoader must not be null");
 
-		BiFunction<Object, Class<?>, Object> converter = (source, targetType) -> DefaultArgumentConverter.INSTANCE //
+		BiFunction<Object, Class<?>, Object> converter = (source, targetType) -> new DefaultArgumentConverter(context) //
 				.convert(source, targetType, classLoader);
 		return new DefaultArgumentsAccessor(converter, invocationIndex, arguments);
 	}
diff --git a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/converter/DefaultArgumentConverter.java b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/converter/DefaultArgumentConverter.java
index 8544019c1894..eea0e734508a 100644
--- a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/converter/DefaultArgumentConverter.java
+++ b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/converter/DefaultArgumentConverter.java
@@ -21,8 +21,10 @@
 import java.util.Currency;
 import java.util.Locale;
 import java.util.UUID;
+import java.util.function.Function;
 
 import org.apiguardian.api.API;
+import org.junit.jupiter.api.extension.ExtensionContext;
 import org.junit.jupiter.api.extension.ParameterContext;
 import org.junit.jupiter.params.support.FieldContext;
 import org.junit.platform.commons.support.conversion.ConversionException;
@@ -50,10 +52,31 @@
 @API(status = INTERNAL, since = "5.0")
 public class DefaultArgumentConverter implements ArgumentConverter {
 
-	public static final DefaultArgumentConverter INSTANCE = new DefaultArgumentConverter();
+	/**
+	 * Property name used to set the format for the conversion of {@link Locale}
+	 * arguments: {@value}
+	 *
+	 * <h4>Supported Values</h4>
+	 * <ul>
+	 * <li>{@code bcp_47}: uses the IETF BCP 47 language tag format, delegating
+	 * the conversion to {@link Locale#forLanguageTag(String)}</li>
+	 * <li>{@code iso_639}: uses the ISO 639 alpha-2 or alpha-3 language code
+	 * format, delegating the conversion to {@link Locale#Locale(String)}</li>
+	 * </ul>
+	 *
+	 * <p>If not specified, the default is {@code bcp_47}.
+	 *
+	 * @since 5.13
+	 */
+	public static final String DEFAULT_LOCALE_CONVERSION_FORMAT_PROPERTY_NAME = "junit.jupiter.params.arguments.conversion.locale.format";
 
-	private DefaultArgumentConverter() {
-		// nothing to initialize
+	private static final Function<String, LocaleConversionFormat> TRANSFORMER = value -> LocaleConversionFormat.valueOf(
+		value.trim().toUpperCase(Locale.ROOT));
+
+	private final ExtensionContext context;
+
+	public DefaultArgumentConverter(ExtensionContext context) {
+		this.context = context;
 	}
 
 	@Override
@@ -84,6 +107,10 @@ public final Object convert(Object source, Class<?> targetType, ClassLoader clas
 		}
 
 		if (source instanceof String) {
+			if (targetType == Locale.class && getLocaleConversionFormat() == LocaleConversionFormat.BCP_47) {
+				return Locale.forLanguageTag((String) source);
+			}
+
 			try {
 				return convert((String) source, targetType, classLoader);
 			}
@@ -97,8 +124,21 @@ public final Object convert(Object source, Class<?> targetType, ClassLoader clas
 				source.getClass().getTypeName(), targetType.getTypeName()));
 	}
 
+	private LocaleConversionFormat getLocaleConversionFormat() {
+		return context.getConfigurationParameter(DEFAULT_LOCALE_CONVERSION_FORMAT_PROPERTY_NAME, TRANSFORMER) //
+				.orElse(LocaleConversionFormat.BCP_47);
+	}
+
 	Object convert(String source, Class<?> targetType, ClassLoader classLoader) {
 		return ConversionSupport.convert(source, targetType, classLoader);
 	}
 
+	enum LocaleConversionFormat {
+
+		BCP_47,
+
+		ISO_639
+
+	}
+
 }


Test patch:
diff --git a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc
index f413644bf869..e84ed85c59be 100644
--- a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc
+++ b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc
@@ -2483,10 +2483,16 @@ integral types: `byte`, `short`, `int`, `long`, and their boxed counterparts.
 | `java.time.ZoneId`         | `"Europe/Berlin"`                        -> `ZoneId.of("Europe/Berlin")`
 | `java.time.ZoneOffset`     | `"+02:30"`                               -> `ZoneOffset.ofHoursMinutes(2, 30)`
 | `java.util.Currency`       | `"JPY"`                                  -> `Currency.getInstance("JPY")`
-| `java.util.Locale`         | `"en"`                                   -> `new Locale("en")`
+| `java.util.Locale`         | `"en-US"`                                -> `Locale.forLanguageTag("en-US")`
 | `java.util.UUID`           | `"d043e930-7b3b-48e3-bdbe-5a3ccfb833db"` -> `UUID.fromString("d043e930-7b3b-48e3-bdbe-5a3ccfb833db")`
 |===
 
+WARNING: To revert to the old `java.util.Locale` conversion behavior of version 5.12 and
+earlier (which called the deprecated `Locale(String)` constructor), you can set the
+`junit.jupiter.params.arguments.conversion.locale.format`
+<<running-tests-config-params, configuration parameter>> to `iso_639`. However, please
+note that this parameter is deprecated and will be removed in a future release.
+
 [[writing-tests-parameterized-tests-argument-conversion-implicit-fallback]]
 ====== Fallback String-to-Object Conversion
 
@@ -2523,7 +2529,7 @@ include::{testDir}/example/ParameterizedTestDemo.java[tags=implicit_fallback_con
 [[writing-tests-parameterized-tests-argument-conversion-explicit]]
 ===== Explicit Conversion
 
-Instead of relying on implicit argument conversion you may explicitly specify an
+Instead of relying on implicit argument conversion, you may explicitly specify an
 `ArgumentConverter` to use for a certain parameter using the `@ConvertWith` annotation
 like in the following example. Note that an implementation of `ArgumentConverter` must be
 declared as either a top-level class or as a `static` nested class.
diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/params/ParameterizedTestIntegrationTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/params/ParameterizedTestIntegrationTests.java
index 0c0c44842427..58ae0dd33a73 100644
--- a/jupiter-tests/src/test/java/org/junit/jupiter/params/ParameterizedTestIntegrationTests.java
+++ b/jupiter-tests/src/test/java/org/junit/jupiter/params/ParameterizedTestIntegrationTests.java
@@ -22,6 +22,7 @@
 import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS;
 import static org.junit.jupiter.engine.discovery.JupiterUniqueIdBuilder.appendTestTemplateInvocationSegment;
 import static org.junit.jupiter.engine.discovery.JupiterUniqueIdBuilder.uniqueIdForTestTemplateMethod;
+import static org.junit.jupiter.params.converter.DefaultArgumentConverter.DEFAULT_LOCALE_CONVERSION_FORMAT_PROPERTY_NAME;
 import static org.junit.jupiter.params.provider.Arguments.arguments;
 import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass;
 import static org.junit.platform.engine.discovery.DiscoverySelectors.selectIteration;
@@ -476,6 +477,29 @@ void failsWhenNoArgumentsSourceIsDeclared() {
 						"Configuration error: You must configure at least one arguments source for this @ParameterizedTest"))));
 	}
 
+	@Test
+	void executesWithDefaultLocaleConversionFormat() {
+		var results = execute(LocaleConversionTestCase.class, "testWithBcp47", Locale.class);
+
+		results.allEvents().assertStatistics(stats -> stats.started(4).succeeded(4));
+	}
+
+	@Test
+	void executesWithBcp47LocaleConversionFormat() {
+		var results = execute(Map.of(DEFAULT_LOCALE_CONVERSION_FORMAT_PROPERTY_NAME, "bcp_47"),
+			LocaleConversionTestCase.class, "testWithBcp47", Locale.class);
+
+		results.allEvents().assertStatistics(stats -> stats.started(4).succeeded(4));
+	}
+
+	@Test
+	void executesWithIso639LocaleConversionFormat() {
+		var results = execute(Map.of(DEFAULT_LOCALE_CONVERSION_FORMAT_PROPERTY_NAME, "iso_639"),
+			LocaleConversionTestCase.class, "testWithIso639", Locale.class);
+
+		results.allEvents().assertStatistics(stats -> stats.started(4).succeeded(4));
+	}
+
 	private EngineExecutionResults execute(DiscoverySelector... selectors) {
 		return EngineTestKit.engine(new JupiterTestEngine()).selectors(selectors).execute();
 	}
@@ -484,6 +508,14 @@ private EngineExecutionResults execute(Class<?> testClass, String methodName, Cl
 		return execute(selectMethod(testClass, methodName, ClassUtils.nullSafeToString(methodParameterTypes)));
 	}
 
+	private EngineExecutionResults execute(Map<String, String> configurationParameters, Class<?> testClass,
+			String methodName, Class<?>... methodParameterTypes) {
+		return EngineTestKit.engine(new JupiterTestEngine()) //
+				.selectors(selectMethod(testClass, methodName, ClassUtils.nullSafeToString(methodParameterTypes))) //
+				.configurationParameters(configurationParameters) //
+				.execute();
+	}
+
 	private EngineExecutionResults execute(String methodName, Class<?>... methodParameterTypes) {
 		return execute(TestCase.class, methodName, methodParameterTypes);
 	}
@@ -2508,6 +2540,24 @@ public static Stream<Arguments> zeroArgumentsProvider() {
 		}
 	}
 
+	static class LocaleConversionTestCase {
+
+		@ParameterizedTest
+		@ValueSource(strings = "en-US")
+		void testWithBcp47(Locale locale) {
+			assertEquals("en", locale.getLanguage());
+			assertEquals("US", locale.getCountry());
+		}
+
+		@ParameterizedTest
+		@ValueSource(strings = "en-US")
+		void testWithIso639(Locale locale) {
+			assertEquals("en-us", locale.getLanguage());
+			assertEquals("", locale.getCountry());
+		}
+
+	}
+
 	private static class TwoSingleStringArgumentsProvider implements ArgumentsProvider {
 
 		@Override
diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/params/aggregator/DefaultArgumentsAccessorTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/params/aggregator/DefaultArgumentsAccessorTests.java
index 792bae865f48..b5f6e941bc6e 100644
--- a/jupiter-tests/src/test/java/org/junit/jupiter/params/aggregator/DefaultArgumentsAccessorTests.java
+++ b/jupiter-tests/src/test/java/org/junit/jupiter/params/aggregator/DefaultArgumentsAccessorTests.java
@@ -16,10 +16,12 @@
 import static org.junit.jupiter.api.Assertions.assertIterableEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
 
 import java.util.Arrays;
 
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtensionContext;
 import org.junit.platform.commons.PreconditionViolationException;
 
 /**
@@ -164,8 +166,9 @@ void size() {
 	}
 
 	private static DefaultArgumentsAccessor defaultArgumentsAccessor(int invocationIndex, Object... arguments) {
+		var context = mock(ExtensionContext.class);
 		var classLoader = DefaultArgumentsAccessorTests.class.getClassLoader();
-		return DefaultArgumentsAccessor.create(invocationIndex, classLoader, arguments);
+		return DefaultArgumentsAccessor.create(context, invocationIndex, classLoader, arguments);
 	}
 
 	@SuppressWarnings("unused")
diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/params/converter/DefaultArgumentConverterTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/params/converter/DefaultArgumentConverterTests.java
index 5336690e1c1e..501f4c09a40c 100644
--- a/jupiter-tests/src/test/java/org/junit/jupiter/params/converter/DefaultArgumentConverterTests.java
+++ b/jupiter-tests/src/test/java/org/junit/jupiter/params/converter/DefaultArgumentConverterTests.java
@@ -12,15 +12,25 @@
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.junit.jupiter.params.converter.DefaultArgumentConverter.DEFAULT_LOCALE_CONVERSION_FORMAT_PROPERTY_NAME;
+import static org.junit.jupiter.params.converter.DefaultArgumentConverter.LocaleConversionFormat.BCP_47;
+import static org.junit.jupiter.params.converter.DefaultArgumentConverter.LocaleConversionFormat.ISO_639;
 import static org.junit.platform.commons.util.ClassLoaderUtils.getClassLoader;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.Locale;
+import java.util.Optional;
 
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtensionContext;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
 import org.junit.platform.commons.support.ReflectionSupport;
@@ -35,7 +45,8 @@
  */
 class DefaultArgumentConverterTests {
 
-	private final DefaultArgumentConverter underTest = spy(DefaultArgumentConverter.INSTANCE);
+	private final ExtensionContext context = mock();
+	private final DefaultArgumentConverter underTest = spy(new DefaultArgumentConverter(context));
 
 	@Test
 	void isAwareOfNull() {
@@ -100,6 +111,36 @@ void delegatesStringsConversion() {
 		verify(underTest).convert("value", int.class, getClassLoader(DefaultArgumentConverterTests.class));
 	}
 
+	@Test
+	void convertsLocaleWithDefaultFormat() {
+		when(context.getConfigurationParameter(eq(DEFAULT_LOCALE_CONVERSION_FORMAT_PROPERTY_NAME), any())) //
+				.thenReturn(Optional.empty());
+
+		assertConverts("en", Locale.class, Locale.ENGLISH);
+		assertConverts("en-US", Locale.class, Locale.US);
+	}
+
+	@Test
+	void convertsLocaleWithExplicitBcp47Format() {
+		when(context.getConfigurationParameter(eq(DEFAULT_LOCALE_CONVERSION_FORMAT_PROPERTY_NAME), any())) //
+				.thenReturn(Optional.of(BCP_47));
+
+		assertConverts("en", Locale.class, Locale.ENGLISH);
+		assertConverts("en-US", Locale.class, Locale.US);
+	}
+
+	@Test
+	void delegatesLocaleConversionWithExplicitIso639Format() {
+		when(context.getConfigurationParameter(eq(DEFAULT_LOCALE_CONVERSION_FORMAT_PROPERTY_NAME), any())) //
+				.thenReturn(Optional.of(ISO_639));
+
+		doReturn(null).when(underTest).convert(any(), any(), any(ClassLoader.class));
+
+		convert("en", Locale.class);
+
+		verify(underTest).convert("en", Locale.class, getClassLoader(DefaultArgumentConverterTests.class));
+	}
+
 	@Test
 	void throwsExceptionForDelegatedConversionFailure() {
 		ConversionException exception = new ConversionException("fail");
diff --git a/jupiter-tests/src/test/kotlin/org/junit/jupiter/params/aggregator/ArgumentsAccessorKotlinTests.kt b/jupiter-tests/src/test/kotlin/org/junit/jupiter/params/aggregator/ArgumentsAccessorKotlinTests.kt
index eb1aa43ed5fb..cbd6ca3a787b 100644
--- a/jupiter-tests/src/test/kotlin/org/junit/jupiter/params/aggregator/ArgumentsAccessorKotlinTests.kt
+++ b/jupiter-tests/src/test/kotlin/org/junit/jupiter/params/aggregator/ArgumentsAccessorKotlinTests.kt
@@ -13,6 +13,8 @@ import org.assertj.core.api.Assertions.assertThat
 import org.junit.jupiter.api.Assertions.assertEquals
 import org.junit.jupiter.api.Test
 import org.junit.jupiter.api.assertThrows
+import org.junit.jupiter.api.extension.ExtensionContext
+import org.mockito.Mockito.mock
 
 /**
  * Unit tests for using [ArgumentsAccessor] from Kotlin.
@@ -52,8 +54,9 @@ class ArgumentsAccessorKotlinTests {
         invocationIndex: Int,
         vararg arguments: Any
     ): DefaultArgumentsAccessor {
+        val context = mock(ExtensionContext::class.java)
         val classLoader = ArgumentsAccessorKotlinTests::class.java.classLoader
-        return DefaultArgumentsAccessor.create(invocationIndex, classLoader, arguments)
+        return DefaultArgumentsAccessor.create(context, invocationIndex, classLoader, arguments)
     }
 
     fun foo() {