From e079fac19c175d459423fcec7f893292e2b38a34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20B=C3=BCnger?= Date: Fri, 7 Nov 2025 20:04:48 +0100 Subject: [PATCH 1/3] Contribute DefaultLocale and DefaultTimeZone JUnit Pioneer is happy to contribute its DefaultLocaleExtension and DefaultTimeZoneExtension to JUnit Jupiter. closes #4727 --- .../release-notes/release-notes-6.1.0-M2.adoc | 7 +- .../asciidoc/user-guide/writing-tests.adoc | 98 ++++ .../DefaultLocaleTimezoneExtensionDemo.java | 143 +++++ .../src/main/java/module-info.java | 1 + .../junit/jupiter/api/util/DefaultLocale.java | 115 ++++ .../api/util/DefaultLocaleExtension.java | 149 +++++ .../jupiter/api/util/DefaultTimeZone.java | 74 +++ .../api/util/DefaultTimeZoneExtension.java | 125 +++++ .../jupiter/api/util/JupiterLocaleUtils.java | 38 ++ .../jupiter/api/util/LocaleProvider.java | 24 + .../jupiter/api/util/ReadsDefaultLocale.java | 43 ++ .../api/util/ReadsDefaultTimeZone.java | 43 ++ .../jupiter/api/util/TimeZoneProvider.java | 24 + .../jupiter/api/util/WritesDefaultLocale.java | 43 ++ .../api/util/WritesDefaultTimeZone.java | 43 ++ .../junit/jupiter/api/util/package-info.java | 8 + .../jupiter/api/util/DefaultLocaleTests.java | 519 ++++++++++++++++++ .../api/util/DefaultTimeZoneTests.java | 379 +++++++++++++ .../junit-jupiter-api.expected.txt | 1 + 19 files changed, 1873 insertions(+), 4 deletions(-) create mode 100644 documentation/src/test/java/example/DefaultLocaleTimezoneExtensionDemo.java create mode 100644 junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultLocale.java create mode 100644 junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultLocaleExtension.java create mode 100644 junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultTimeZone.java create mode 100644 junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultTimeZoneExtension.java create mode 100644 junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/JupiterLocaleUtils.java create mode 100644 junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/LocaleProvider.java create mode 100644 junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/ReadsDefaultLocale.java create mode 100644 junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/ReadsDefaultTimeZone.java create mode 100644 junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/TimeZoneProvider.java create mode 100644 junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/WritesDefaultLocale.java create mode 100644 junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/WritesDefaultTimeZone.java create mode 100644 junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/package-info.java create mode 100644 jupiter-tests/src/test/java/org/junit/jupiter/api/util/DefaultLocaleTests.java create mode 100644 jupiter-tests/src/test/java/org/junit/jupiter/api/util/DefaultTimeZoneTests.java diff --git a/documentation/src/docs/asciidoc/release-notes/release-notes-6.1.0-M2.adoc b/documentation/src/docs/asciidoc/release-notes/release-notes-6.1.0-M2.adoc index e573a715c887..6d59a8b8e3cb 100644 --- a/documentation/src/docs/asciidoc/release-notes/release-notes-6.1.0-M2.adoc +++ b/documentation/src/docs/asciidoc/release-notes/release-notes-6.1.0-M2.adoc @@ -6,9 +6,7 @@ *Scope:* ❓ For a complete list of all _closed_ issues and pull requests for this release, consult the -link:{junit-framework-repo}+/milestone/112?closed=1+[6.1.0-M2] milestone page in the JUnit -repository on GitHub. - +link:{junit-framework-repo}+/milestone/112?closed=1+[6.1.0-M2] milestone page in the JUnit repository on GitHub. [[release-notes-6.1.0-M2-junit-platform]] === JUnit Platform @@ -28,7 +26,6 @@ repository on GitHub. * ❓ - [[release-notes-6.1.0-M2-junit-jupiter]] === JUnit Jupiter @@ -47,6 +44,8 @@ repository on GitHub. * `JAVA_27` has been added to the `JRE` enum for use with `JRE`-based execution conditions. +* https://www.junit-pioneer.org/[JUnit Pioneer]'s `DefaultLocaleExtension` and `DefaultTimeZoneExtension` are now part of the JUnit Jupiter. +Find an example at the <<../user-guide/index.adoc#writing-tests-built-in-extensions-DefaultLocaleAndTimeZone, User Guide>>. [[release-notes-6.1.0-M2-junit-vintage]] === JUnit Vintage diff --git a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc index 920b418c997e..3a091e7f759e 100644 --- a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc +++ b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc @@ -3824,3 +3824,101 @@ include::{testDir}/example/AutoCloseDemo.java[tags=user_guide_example] <1> Annotate an instance field with `@AutoClose`. <2> `WebClient` implements `java.lang.AutoCloseable` which defines a `close()` method that will be invoked after each `@Test` method. + +[[writing-tests-built-in-extensions-DefaultLocaleAndTimeZone]] +==== The @DefaultLocale and @DefaultTimeZone Extensions + +---- +include::{testDir}/example/DefaultLocaleTimezoneExtensionDemo.java[tags=default_locale_language] +---- + +The `@DefaultLocale` and `@DefaultTimeZone` annotations can be used to change the values returned from `Locale.getDefault()` and `TimeZone.getDefault()`, respectively, which are often used implicitly when no specific locale or time zone is chosen. +Both annotations work on the test class level and on the test method level, and are inherited from higher-level containers. +After the annotated element has been executed, the initial default value is restored. + +===== `@DefaultLocale` + +The default `Locale` can be specified using an {jdk-javadoc-base-url}/java.base/java/util/Locale.html#forLanguageTag-java.lang.String-[IETF BCP 47 language tag string] + +[source,java,indent=0] +---- +include::{testDir}/example/DefaultLocaleTimezoneExtensionDemo.java[tags=default_locale_language] +---- + +Alternatively the default `Locale` can be created using the following attributes of which a {jdk-javadoc-base-url}/java.base/java/util/Locale.Builder.html[Locale Builder] can create an instance with: + +* `language` or +* `language` and `country` or +* `language`, `country`, and `variant` + +NOTE: The variant needs to be a string which follows the https://www.rfc-editor.org/rfc/rfc5646.html[IETF BCP 47 / RFC 5646] syntax! + +[source,java,indent=0] +---- +include::{testDir}/example/DefaultLocaleTimezoneExtensionDemo.java[tag=default_locale_language_alternatives] +---- + +Note that mixing language tag configuration and constructor based configuration will cause an `ExtensionConfigurationException` to be thrown. +Furthermore, a `variant` can only be specified if `country` is also specified. +If `variant` is specified without `country`, an `ExtensionConfigurationException` will be thrown. + +Any method level `@DefaultLocale` configurations will override class level configurations. + +[source,java,indent=0] +---- +include::{testDir}/example/DefaultLocaleTimezoneExtensionDemo.java[tag=default_locale_class_level] +---- + +NOTE: A class-level configuration means that the specified locale is set before and reset after each individual test in the annotated class. + +If your use case is not covered, you can implement the `LocaleProvider` interface. + +[source,java,indent=0] +---- +include::{testDir}/example/DefaultLocaleTimezoneExtensionDemo.java[tag=default_locale_with_provider] +---- + +NOTE: The provider implementation must have a no-args (or the default) constructor. + +===== `@DefaultTimeZone` + +The default `TimeZone` is specified according to the https://docs.oracle.com/javase/8/docs/api/java/util/TimeZone.html#getTimeZone-java.lang.String-[TimeZone.getTimeZone(String)] method. + +[source,java,indent=0] +---- +include::{testDir}/example/DefaultLocaleTimezoneExtensionDemo.java[tag=default_timezone_zone] +---- + +Any method level `@DefaultTimeZone` configurations will override class level configurations: + +[source,java,indent=0] +---- +include::{testDir}/example/DefaultLocaleTimezoneExtensionDemo.java[tag=default_timezone_class_level] +---- + +NOTE: A class-level configuration means that the specified time zone is set before and reset after each individual test in the annotated class. + +If your use case is not covered, you can implement the `TimeZoneProvider` interface. + +[source,java,indent=0] +---- +include::{testDir}/example/DefaultLocaleTimezoneExtensionDemo.java[tag=default_time_zone_with_provider] +---- + +NOTE: The provider implementation must have a no-args (or the default) constructor. + +===== Thread-Safety + +Since default locale and time zone are global state, reading and writing them during https://docs.junit.org/current/user-guide/#writing-tests-parallel-execution[parallel test execution] can lead to unpredictable results and flaky tests. +The `@DefaultLocale` and `@DefaultTimeZone` extensions are prepared for that and tests annotated with them will never execute in parallel (thanks to https://docs.junit.org/current/api/org.junit.jupiter.api/org/junit/jupiter/api/parallel/ResourceLock.html[resource locks]) to guarantee correct test results. + +However, this does not cover all possible cases. +Tested code that reads or writes default locale and time zone _independently_ of the extensions can still run in parallel to them and may thus behave erratically when, for example, it unexpectedly reads a locale set by the extension in another thread. +Tests that cover code that reads or writes the default locale or time zone need to be annotated with the respective annotation: + +* `@ReadsDefaultLocale` +* `@ReadsDefaultTimeZone` +* `@WritesDefaultLocale` +* `@WritesDefaultTimeZone` + +Tests annotated in this way will never execute in parallel with tests annotated with `@DefaultLocale` or `@DefaultTimeZone`. diff --git a/documentation/src/test/java/example/DefaultLocaleTimezoneExtensionDemo.java b/documentation/src/test/java/example/DefaultLocaleTimezoneExtensionDemo.java new file mode 100644 index 000000000000..75a9281c6bf3 --- /dev/null +++ b/documentation/src/test/java/example/DefaultLocaleTimezoneExtensionDemo.java @@ -0,0 +1,143 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package example; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.ZoneOffset; +import java.util.Locale; +import java.util.TimeZone; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.util.DefaultLocale; +import org.junit.jupiter.api.util.DefaultTimeZone; +import org.junit.jupiter.api.util.LocaleProvider; +import org.junit.jupiter.api.util.TimeZoneProvider; + +public class DefaultLocaleTimezoneExtensionDemo { + + // tag::default_locale_language[] + @Test + @DefaultLocale("zh-Hant-TW") + void test_with_language() { + assertThat(Locale.getDefault()).isEqualTo(Locale.forLanguageTag("zh-Hant-TW")); + } + // end::default_locale_language[] + + // tag::default_locale_language_alternatives[] + @Test + @DefaultLocale(language = "en") + void test_with_language_only() { + assertThat(Locale.getDefault()).isEqualTo(new Locale.Builder().setLanguage("en").build()); + } + + @Test + @DefaultLocale(language = "en", country = "EN") + void test_with_language_and_country() { + assertThat(Locale.getDefault()).isEqualTo(new Locale.Builder().setLanguage("en").setRegion("EN").build()); + } + + @Test + @DefaultLocale(language = "ja", country = "JP", variant = "japanese") + void test_with_language_and_country_and_vairant() { + assertThat(Locale.getDefault()).isEqualTo( + new Locale.Builder().setLanguage("ja").setRegion("JP").setVariant("japanese").build()); + } + // end::default_locale_language_alternatives[] + + @Nested + // tag::default_locale_class_level[] + @DefaultLocale(language = "fr") + class MyLocaleTests { + + @Test + void test_with_class_level_configuration() { + assertThat(Locale.getDefault()).isEqualTo(new Locale.Builder().setLanguage("fr").build()); + } + + @Test + @DefaultLocale(language = "en") + void test_with_method_level_configuration() { + assertThat(Locale.getDefault()).isEqualTo(new Locale.Builder().setLanguage("en").build()); + } + + } + // end::default_locale_class_level[] + + // tag::default_locale_with_provider[] + @Test + @DefaultLocale(localeProvider = EnglishProvider.class) + void test_with_locale_provider() { + assertThat(Locale.getDefault()).isEqualTo(new Locale.Builder().setLanguage("en").build()); + } + + static class EnglishProvider implements LocaleProvider { + + @Override + public Locale get() { + return Locale.ENGLISH; + } + + } + // end::default_locale_with_provider[] + + // tag::default_timezone_zone[] + @Test + @DefaultTimeZone("CET") + void test_with_short_zone_id() { + assertThat(TimeZone.getDefault()).isEqualTo(TimeZone.getTimeZone("CET")); + } + + @Test + @DefaultTimeZone("Africa/Juba") + void test_with_long_zone_id() { + assertThat(TimeZone.getDefault()).isEqualTo(TimeZone.getTimeZone("Africa/Juba")); + } + // end::default_timezone_zone[] + + @Nested + // tag::default_timezone_class_level[] + @DefaultTimeZone("CET") + class MyTimeZoneTests { + + @Test + void test_with_class_level_configuration() { + assertThat(TimeZone.getDefault()).isEqualTo(TimeZone.getTimeZone("CET")); + } + + @Test + @DefaultTimeZone("Africa/Juba") + void test_with_method_level_configuration() { + assertThat(TimeZone.getDefault()).isEqualTo(TimeZone.getTimeZone("Africa/Juba")); + } + + } + // end::default_timezone_class_level[] + + // tag::default_time_zone_with_provider[] + @Test + @DefaultTimeZone(timeZoneProvider = UtcTimeZoneProvider.class) + void test_with_time_zone_provider() { + assertThat(TimeZone.getDefault()).isEqualTo(TimeZone.getTimeZone("UTC")); + } + + static class UtcTimeZoneProvider implements TimeZoneProvider { + + @Override + public TimeZone get() { + return TimeZone.getTimeZone(ZoneOffset.UTC); + } + + } + // end::default_time_zone_with_provider[] + +} diff --git a/junit-jupiter-api/src/main/java/module-info.java b/junit-jupiter-api/src/main/java/module-info.java index 08dea9c21f58..3b164bcaa5d9 100644 --- a/junit-jupiter-api/src/main/java/module-info.java +++ b/junit-jupiter-api/src/main/java/module-info.java @@ -31,6 +31,7 @@ exports org.junit.jupiter.api.io; exports org.junit.jupiter.api.parallel; exports org.junit.jupiter.api.timeout to org.junit.jupiter.engine; + exports org.junit.jupiter.api.util; opens org.junit.jupiter.api.condition to org.junit.platform.commons; } diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultLocale.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultLocale.java new file mode 100644 index 000000000000..e3bec9ad1009 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultLocale.java @@ -0,0 +1,115 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.api.util; + +import static org.apiguardian.api.API.Status.STABLE; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.apiguardian.api.API; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * {@code @DefaultLocale} is a JUnit Jupiter extension to change the value + * returned by {@link java.util.Locale#getDefault()} for a test execution. + * + *

The {@link java.util.Locale} to set as the default locale can be + * configured in several ways:

+ * + * + * + *

Please keep in mind that the {@code Locale.Builder} does a syntax check, if you use a variant! + * The given string must match the BCP 47 (or more detailed RFC 5646) syntax.

+ * + *

If a language tag is set, none of the other fields must be set. Otherwise, an + * {@link org.junit.jupiter.api.extension.ExtensionConfigurationException} will + * be thrown. Specifying a {@link #country()} but no {@link #language()}, or a + * {@link #variant()} but no {@link #country()} and {@link #language()} will + * also cause an {@code ExtensionConfigurationException}. After the annotated + * element has been executed, the default {@code Locale} will be restored to + * its original value.

+ * + *

{@code @DefaultLocale} can be used on the method and on the class level. It + * is inherited from higher-level containers, but can only be used once per method + * or class. If a class is annotated, the configured {@code Locale} will be the + * default {@code Locale} for all tests inside that class. Any method level + * configurations will override the class level default {@code Locale}.

+ * + *

During + * parallel test execution, + * all tests annotated with {@link DefaultLocale}, {@link ReadsDefaultLocale}, and {@link WritesDefaultLocale} + * are scheduled in a way that guarantees correctness under mutation of shared global state.

+ * + *

For more details and examples, see + * the documentation on @DefaultLocale and @DefaultTimeZone.

+ * + * @since 6.1 + * @see java.util.Locale#getDefault() + * @see DefaultTimeZone + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.TYPE }) +@Inherited +@WritesDefaultLocale +@API(status = STABLE, since = "6.1") +@ExtendWith(DefaultLocaleExtension.class) +public @interface DefaultLocale { + + /** + * A language tag string as specified by IETF BCP 47. See + * {@link java.util.Locale#forLanguageTag(String)} for more information + * about valid language tag values. + * + * @since 0.3 + */ + String value() default ""; + + /** + * An ISO 639 alpha-2 or alpha-3 language code, or a language subtag up to + * 8 characters in length. See the {@link java.util.Locale} class + * description about valid language values. + */ + String language() default ""; + + /** + * An ISO 3166 alpha-2 country code or a UN M.49 numeric-3 area code. See + * the {@link java.util.Locale} class description about valid country + * values. + */ + String country() default ""; + + /** + * An IETF BCP 47 language string that matches the RFC 5646 syntax. + * It's validated by the {@code Locale.Builder}, using {@code sun.util.locale.LanguageTag#isVariant}. + */ + String variant() default ""; + + /** + * A class implementing {@link LocaleProvider} to be used for custom {@code Locale} resolution. + * This is mutually exclusive with other properties, if any other property is given a value it + * will result in an {@link org.junit.jupiter.api.extension.ExtensionConfigurationException}. + */ + Class localeProvider() default LocaleProvider.NullLocaleProvider.class; + +} diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultLocaleExtension.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultLocaleExtension.java new file mode 100644 index 000000000000..46d3e90b55b6 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultLocaleExtension.java @@ -0,0 +1,149 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.api.util; + +import static org.apiguardian.api.API.Status.STABLE; + +import java.util.Locale; +import java.util.Optional; + +import org.apiguardian.api.API; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionConfigurationException; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ExtensionContext.Namespace; +import org.junit.jupiter.api.util.LocaleProvider.NullLocaleProvider; +import org.junit.platform.commons.support.AnnotationSupport; +import org.junit.platform.commons.support.ReflectionSupport; + +@API(status = STABLE, since = "6.1") +public final class DefaultLocaleExtension implements BeforeAllCallback, BeforeEachCallback, AfterEachCallback { + + private static final Namespace NAMESPACE = Namespace.create(DefaultLocaleExtension.class); + + private static final String CUSTOM_KEY = "CustomLocale"; + private static final String DEFAULT_KEY = "DefaultLocale"; + + public DefaultLocaleExtension() { + } + + @Override + public void beforeAll(ExtensionContext context) throws Exception { + createLocaleFromAnnotation(context) // + .ifPresent(locale -> store(context, CUSTOM_KEY, locale)); + } + + @Override + public void beforeEach(ExtensionContext context) { + createLocaleFromAnnotation(context) // + .or(() -> load(context, CUSTOM_KEY)) // + .ifPresent(locale -> setDefaultLocale(context, locale)); + } + + private void setDefaultLocale(ExtensionContext context, Locale customLocale) { + store(context, DEFAULT_KEY, Locale.getDefault()); + Locale.setDefault(customLocale); + } + + private static Optional createLocaleFromAnnotation(ExtensionContext context) { + return AnnotationSupport.findAnnotation(context.getElement(), DefaultLocale.class) // + .map(DefaultLocaleExtension::createLocale); + } + + private static Locale createLocale(DefaultLocale annotation) { + if (!annotation.value().isEmpty()) { + return createFromLanguageTag(annotation); + } + else if (!annotation.language().isEmpty()) { + return createFromParts(annotation); + } + else { + return getFromProvider(annotation); + } + } + + private static Locale createFromLanguageTag(DefaultLocale annotation) { + if (!annotation.language().isEmpty() || !annotation.country().isEmpty() || !annotation.variant().isEmpty() + || annotation.localeProvider() != NullLocaleProvider.class) { + throw new ExtensionConfigurationException( + "@DefaultLocale can only be used with language tag if language, country, variant and provider are not set"); + } + return Locale.forLanguageTag(annotation.value()); + } + + private static Locale createFromParts(DefaultLocale annotation) { + if (annotation.localeProvider() != NullLocaleProvider.class) + throw new ExtensionConfigurationException( + "@DefaultLocale can only be used with language tag if provider is not set"); + String language = annotation.language(); + String country = annotation.country(); + String variant = annotation.variant(); + if (!language.isEmpty() && !country.isEmpty() && !variant.isEmpty()) { + return JupiterLocaleUtils.createLocale(language, country, variant); + } + else if (!language.isEmpty() && !country.isEmpty()) { + return JupiterLocaleUtils.createLocale(language, country); + } + else if (!language.isEmpty() && variant.isEmpty()) { + return JupiterLocaleUtils.createLocale(language); + } + else { + throw new ExtensionConfigurationException( + "@DefaultLocale not configured correctly. When not using a language tag, specify either" + + " language, or language and country, or language and country and variant."); + } + } + + private static Locale getFromProvider(DefaultLocale annotation) { + if (!annotation.country().isEmpty() || !annotation.variant().isEmpty()) + throw new ExtensionConfigurationException( + "@DefaultLocale can only be used with a provider if value, language, country and variant are not set."); + var providerClass = annotation.localeProvider(); + LocaleProvider provider; + try { + provider = ReflectionSupport.newInstance(providerClass); + } + catch (Exception exception) { + throw new ExtensionConfigurationException( + "LocaleProvider instance could not be constructed because of an exception", exception); + } + return invoke(provider); + } + + @SuppressWarnings("ConstantValue") + private static Locale invoke(LocaleProvider provider) { + var locale = provider.get(); + if (locale == null) { + throw new ExtensionConfigurationException("LocaleProvider instance returned with null"); + } + return locale; + } + + @Override + public void afterEach(ExtensionContext context) { + load(context, DEFAULT_KEY).ifPresent(Locale::setDefault); + } + + private static void store(ExtensionContext context, String key, Locale value) { + getStore(context).put(key, value); + } + + private static Optional load(ExtensionContext context, String key) { + return Optional.ofNullable(getStore(context).get(key, Locale.class)); + } + + private static ExtensionContext.Store getStore(ExtensionContext context) { + return context.getStore(NAMESPACE); + } + +} diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultTimeZone.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultTimeZone.java new file mode 100644 index 000000000000..e69d0a83b667 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultTimeZone.java @@ -0,0 +1,74 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.api.util; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.apiguardian.api.API; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * {@code @DefaultTimeZone} is a JUnit Jupiter extension to change the value + * returned by {@link java.util.TimeZone#getDefault()} for a test execution. + * + *

The {@link java.util.TimeZone} to set as the default {@code TimeZone} is + * configured by specifying the {@code TimeZone} ID as defined by + * {@link java.util.TimeZone#getTimeZone(String)}. After the annotated element + * has been executed, the default {@code TimeZone} will be restored to its + * original value.

+ * + *

{@code @DefaultTimeZone} can be used on the method and on the class + * level. It is inherited from higher-level containers, but can only be used + * once per method or class. If a class is annotated, the configured + * {@code TimeZone} will be the default {@code TimeZone} for all tests inside + * that class. Any method level configurations will override the class level + * default {@code TimeZone}.

+ * + *

During + * parallel test execution, + * all tests annotated with {@link DefaultTimeZone}, {@link ReadsDefaultTimeZone}, and {@link WritesDefaultTimeZone} + * are scheduled in a way that guarantees correctness under mutation of shared global state.

+ * + *

For more details and examples, see + * the documentation on @DefaultLocale and @DefaultTimeZone.

+ * + * @since 6.1 + * @see java.util.TimeZone#getDefault() + * @see DefaultLocale + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.TYPE }) +@Inherited +@WritesDefaultTimeZone +@API(status = API.Status.STABLE, since = "6.1") +@ExtendWith(DefaultTimeZoneExtension.class) +public @interface DefaultTimeZone { + + /** + * The ID for a {@code TimeZone}, either an abbreviation such as "PST", a + * full name such as "America/Los_Angeles", or a custom ID such as + * "GMT-8:00". Note that the support of abbreviations is for JDK 1.1.x + * compatibility only and full names should be used. + */ + String value() default ""; + + /** + * A class implementing {@link TimeZoneProvider} to be used for custom {@code TimeZone} resolution. + * This is mutually exclusive with other properties, if any other property is given a value it + * will result in an {@link org.junit.jupiter.api.extension.ExtensionConfigurationException}. + */ + Class timeZoneProvider() default TimeZoneProvider.NullTimeZoneProvider.class; + +} diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultTimeZoneExtension.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultTimeZoneExtension.java new file mode 100644 index 000000000000..a235f0cdeec8 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultTimeZoneExtension.java @@ -0,0 +1,125 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.api.util; + +import static org.apiguardian.api.API.Status.STABLE; + +import java.util.Optional; +import java.util.TimeZone; + +import org.apiguardian.api.API; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionConfigurationException; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ExtensionContext.Namespace; +import org.junit.jupiter.api.util.TimeZoneProvider.NullTimeZoneProvider; +import org.junit.platform.commons.support.AnnotationSupport; +import org.junit.platform.commons.support.ReflectionSupport; + +@API(status = STABLE, since = "6.1") +public final class DefaultTimeZoneExtension implements BeforeAllCallback, BeforeEachCallback, AfterEachCallback { + + private static final Namespace NAMESPACE = Namespace.create(DefaultTimeZoneExtension.class); + + private static final String CUSTOM_KEY = "CustomTimeZone"; + private static final String DEFAULT_KEY = "DefaultTimeZone"; + + public DefaultTimeZoneExtension() { + } + + @Override + public void beforeAll(ExtensionContext context) throws Exception { + createTimeZoneFromAnnotation(context) // + .ifPresent(timeZone -> store(context, CUSTOM_KEY, timeZone)); + } + + @Override + public void beforeEach(ExtensionContext context) { + createTimeZoneFromAnnotation(context) // + .or(() -> load(context, CUSTOM_KEY)) // + .ifPresent(timeZone -> setDefaultTimeZone(context, timeZone)); + } + + private void setDefaultTimeZone(ExtensionContext context, TimeZone customTimeZone) { + store(context, DEFAULT_KEY, TimeZone.getDefault()); + TimeZone.setDefault(customTimeZone); + } + + private static Optional createTimeZoneFromAnnotation(ExtensionContext context) { + return AnnotationSupport.findAnnotation(context.getElement(), DefaultTimeZone.class) // + .map(DefaultTimeZoneExtension::createTimeZone); + } + + private static TimeZone createTimeZone(DefaultTimeZone annotation) { + validateCorrectConfiguration(annotation); + + if (!annotation.value().isEmpty()) { + return createTimeZoneFromZoneId(annotation.value()); + } + else { + return createTimeZoneFromProvider(annotation.timeZoneProvider()); + } + } + + private static void validateCorrectConfiguration(DefaultTimeZone annotation) { + boolean noValue = annotation.value().isEmpty(); + boolean noProvider = annotation.timeZoneProvider() == NullTimeZoneProvider.class; + if (noValue == noProvider) { + throw new ExtensionConfigurationException( + "Either a valid time zone id or a TimeZoneProvider must be provided to " + + DefaultTimeZone.class.getSimpleName()); + } + } + + private static TimeZone createTimeZoneFromZoneId(String timeZoneId) { + TimeZone configuredTimeZone = TimeZone.getTimeZone(timeZoneId); + // TimeZone::getTimeZone returns with GMT as fallback if the given ID cannot be understood + if (configuredTimeZone.equals(TimeZone.getTimeZone("GMT")) && !"GMT".equals(timeZoneId)) { + throw new ExtensionConfigurationException(""" + @DefaultTimeZone not configured correctly. + Could not find the specified time zone + '%s'. + Please use correct identifiers, e.g. "GMT" for Greenwich Mean Time. + """.formatted(timeZoneId)); + } + return configuredTimeZone; + } + + private static TimeZone createTimeZoneFromProvider(Class providerClass) { + try { + TimeZoneProvider provider = ReflectionSupport.newInstance(providerClass); + return Optional.ofNullable(provider.get()).orElse(TimeZone.getTimeZone("GMT")); + } + catch (Exception exception) { + throw new ExtensionConfigurationException("Could not instantiate TimeZoneProvider because of exception", + exception); + } + } + + @Override + public void afterEach(ExtensionContext context) { + load(context, DEFAULT_KEY).ifPresent(TimeZone::setDefault); + } + + private static void store(ExtensionContext context, String key, TimeZone value) { + getStore(context).put(key, value); + } + + private static Optional load(ExtensionContext context, String key) { + return Optional.ofNullable(getStore(context).get(key, TimeZone.class)); + } + + private static ExtensionContext.Store getStore(ExtensionContext context) { + return context.getStore(NAMESPACE); + } + +} diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/JupiterLocaleUtils.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/JupiterLocaleUtils.java new file mode 100644 index 000000000000..423d7445fb70 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/JupiterLocaleUtils.java @@ -0,0 +1,38 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.api.util; + +import java.util.Locale; + +/** + * Utility class to create {@code Locale}. + * + * @since 6.1 + */ +class JupiterLocaleUtils { + + private JupiterLocaleUtils() { + // private constructor to prevent instantiation of utility class + } + + public static Locale createLocale(String language, String country, String variant) { + return new Locale.Builder().setLanguage(language).setRegion(country).setVariant(variant).build(); + } + + public static Locale createLocale(String language, String country) { + return new Locale.Builder().setLanguage(language).setRegion(country).build(); + } + + public static Locale createLocale(String language) { + return new Locale.Builder().setLanguage(language).build(); + } + +} diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/LocaleProvider.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/LocaleProvider.java new file mode 100644 index 000000000000..5916f482e307 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/LocaleProvider.java @@ -0,0 +1,24 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.api.util; + +import java.util.Locale; +import java.util.function.Supplier; + +import org.apiguardian.api.API; + +@API(status = API.Status.STABLE, since = "6.1") +public interface LocaleProvider extends Supplier { + + interface NullLocaleProvider extends LocaleProvider { + } + +} diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/ReadsDefaultLocale.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/ReadsDefaultLocale.java new file mode 100644 index 000000000000..6d2d313eee3a --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/ReadsDefaultLocale.java @@ -0,0 +1,43 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.api.util; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.apiguardian.api.API; +import org.junit.jupiter.api.parallel.ResourceAccessMode; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.junit.jupiter.api.parallel.Resources; + +/** + * Marks tests that read the default locale but don't use the locale extension themselves. + * + *

During + * parallel test execution, + * all tests annotated with {@link DefaultLocale}, {@link ReadsDefaultLocale}, and {@link WritesDefaultLocale} + * are scheduled in a way that guarantees correctness under mutation of shared global state.

+ * + *

For more details and examples, see + * the documentation on @DefaultLocale and @DefaultTimeZone.

+ * + * @since 6.1 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.CONSTRUCTOR, ElementType.METHOD, ElementType.PACKAGE, ElementType.TYPE }) +@Inherited +@ResourceLock(value = Resources.LOCALE, mode = ResourceAccessMode.READ) +@API(status = API.Status.STABLE, since = "6.1") +public @interface ReadsDefaultLocale { +} diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/ReadsDefaultTimeZone.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/ReadsDefaultTimeZone.java new file mode 100644 index 000000000000..22d7b271e0f7 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/ReadsDefaultTimeZone.java @@ -0,0 +1,43 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.api.util; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.apiguardian.api.API; +import org.junit.jupiter.api.parallel.ResourceAccessMode; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.junit.jupiter.api.parallel.Resources; + +/** + * Marks tests that read the default time zone but don't use the time zone extension themselves. + * + *

During + * parallel test execution, + * all tests annotated with {@link DefaultTimeZone}, {@link ReadsDefaultTimeZone}, and {@link WritesDefaultTimeZone} + * are scheduled in a way that guarantees correctness under mutation of shared global state.

+ * + *

For more details and examples, see + * the documentation on @DefaultLocale and @DefaultTimeZone.

+ * + * @since 6.1 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.CONSTRUCTOR, ElementType.METHOD, ElementType.PACKAGE, ElementType.TYPE }) +@Inherited +@ResourceLock(value = Resources.TIME_ZONE, mode = ResourceAccessMode.READ) +@API(status = API.Status.STABLE, since = "6.1") +public @interface ReadsDefaultTimeZone { +} diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/TimeZoneProvider.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/TimeZoneProvider.java new file mode 100644 index 000000000000..8b876f2a80cf --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/TimeZoneProvider.java @@ -0,0 +1,24 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.api.util; + +import java.util.TimeZone; +import java.util.function.Supplier; + +import org.apiguardian.api.API; + +@API(status = API.Status.STABLE, since = "6.1") +public interface TimeZoneProvider extends Supplier { + + interface NullTimeZoneProvider extends TimeZoneProvider { + } + +} diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/WritesDefaultLocale.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/WritesDefaultLocale.java new file mode 100644 index 000000000000..73cfe3a1e1b7 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/WritesDefaultLocale.java @@ -0,0 +1,43 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.api.util; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.apiguardian.api.API; +import org.junit.jupiter.api.parallel.ResourceAccessMode; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.junit.jupiter.api.parallel.Resources; + +/** + * Marks tests that write the default locale but don't use the locale extension themselves. + * + *

During + * parallel test execution, + * all tests annotated with {@link DefaultLocale}, {@link ReadsDefaultLocale}, and {@link WritesDefaultLocale} + * are scheduled in a way that guarantees correctness under mutation of shared global state.

+ * + *

For more details and examples, see + * the documentation on @DefaultLocale and @DefaultTimeZone.

+ * + * @since 6.1 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.CONSTRUCTOR, ElementType.METHOD, ElementType.PACKAGE, ElementType.TYPE }) +@Inherited +@ResourceLock(value = Resources.LOCALE, mode = ResourceAccessMode.READ_WRITE) +@API(status = API.Status.STABLE, since = "6.1") +public @interface WritesDefaultLocale { +} diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/WritesDefaultTimeZone.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/WritesDefaultTimeZone.java new file mode 100644 index 000000000000..a32383232906 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/WritesDefaultTimeZone.java @@ -0,0 +1,43 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.api.util; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.apiguardian.api.API; +import org.junit.jupiter.api.parallel.ResourceAccessMode; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.junit.jupiter.api.parallel.Resources; + +/** + * Marks tests that write the default time zone but don't use the time zone extension themselves. + * + *

During + * parallel test execution, + * all tests annotated with {@link DefaultTimeZone}, {@link ReadsDefaultTimeZone}, and {@link WritesDefaultTimeZone} + * are scheduled in a way that guarantees correctness under mutation of shared global state.

+ * + *

For more details and examples, see + * the documentation on @DefaultLocale and @DefaultTimeZone.

+ * + * @since 6.1 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.CONSTRUCTOR, ElementType.METHOD, ElementType.PACKAGE, ElementType.TYPE }) +@Inherited +@ResourceLock(value = Resources.TIME_ZONE, mode = ResourceAccessMode.READ_WRITE) +@API(status = API.Status.STABLE, since = "6.1") +public @interface WritesDefaultTimeZone { +} diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/package-info.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/package-info.java new file mode 100644 index 000000000000..2b27a269fda0 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/package-info.java @@ -0,0 +1,8 @@ +/** + * This package contains all files of the DefaultLocaleExtension and DefaultTimeZoneExtension. + */ + +@NullMarked +package org.junit.jupiter.api.util; + +import org.jspecify.annotations.NullMarked; diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/api/util/DefaultLocaleTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/api/util/DefaultLocaleTests.java new file mode 100644 index 000000000000..ee8fdf36f363 --- /dev/null +++ b/jupiter-tests/src/test/java/org/junit/jupiter/api/util/DefaultLocaleTests.java @@ -0,0 +1,519 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.api.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.platform.engine.discovery.DiscoverySelectors.selectMethod; +import static org.junit.platform.testkit.engine.EventConditions.finishedSuccessfully; +import static org.junit.platform.testkit.engine.EventConditions.finishedWithFailure; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.instanceOf; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.message; + +import java.util.Locale; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.ExtensionConfigurationException; +import org.junit.jupiter.engine.AbstractJupiterTestEngineTests; +import org.junit.platform.testkit.engine.EngineExecutionResults; + +@DisplayName("DefaultLocale extension") +class DefaultLocaleTests extends AbstractJupiterTestEngineTests { + + private static Locale TEST_DEFAULT_LOCALE; + private static Locale DEFAULT_LOCALE_BEFORE_TEST; + + @BeforeAll + static void globalSetUp() { + DEFAULT_LOCALE_BEFORE_TEST = Locale.getDefault(); + TEST_DEFAULT_LOCALE = JupiterLocaleUtils.createLocale("custom"); + Locale.setDefault(TEST_DEFAULT_LOCALE); + } + + @AfterAll + static void globalTearDown() { + Locale.setDefault(DEFAULT_LOCALE_BEFORE_TEST); + } + + @Nested + @DisplayName("applied on the method level") + class MethodLevelTests { + + @Test + @ReadsDefaultLocale + @DisplayName("does nothing when annotation is not present") + void testDefaultLocaleNoAnnotation() { + assertThat(Locale.getDefault()).isEqualTo(TEST_DEFAULT_LOCALE); + } + + @Test + @DefaultLocale("zh-Hant-TW") + @DisplayName("sets the default locale using a language tag") + void setsLocaleViaLanguageTag() { + assertThat(Locale.getDefault()).isEqualTo(Locale.forLanguageTag("zh-Hant-TW")); + } + + @Test + @DefaultLocale(language = "en") + @DisplayName("sets the default locale using a language") + void setsLanguage() { + assertThat(Locale.getDefault()).isEqualTo(JupiterLocaleUtils.createLocale("en")); + } + + @Test + @DefaultLocale(language = "en", country = "EN") + @DisplayName("sets the default locale using a language and a country") + void setsLanguageAndCountry() { + assertThat(Locale.getDefault()).isEqualTo(JupiterLocaleUtils.createLocale("en", "EN")); + } + + /** + * A valid variant checked by {@link sun.util.locale.LanguageTag#isVariant} against BCP 47 (or more detailed RFC 5646) matches either {@code [0-9a-Z]{5-8}} or {@code [0-9][0-9a-Z]{3}}. + * It does NOT check if such a variant exists in real. + *
+ * The Locale-Builder accepts valid variants, concatenated by minus or underscore (minus will be transformed by the builder). + * This means "en-EN" is a valid languageTag, but not a valid IETF BCP 47 variant subtag. + *
+ * This is very confusing as the official page for supported locales shows that japanese locales return {@code *} or {@code JP} as a variant. + * Even more confusing the enum values {@code Locale.JAPAN} and {@code Locale.JAPANESE} don't return a variant. + * + * @see RFC 5646 + */ + @Test + @DefaultLocale(language = "ja", country = "JP", variant = "japanese") + @DisplayName("sets the default locale using a language, a country and a variant") + void setsLanguageAndCountryAndVariant() { + assertThat(Locale.getDefault()).isEqualTo(JupiterLocaleUtils.createLocale("ja", "JP", "japanese")); + } + + } + + @Test + @WritesDefaultLocale + @DisplayName("applied on the class level, should execute tests with configured Locale") + void shouldExecuteTestsWithConfiguredLocale() { + EngineExecutionResults results = executeTestsForClass(ClassLevelTestCases.class); + + results.testEvents().assertThatEvents().haveAtMost(2, finishedSuccessfully()); + } + + @DefaultLocale(language = "fr", country = "FR") + static class ClassLevelTestCases { + + @Test + @ReadsDefaultLocale + void shouldExecuteWithClassLevelLocale() { + assertThat(Locale.getDefault()).isEqualTo(JupiterLocaleUtils.createLocale("fr", "FR")); + } + + @Test + @DefaultLocale(language = "de", country = "DE") + void shouldBeOverriddenWithMethodLevelLocale() { + assertThat(Locale.getDefault()).isEqualTo(JupiterLocaleUtils.createLocale("de", "DE")); + } + + } + + @Nested + @DefaultLocale(language = "en") + @DisplayName("with nested classes") + class NestedDefaultLocaleTests { + + @Nested + @DisplayName("without DefaultLocale annotation") + class NestedClass { + + @Test + @DisplayName("DefaultLocale should be set from enclosed class when it is not provided in nested") + void shouldSetLocaleFromEnclosedClass() { + assertThat(Locale.getDefault().getLanguage()).isEqualTo("en"); + } + + } + + @Nested + @DefaultLocale(language = "de") + @DisplayName("with DefaultLocale annotation") + class AnnotatedNestedClass { + + @Test + @DisplayName("DefaultLocale should be set from nested class when it is provided") + void shouldSetLocaleFromNestedClass() { + assertThat(Locale.getDefault().getLanguage()).isEqualTo("de"); + } + + @Test + @DefaultLocale(language = "ch") + @DisplayName("DefaultLocale should be set from method when it is provided") + void shouldSetLocaleFromMethodOfNestedClass() { + assertThat(Locale.getDefault().getLanguage()).isEqualTo("ch"); + } + + } + + } + + @Nested + @DefaultLocale(language = "fi") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + @DisplayName("correctly sets/resets before/after each/all extension points") + class ResettingDefaultLocaleTests { + + @Nested + @DefaultLocale(language = "de") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class ResettingDefaultLocaleNestedTests { + + @Test + @DefaultLocale(language = "en") + void setForTestMethod() { + // only here to set the locale, so another test can verify whether it was reset; + // still, better to assert the value was actually set + assertThat(Locale.getDefault().getLanguage()).isEqualTo("en"); + } + + @AfterAll + @ReadsDefaultLocale + void resetAfterTestMethodExecution() { + assertThat(Locale.getDefault().getLanguage()).isEqualTo("custom"); + } + + } + + @AfterAll + @ReadsDefaultLocale + void resetAfterTestMethodExecution() { + assertThat(Locale.getDefault().getLanguage()).isEqualTo("custom"); + } + + } + + @DefaultLocale(language = "en") + static class ClassLevelResetTestCase { + + @Test + void setForTestMethod() { + // only here to set the locale, so another test can verify whether it was reset; + // still, better to assert the value was actually set + assertThat(Locale.getDefault().getLanguage()).isEqualTo("en"); + } + + } + + @Nested + @DisplayName("when configured incorrect") + class ConfigurationFailureTests { + + @Nested + @DisplayName("on the method level") + class MethodLevel { + + @Test + @DisplayName("should fail when nothing is configured") + void shouldFailWhenNothingIsConfigured() { + EngineExecutionResults results = executeTests( + selectMethod(MethodLevelInitializationFailureTestCases.class, "shouldFailMissingConfiguration")); + + results.testEvents().assertThatEvents().haveAtMost(1, + finishedWithFailure(instanceOf(ExtensionConfigurationException.class))); + } + + @Test + @DisplayName("should fail when variant is set but country is not") + void shouldFailWhenVariantIsSetButCountryIsNot() { + EngineExecutionResults results = executeTests( + selectMethod(MethodLevelInitializationFailureTestCases.class, "shouldFailMissingCountry")); + + results.testEvents().assertThatEvents().haveAtMost(1, + finishedWithFailure(instanceOf(ExtensionConfigurationException.class))); + } + + @Test + @DisplayName("should fail when languageTag and language is set") + void shouldFailWhenLanguageTagAndLanguageIsSet() { + EngineExecutionResults results = executeTests( + selectMethod(MethodLevelInitializationFailureTestCases.class, "shouldFailLanguageTagAndLanguage")); + + results.testEvents().assertThatEvents().haveAtMost(1, + finishedWithFailure(instanceOf(ExtensionConfigurationException.class))); + } + + @Test + @DisplayName("should fail when languageTag and country is set") + void shouldFailWhenLanguageTagAndCountryIsSet() { + EngineExecutionResults results = executeTests( + selectMethod(MethodLevelInitializationFailureTestCases.class, "shouldFailLanguageTagAndCountry")); + + results.testEvents().assertThatEvents().haveAtMost(1, + finishedWithFailure(instanceOf(ExtensionConfigurationException.class))); + } + + @Test + @DisplayName("should fail when languageTag and variant is set") + void shouldFailWhenLanguageTagAndVariantIsSet() { + EngineExecutionResults results = executeTests( + selectMethod(MethodLevelInitializationFailureTestCases.class, "shouldFailLanguageTagAndVariant")); + + results.testEvents().assertThatEvents().haveAtMost(1, + finishedWithFailure(instanceOf(ExtensionConfigurationException.class))); + } + + @Test + @DisplayName("should fail when invalid BCP 47 variant is set") + void shouldFailIfNoValidBCP47VariantIsSet() { + EngineExecutionResults results = executeTests( + selectMethod(MethodLevelInitializationFailureTestCases.class, "shouldFailNoValidBCP47Variant")); + + results.testEvents().assertThatEvents().haveAtMost(1, + finishedWithFailure(instanceOf(ExtensionConfigurationException.class))); + } + + } + + @Nested + @DisplayName("on the class level") + class ClassLevel { + + @Test + @DisplayName("should fail when variant is set but country is not") + void shouldFailWhenVariantIsSetButCountryIsNot() { + EngineExecutionResults results = executeTestsForClass(ClassLevelInitializationFailureTestCases.class); + + results.testEvents().assertThatEvents().haveAtMost(1, + finishedWithFailure(instanceOf(ExtensionConfigurationException.class))); + } + + } + + } + + static class MethodLevelInitializationFailureTestCases { + + @Test + @DefaultLocale + void shouldFailMissingConfiguration() { + } + + @Test + @DefaultLocale(language = "de", variant = "ch") + void shouldFailMissingCountry() { + } + + @Test + @DefaultLocale(value = "Something", language = "de") + void shouldFailLanguageTagAndLanguage() { + } + + @Test + @DefaultLocale(value = "Something", country = "DE") + void shouldFailLanguageTagAndCountry() { + } + + @Test + @DefaultLocale(value = "Something", variant = "ch") + void shouldFailLanguageTagAndVariant() { + } + + @Test + @DefaultLocale(variant = "en-GB") + void shouldFailNoValidBCP47Variant() { + } + + } + + @DefaultLocale(language = "de", variant = "ch") + static class ClassLevelInitializationFailureTestCases { + + @Test + void shouldFail() { + } + + } + + @Nested + @DisplayName("used with inheritance") + class InheritanceTests extends InheritanceBaseTest { + + @Test + @DisplayName("should inherit default locale annotation") + void shouldInheritClearAndSetProperty() { + assertThat(Locale.getDefault()).isEqualTo(JupiterLocaleUtils.createLocale("fr", "FR")); + } + + } + + @DefaultLocale(language = "fr", country = "FR") + static class InheritanceBaseTest { + + } + + @Nested + @DisplayName("when used with a locale provider") + class LocaleProviderTests { + + @Test + @DisplayName("can get a basic locale from provider") + @DefaultLocale(localeProvider = BasicLocaleProvider.class) + void canUseProvider() { + assertThat(Locale.getDefault()).isEqualTo(Locale.FRENCH); + } + + @Test + @ReadsDefaultLocale + @DisplayName("throws a NullPointerException with custom message if provider returns null") + void providerReturnsNull() { + EngineExecutionResults results = executeTests(selectMethod(BadProviderTestCases.class, "returnsNull")); + + results.testEvents().assertThatEvents().haveAtMost(1, + finishedWithFailure(instanceOf(NullPointerException.class), + message(it -> it.contains("LocaleProvider instance returned with null")))); + } + + @Test + @ReadsDefaultLocale + @DisplayName("throws an ExtensionConfigurationException if any other option is present") + void mutuallyExclusiveWithValue() { + EngineExecutionResults results = executeTests( + selectMethod(BadProviderTestCases.class, "mutuallyExclusiveWithValue")); + + results.testEvents().assertThatEvents().haveAtMost(1, + finishedWithFailure(instanceOf(ExtensionConfigurationException.class), message(it -> it.contains( + "can only be used with a provider if value, language, country and variant are not set.")))); + } + + @Test + @ReadsDefaultLocale + @DisplayName("throws an ExtensionConfigurationException if any other option is present") + void mutuallyExclusiveWithLanguage() { + EngineExecutionResults results = executeTests( + selectMethod(BadProviderTestCases.class, "mutuallyExclusiveWithLanguage")); + + results.testEvents().assertThatEvents().haveAtMost(1, + finishedWithFailure(instanceOf(ExtensionConfigurationException.class), + message(it -> it.contains("can only be used with language tag if provider is not set.")))); + } + + @Test + @ReadsDefaultLocale + @DisplayName("throws an ExtensionConfigurationException if any other option is present") + void mutuallyExclusiveWithCountry() { + EngineExecutionResults results = executeTests( + selectMethod(BadProviderTestCases.class, "mutuallyExclusiveWithCountry")); + + results.testEvents().assertThatEvents().haveAtMost(1, + finishedWithFailure(instanceOf(ExtensionConfigurationException.class), message(it -> it.contains( + "can only be used with a provider if value, language, country and variant are not set.")))); + } + + @Test + @ReadsDefaultLocale + @DisplayName("throws an ExtensionConfigurationException if any other option is present") + void mutuallyExclusiveWithVariant() { + EngineExecutionResults results = executeTests( + selectMethod(BadProviderTestCases.class, "mutuallyExclusiveWithVariant")); + + results.testEvents().assertThatEvents().haveAtMost(1, + finishedWithFailure(instanceOf(ExtensionConfigurationException.class), message(it -> it.contains( + "can only be used with a provider if value, language, country and variant are not set.")))); + } + + @Test + @ReadsDefaultLocale + @DisplayName("throws an ExtensionConfigurationException if localeProvider can't be constructed") + void badConstructor() { + EngineExecutionResults results = executeTests(selectMethod(BadProviderTestCases.class, "badConstructor")); + + results.testEvents().assertThatEvents().haveAtMost(1, + finishedWithFailure(instanceOf(ExtensionConfigurationException.class), + message(it -> it.contains("could not be constructed because of an exception")))); + } + + } + + static class BadProviderTestCases { + + @Test + @DefaultLocale(value = "en", localeProvider = BasicLocaleProvider.class) + void mutuallyExclusiveWithValue() { + // can't have both a value and a provider + } + + @Test + @DefaultLocale(language = "en", localeProvider = BasicLocaleProvider.class) + void mutuallyExclusiveWithLanguage() { + // can't have both a language property and a provider + } + + @Test + @DefaultLocale(country = "EN", localeProvider = BasicLocaleProvider.class) + void mutuallyExclusiveWithCountry() { + // can't have both a country property and a provider + } + + @Test + @DefaultLocale(variant = "japanese", localeProvider = BasicLocaleProvider.class) + void mutuallyExclusiveWithVariant() { + // can't have both a variant property and a provider + } + + @Test + @DefaultLocale(localeProvider = ReturnsNullLocaleProvider.class) + void returnsNull() { + // provider should not return 'null' + } + + @Test + @DefaultLocale(localeProvider = BadConstructorLocaleProvider.class) + void badConstructor() { + // provider has to have a no-args constructor + } + + } + + static class BasicLocaleProvider implements LocaleProvider { + + @Override + public Locale get() { + return Locale.FRENCH; + } + + } + + static class ReturnsNullLocaleProvider implements LocaleProvider { + + @Override + @SuppressWarnings("NullAway") + public Locale get() { + return null; + } + + } + + static class BadConstructorLocaleProvider implements LocaleProvider { + + private final String language; + + BadConstructorLocaleProvider(String language) { + this.language = language; + } + + @Override + public Locale get() { + return Locale.forLanguageTag(language); + } + + } + +} diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/api/util/DefaultTimeZoneTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/api/util/DefaultTimeZoneTests.java new file mode 100644 index 000000000000..203cc508d67e --- /dev/null +++ b/jupiter-tests/src/test/java/org/junit/jupiter/api/util/DefaultTimeZoneTests.java @@ -0,0 +1,379 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.api.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.platform.engine.discovery.DiscoverySelectors.selectMethod; +import static org.junit.platform.testkit.engine.EventConditions.finishedWithFailure; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.instanceOf; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.message; + +import java.util.TimeZone; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.ExtensionConfigurationException; +import org.junit.jupiter.engine.AbstractJupiterTestEngineTests; +import org.junit.platform.testkit.engine.EngineExecutionResults; + +@DisplayName("DefaultTimeZone extension") +class DefaultTimeZoneTests extends AbstractJupiterTestEngineTests { + + private static TimeZone TEST_DEFAULT_TIMEZONE; + private static TimeZone DEFAULT_TIMEZONE_BEFORE_TEST; + + @BeforeAll + static void globalSetUp() { + // we set UTC as test time zone unless it is already + // the system's time zone; in that case we use UTC+12 + DEFAULT_TIMEZONE_BEFORE_TEST = TimeZone.getDefault(); + TimeZone utc = TimeZone.getTimeZone("UTC"); + TimeZone utcPlusTwelve = TimeZone.getTimeZone("GMT+12:00"); + if (DEFAULT_TIMEZONE_BEFORE_TEST.equals(utc)) + TimeZone.setDefault(utcPlusTwelve); + else + TimeZone.setDefault(utc); + TEST_DEFAULT_TIMEZONE = TimeZone.getDefault(); + } + + @AfterAll + static void globalTearDown() { + TimeZone.setDefault(DEFAULT_TIMEZONE_BEFORE_TEST); + } + + @Nested + @DisplayName("when applied on the method level") + class MethodLevelTests { + + @Test + @ReadsDefaultTimeZone + @DisplayName("does nothing when annotation is not present") + void doesNothingWhenAnnotationNotPresent() { + assertThat(TimeZone.getDefault()).isEqualTo(TEST_DEFAULT_TIMEZONE); + } + + @Test + @DefaultTimeZone("GMT") + @DisplayName("does not throw when explicitly set to GMT") + void doesNotThrowForExplicitGmt() { + assertThat(TimeZone.getDefault()).isEqualTo(TimeZone.getTimeZone("GMT")); + } + + @Test + @DefaultTimeZone("CET") + @DisplayName("sets the default time zone using an abbreviation") + void setsTimeZoneFromAbbreviation() { + assertThat(TimeZone.getDefault()).isEqualTo(TimeZone.getTimeZone("CET")); + } + + @Test + @DefaultTimeZone("America/Los_Angeles") + @DisplayName("sets the default time zone using a full name") + void setsTimeZoneFromFullName() { + assertThat(TimeZone.getDefault()).isEqualTo(TimeZone.getTimeZone("America/Los_Angeles")); + } + + } + + @Nested + @DefaultTimeZone("GMT-8:00") + @DisplayName("when applied on the class level") + class ClassLevelTestCases { + + @Test + @ReadsDefaultTimeZone + @DisplayName("sets the default time zone") + void shouldExecuteWithClassLevelTimeZone() { + assertThat(TimeZone.getDefault()).isEqualTo(TimeZone.getTimeZone("GMT-8:00")); + } + + @Test + @DefaultTimeZone("GMT-12:00") + @DisplayName("gets overridden by annotation on the method level") + void shouldBeOverriddenWithMethodLevelTimeZone() { + assertThat(TimeZone.getDefault()).isEqualTo(TimeZone.getTimeZone("GMT-12:00")); + } + + } + + @Nested + @DefaultTimeZone("GMT") + @DisplayName("when explicitly set to GMT on the class level") + class ExplicitGmtClassLevelTestCases { + + @Test + @DisplayName("does not throw and sets to GMT ") + void explicitGmt() { + assertThat(TimeZone.getDefault()).isEqualTo(TimeZone.getTimeZone("GMT")); + } + + } + + @Nested + @DefaultTimeZone("GMT-8:00") + @DisplayName("with nested classes") + class NestedTests { + + @Nested + @DisplayName("without DefaultTimeZone annotation") + class NestedClass { + + @Test + @ReadsDefaultTimeZone + @DisplayName("DefaultTimeZone should be set from enclosed class when it is not provided in nested") + public void shouldSetTimeZoneFromEnclosedClass() { + assertThat(TimeZone.getDefault()).isEqualTo(TimeZone.getTimeZone("GMT-8:00")); + } + + } + + @Nested + @DefaultTimeZone("GMT-12:00") + @DisplayName("with DefaultTimeZone annotation") + class AnnotatedNestedClass { + + @Test + @ReadsDefaultTimeZone + @DisplayName("DefaultTimeZone should be set from nested class when it is provided") + public void shouldSetTimeZoneFromNestedClass() { + assertThat(TimeZone.getDefault()).isEqualTo(TimeZone.getTimeZone("GMT-12:00")); + } + + @Test + @DefaultTimeZone("GMT-6:00") + @DisplayName("DefaultTimeZone should be set from method when it is provided") + public void shouldSetTimeZoneFromMethodOfNestedClass() { + assertThat(TimeZone.getDefault()).isEqualTo(TimeZone.getTimeZone("GMT-6:00")); + } + + } + + } + + @Nested + @DefaultTimeZone("GMT-12:00") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class ResettingDefaultTimeZoneTests { + + @Nested + @DefaultTimeZone("GMT-3:00") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class ResettingDefaultTimeZoneNestedTests { + + @Test + @DefaultTimeZone("GMT+6:00") + void setForTestMethod() { + // only here to set the time zone, so another test can verify whether it was reset; + // still, better to assert the value was actually set + assertThat(TimeZone.getDefault()).isEqualTo(TimeZone.getTimeZone("GMT+6:00")); + } + + @AfterAll + @ReadsDefaultTimeZone + void resetAfterTestMethodExecution() { + assertThat(TimeZone.getDefault()).isEqualTo(TEST_DEFAULT_TIMEZONE); + } + + } + + @AfterAll + @ReadsDefaultTimeZone + void resetAfterTestMethodExecution() { + assertThat(TimeZone.getDefault()).isEqualTo(TEST_DEFAULT_TIMEZONE); + } + + } + + @Nested + @DisplayName("when misconfigured") + class ConfigurationTests { + + @Test + @ReadsDefaultTimeZone + @DisplayName("on method level, throws exception") + void throwsWhenConfigurationIsBad() { + EngineExecutionResults results = executeTests( + selectMethod(BadMethodLevelConfigurationTestCases.class, "badConfiguration")); + results.testEvents().assertThatEvents().haveAtMost(1, + finishedWithFailure(instanceOf(ExtensionConfigurationException.class), + message(it -> it.contains("@DefaultTimeZone not configured correctly.")))); + } + + @Test + @ReadsDefaultTimeZone + @DisplayName("on class level, throws exception") + void shouldThrowWithBadConfiguration() { + EngineExecutionResults results = executeTestsForClass(BadClassLevelConfigurationTestCases.class); + + results.testEvents().assertThatEvents().haveAtMost(1, + finishedWithFailure(instanceOf(ExtensionConfigurationException.class), + message(it -> it.contains("@DefaultTimeZone not configured correctly.")))); + } + + @AfterEach + void verifyMisconfigurationSisNotChangeTimeZone() { + assertThat(TimeZone.getDefault()).isEqualTo(TEST_DEFAULT_TIMEZONE); + } + + } + + static class BadMethodLevelConfigurationTestCases { + + @Test + @DefaultTimeZone("Gibberish") + void badConfiguration() { + } + + } + + @DefaultTimeZone("Gibberish") + static class BadClassLevelConfigurationTestCases { + + @Test + void badConfiguration() { + } + + } + + @Nested + @DisplayName("used with inheritance") + class InheritanceTests extends InheritanceBaseTest { + + @Test + @DisplayName("should inherit default time zone annotation") + void shouldInheritClearAndSetProperty() { + assertThat(TimeZone.getDefault()).isEqualTo(TimeZone.getTimeZone("GMT-8:00")); + } + + } + + @DefaultTimeZone("GMT-8:00") + static class InheritanceBaseTest { + + } + + @Nested + @DisplayName("used with TimeZoneProvider") + class ProviderTests { + + @Test + @DisplayName("can get a basic time zone") + @DefaultTimeZone(timeZoneProvider = BasicTimeZoneProvider.class) + void canGetBasicTimeZone() { + assertThat(TimeZone.getDefault()).isEqualTo(TimeZone.getTimeZone("Europe/Prague")); + } + + @Test + @DisplayName("defaults to GMT if the provider returns null") + @DefaultTimeZone(timeZoneProvider = NullProvider.class) + void defaultToGmt() { + assertThat(TimeZone.getDefault()).isEqualTo(TimeZone.getTimeZone("GMT")); + } + + @Test + @ReadsDefaultTimeZone + @DisplayName("throws ExtensionConfigurationException if the provider is not the only option") + void throwsForMutuallyExclusiveOptions() { + EngineExecutionResults results = executeTests( + selectMethod(BadTimeZoneProviderTestCases.class, "notExclusive")); + + results.testEvents().assertThatEvents().haveAtMost(1, + finishedWithFailure(instanceOf(ExtensionConfigurationException.class), + message(it -> it.contains("Either a valid time zone id or a TimeZoneProvider must be provided")))); + } + + @Test + @ReadsDefaultTimeZone + @DisplayName("throws ExtensionConfigurationException if properties are empty") + void throwsForEmptyOptions() { + EngineExecutionResults results = executeTests(selectMethod(BadTimeZoneProviderTestCases.class, "empty")); + + results.testEvents().assertThatEvents().haveAtMost(1, + finishedWithFailure(instanceOf(ExtensionConfigurationException.class), + message(it -> it.contains("Either a valid time zone id or a TimeZoneProvider must be provided")))); + } + + @Test + @ReadsDefaultTimeZone + @DisplayName("throws ExtensionConfigurationException if the provider does not have a suitable constructor") + void throwsForBadConstructor() { + EngineExecutionResults results = executeTests( + selectMethod(BadTimeZoneProviderTestCases.class, "noConstructor")); + + results.testEvents().assertThatEvents().haveAtMost(1, + finishedWithFailure(instanceOf(ExtensionConfigurationException.class), + message(it -> it.contains("Could not instantiate TimeZoneProvider because of exception")))); + } + + } + + static class BadTimeZoneProviderTestCases { + + @Test + @DefaultTimeZone(value = "GMT", timeZoneProvider = BasicTimeZoneProvider.class) + void notExclusive() { + // can't have both a time zone value and a provider + } + + @Test + @DefaultTimeZone + void empty() { + // must have a provider or a time zone + } + + @Test + @DefaultTimeZone(timeZoneProvider = ComplicatedProvider.class) + void noConstructor() { + // provider has to have a no-args constructor + } + + } + + static class BasicTimeZoneProvider implements TimeZoneProvider { + + @Override + public TimeZone get() { + return TimeZone.getTimeZone("Europe/Prague"); + } + + } + + static class NullProvider implements TimeZoneProvider { + + @Override + @SuppressWarnings("NullAway") + public TimeZone get() { + return null; + } + + } + + static class ComplicatedProvider implements TimeZoneProvider { + + private final String timeZoneString; + + ComplicatedProvider(String timeZoneString) { + this.timeZoneString = timeZoneString; + } + + @Override + public TimeZone get() { + return TimeZone.getTimeZone(timeZoneString); + } + + } + +} diff --git a/platform-tooling-support-tests/projects/jar-describe-module/junit-jupiter-api.expected.txt b/platform-tooling-support-tests/projects/jar-describe-module/junit-jupiter-api.expected.txt index 7bbb648259dd..547a19d16a52 100644 --- a/platform-tooling-support-tests/projects/jar-describe-module/junit-jupiter-api.expected.txt +++ b/platform-tooling-support-tests/projects/jar-describe-module/junit-jupiter-api.expected.txt @@ -6,6 +6,7 @@ exports org.junit.jupiter.api.extension.support exports org.junit.jupiter.api.function exports org.junit.jupiter.api.io exports org.junit.jupiter.api.parallel +exports org.junit.jupiter.api.util requires java.base mandated requires kotlin.stdlib static requires org.apiguardian.api static transitive From 956b614ee4ea7c4619b6b6defd6816e19d3dae09 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Sun, 7 Dec 2025 13:44:07 +0100 Subject: [PATCH 2/3] Polish user guide and release notes --- .../src/docs/asciidoc/link-attributes.adoc | 9 +++ .../release-notes/release-notes-6.1.0-M2.adoc | 9 ++- .../asciidoc/user-guide/writing-tests.adoc | 78 +++++++++++-------- .../DefaultLocaleTimezoneExtensionDemo.java | 4 - 4 files changed, 60 insertions(+), 40 deletions(-) diff --git a/documentation/src/docs/asciidoc/link-attributes.adoc b/documentation/src/docs/asciidoc/link-attributes.adoc index 387355dbb30f..649b7d2e847d 100644 --- a/documentation/src/docs/asciidoc/link-attributes.adoc +++ b/documentation/src/docs/asciidoc/link-attributes.adoc @@ -133,6 +133,15 @@ endif::[] :TestMethodOrder: {javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/TestMethodOrder.html[@TestMethodOrder] :TestReporter: {javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/TestReporter.html[TestReporter] :TestTemplate: {javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/TestTemplate.html[@TestTemplate] +// @DefaultLocale and @DefaultTimeZone +:DefaultLocale: {javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/util/DefaultLocale.html[@DefaultLocale] +:DefaultTimeZone: {javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/util/DefaultTimeZone.html[@DefaultTimeZone] +:LocaleProvider: {javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/util/LocaleProvider.html[LocaleProvider] +:TimeZoneProvider: {javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/util/TimeZoneProvider.html[TimeZoneProvider] +:ReadsDefaultLocale: {javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/util/ReadsDefaultLocale.html[@ReadsDefaultLocale] +:ReadsDefaultTimeZone: {javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/util/ReadsDefaultTimeZone.html[@ReadsDefaultTimeZone] +:WritesDefaultLocale: {javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/util/WritesDefaultLocale.html[@WritesDefaultLocale] +:WritesDefaultTimeZone: {javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/util/WritesDefaultTimeZone.html[@WritesDefaultTimeZone] // Jupiter Parallel API :Execution: {javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/parallel/Execution.html[@Execution] :Isolated: {javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/parallel/Isolated.html[@Isolated] diff --git a/documentation/src/docs/asciidoc/release-notes/release-notes-6.1.0-M2.adoc b/documentation/src/docs/asciidoc/release-notes/release-notes-6.1.0-M2.adoc index 6d59a8b8e3cb..9a9ee9e870cc 100644 --- a/documentation/src/docs/asciidoc/release-notes/release-notes-6.1.0-M2.adoc +++ b/documentation/src/docs/asciidoc/release-notes/release-notes-6.1.0-M2.adoc @@ -6,7 +6,8 @@ *Scope:* ❓ For a complete list of all _closed_ issues and pull requests for this release, consult the -link:{junit-framework-repo}+/milestone/112?closed=1+[6.1.0-M2] milestone page in the JUnit repository on GitHub. +link:{junit-framework-repo}+/milestone/112?closed=1+[6.1.0-M2] milestone page in the JUnit +repository on GitHub. [[release-notes-6.1.0-M2-junit-platform]] === JUnit Platform @@ -43,9 +44,9 @@ link:{junit-framework-repo}+/milestone/112?closed=1+[6.1.0-M2] milestone page in ==== New Features and Improvements * `JAVA_27` has been added to the `JRE` enum for use with `JRE`-based execution conditions. - -* https://www.junit-pioneer.org/[JUnit Pioneer]'s `DefaultLocaleExtension` and `DefaultTimeZoneExtension` are now part of the JUnit Jupiter. -Find an example at the <<../user-guide/index.adoc#writing-tests-built-in-extensions-DefaultLocaleAndTimeZone, User Guide>>. +* https://www.junit-pioneer.org/[JUnit Pioneer]'s `DefaultLocaleExtension` and + `DefaultTimeZoneExtension` are now part of the JUnit Jupiter. Find examples in the + <<../user-guide/index.adoc#writing-tests-built-in-extensions-DefaultLocaleAndTimeZone, User Guide>>. [[release-notes-6.1.0-M2-junit-vintage]] === JUnit Vintage diff --git a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc index 3a091e7f759e..39ee8a9a570a 100644 --- a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc +++ b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc @@ -3828,50 +3828,55 @@ include::{testDir}/example/AutoCloseDemo.java[tags=user_guide_example] [[writing-tests-built-in-extensions-DefaultLocaleAndTimeZone]] ==== The @DefaultLocale and @DefaultTimeZone Extensions ----- -include::{testDir}/example/DefaultLocaleTimezoneExtensionDemo.java[tags=default_locale_language] ----- +The `{DefaultLocale}` and `{DefaultTimeZone}` annotations can be used to change the values +returned from `Locale.getDefault()` and `TimeZone.getDefault()`, respectively, which are +often used implicitly when no specific locale or time zone is chosen. Both annotations +work on the test class level and on the test method level, and are inherited from +higher-level containers. After the annotated element has been executed, the initial +default value is restored. -The `@DefaultLocale` and `@DefaultTimeZone` annotations can be used to change the values returned from `Locale.getDefault()` and `TimeZone.getDefault()`, respectively, which are often used implicitly when no specific locale or time zone is chosen. -Both annotations work on the test class level and on the test method level, and are inherited from higher-level containers. -After the annotated element has been executed, the initial default value is restored. +===== @DefaultLocale -===== `@DefaultLocale` - -The default `Locale` can be specified using an {jdk-javadoc-base-url}/java.base/java/util/Locale.html#forLanguageTag-java.lang.String-[IETF BCP 47 language tag string] +The default `Locale` can be specified using an +{jdk-javadoc-base-url}/java.base/java/util/Locale.html#forLanguageTag-java.lang.String-[IETF BCP 47 language tag string]. [source,java,indent=0] ---- include::{testDir}/example/DefaultLocaleTimezoneExtensionDemo.java[tags=default_locale_language] ---- -Alternatively the default `Locale` can be created using the following attributes of which a {jdk-javadoc-base-url}/java.base/java/util/Locale.Builder.html[Locale Builder] can create an instance with: +Alternatively, the default `Locale` can be created using the following attributes from +which a {jdk-javadoc-base-url}/java.base/java/util/Locale.Builder.html[`Locale.Builder`] +can create an instance: * `language` or * `language` and `country` or * `language`, `country`, and `variant` -NOTE: The variant needs to be a string which follows the https://www.rfc-editor.org/rfc/rfc5646.html[IETF BCP 47 / RFC 5646] syntax! +NOTE: The variant needs to be a string which follows the +https://www.rfc-editor.org/rfc/rfc5646.html[IETF BCP 47 / RFC 5646] syntax [source,java,indent=0] ---- include::{testDir}/example/DefaultLocaleTimezoneExtensionDemo.java[tag=default_locale_language_alternatives] ---- -Note that mixing language tag configuration and constructor based configuration will cause an `ExtensionConfigurationException` to be thrown. -Furthermore, a `variant` can only be specified if `country` is also specified. -If `variant` is specified without `country`, an `ExtensionConfigurationException` will be thrown. +Mixing language tag configuration (via the annotation's `value` attributed) and +attributed-based configuration will cause an exception to be thrown. Furthermore, a +`variant` can only be specified if `country` is also specified. Otherwise, an exception +will be thrown. -Any method level `@DefaultLocale` configurations will override class level configurations. +Any method-level `@DefaultLocale` configurations will override class-level configurations. [source,java,indent=0] ---- include::{testDir}/example/DefaultLocaleTimezoneExtensionDemo.java[tag=default_locale_class_level] ---- -NOTE: A class-level configuration means that the specified locale is set before and reset after each individual test in the annotated class. +NOTE: A class-level configuration means that the specified locale is set before and reset +after each individual test in the annotated class. -If your use case is not covered, you can implement the `LocaleProvider` interface. +If your use case is not covered, you can implement the `{LocaleProvider}` interface. [source,java,indent=0] ---- @@ -3880,9 +3885,11 @@ include::{testDir}/example/DefaultLocaleTimezoneExtensionDemo.java[tag=default_l NOTE: The provider implementation must have a no-args (or the default) constructor. -===== `@DefaultTimeZone` +===== @DefaultTimeZone -The default `TimeZone` is specified according to the https://docs.oracle.com/javase/8/docs/api/java/util/TimeZone.html#getTimeZone-java.lang.String-[TimeZone.getTimeZone(String)] method. +The default `TimeZone` is specified according to the +{jdk-javadoc-base-url}/java.base/java/util/TimeZone.html#getTimeZone(java.lang.String)[TimeZone.getTimeZone(String)] +method. [source,java,indent=0] ---- @@ -3896,9 +3903,10 @@ Any method level `@DefaultTimeZone` configurations will override class level con include::{testDir}/example/DefaultLocaleTimezoneExtensionDemo.java[tag=default_timezone_class_level] ---- -NOTE: A class-level configuration means that the specified time zone is set before and reset after each individual test in the annotated class. +NOTE: A class-level configuration means that the specified time zone is set before and +reset after each individual test in the annotated class. -If your use case is not covered, you can implement the `TimeZoneProvider` interface. +If your use case is not covered, you can implement the `{TimeZoneProvider}` interface. [source,java,indent=0] ---- @@ -3907,18 +3915,24 @@ include::{testDir}/example/DefaultLocaleTimezoneExtensionDemo.java[tag=default_t NOTE: The provider implementation must have a no-args (or the default) constructor. -===== Thread-Safety +===== Thread Safety -Since default locale and time zone are global state, reading and writing them during https://docs.junit.org/current/user-guide/#writing-tests-parallel-execution[parallel test execution] can lead to unpredictable results and flaky tests. -The `@DefaultLocale` and `@DefaultTimeZone` extensions are prepared for that and tests annotated with them will never execute in parallel (thanks to https://docs.junit.org/current/api/org.junit.jupiter.api/org/junit/jupiter/api/parallel/ResourceLock.html[resource locks]) to guarantee correct test results. +Since the default locale and time zone are global state, reading and writing them during +<> can lead to unpredictable +results and flaky tests. The `@DefaultLocale` and `@DefaultTimeZone` extensions are +prepared for that and tests annotated with them will never execute in parallel (thanks to +`{ResourceLock}`) to guarantee correct test results. -However, this does not cover all possible cases. -Tested code that reads or writes default locale and time zone _independently_ of the extensions can still run in parallel to them and may thus behave erratically when, for example, it unexpectedly reads a locale set by the extension in another thread. -Tests that cover code that reads or writes the default locale or time zone need to be annotated with the respective annotation: +However, this does not cover all possible cases. Tested code that reads or writes default +locale and time zone _independently_ of the extensions can still run in parallel to them +and may thus behave erratically when, for example, it unexpectedly reads a locale set by +the extension in another thread. Tests that cover code that reads or writes the default +locale or time zone need to be annotated with the respective annotation: -* `@ReadsDefaultLocale` -* `@ReadsDefaultTimeZone` -* `@WritesDefaultLocale` -* `@WritesDefaultTimeZone` +* `{ReadsDefaultLocale}` +* `{ReadsDefaultTimeZone}` +* `{WritesDefaultLocale}` +* `{WritesDefaultTimeZone}` -Tests annotated in this way will never execute in parallel with tests annotated with `@DefaultLocale` or `@DefaultTimeZone`. +Tests annotated in this way will never execute in parallel with tests annotated with +`@DefaultLocale` or `@DefaultTimeZone`. diff --git a/documentation/src/test/java/example/DefaultLocaleTimezoneExtensionDemo.java b/documentation/src/test/java/example/DefaultLocaleTimezoneExtensionDemo.java index 75a9281c6bf3..9f7912706406 100644 --- a/documentation/src/test/java/example/DefaultLocaleTimezoneExtensionDemo.java +++ b/documentation/src/test/java/example/DefaultLocaleTimezoneExtensionDemo.java @@ -81,12 +81,10 @@ void test_with_locale_provider() { } static class EnglishProvider implements LocaleProvider { - @Override public Locale get() { return Locale.ENGLISH; } - } // end::default_locale_with_provider[] @@ -131,12 +129,10 @@ void test_with_time_zone_provider() { } static class UtcTimeZoneProvider implements TimeZoneProvider { - @Override public TimeZone get() { return TimeZone.getTimeZone(ZoneOffset.UTC); } - } // end::default_time_zone_with_provider[] From 9888b92f8f0c57386204f870b49a02ec97fd2eb5 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Sun, 7 Dec 2025 14:16:56 +0100 Subject: [PATCH 3/3] Polish Javadoc and implementation visibility --- .../asciidoc/user-guide/writing-tests.adoc | 2 + .../src/main/java/module-info.java | 1 + .../junit/jupiter/api/util/DefaultLocale.java | 64 +++++++++++-------- .../api/util/DefaultLocaleExtension.java | 15 ++--- .../jupiter/api/util/DefaultTimeZone.java | 40 +++++++----- .../api/util/DefaultTimeZoneExtension.java | 15 ++--- .../jupiter/api/util/JupiterLocaleUtils.java | 2 +- .../jupiter/api/util/LocaleProvider.java | 14 ++-- .../jupiter/api/util/NullLocaleProvider.java | 17 +++++ .../api/util/NullTimeZoneProvider.java | 17 +++++ .../jupiter/api/util/ReadsDefaultLocale.java | 21 ++++-- .../api/util/ReadsDefaultTimeZone.java | 21 ++++-- .../jupiter/api/util/TimeZoneProvider.java | 14 ++-- .../jupiter/api/util/WritesDefaultLocale.java | 21 ++++-- .../api/util/WritesDefaultTimeZone.java | 21 ++++-- .../junit/jupiter/api/util/package-info.java | 5 +- .../junit-jupiter-api.expected.txt | 1 + 17 files changed, 187 insertions(+), 104 deletions(-) create mode 100644 junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/NullLocaleProvider.java create mode 100644 junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/NullTimeZoneProvider.java diff --git a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc index 39ee8a9a570a..18eddfd55b43 100644 --- a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc +++ b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc @@ -3835,6 +3835,7 @@ work on the test class level and on the test method level, and are inherited fro higher-level containers. After the annotated element has been executed, the initial default value is restored. +[[writing-tests-built-in-extensions-DefaultLocale]] ===== @DefaultLocale The default `Locale` can be specified using an @@ -3885,6 +3886,7 @@ include::{testDir}/example/DefaultLocaleTimezoneExtensionDemo.java[tag=default_l NOTE: The provider implementation must have a no-args (or the default) constructor. +[[writing-tests-built-in-extensions-DefaultTimeZone]] ===== @DefaultTimeZone The default `TimeZone` is specified according to the diff --git a/junit-jupiter-api/src/main/java/module-info.java b/junit-jupiter-api/src/main/java/module-info.java index 3b164bcaa5d9..daca4e18bdff 100644 --- a/junit-jupiter-api/src/main/java/module-info.java +++ b/junit-jupiter-api/src/main/java/module-info.java @@ -34,4 +34,5 @@ exports org.junit.jupiter.api.util; opens org.junit.jupiter.api.condition to org.junit.platform.commons; + opens org.junit.jupiter.api.util to org.junit.platform.commons; } diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultLocale.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultLocale.java index e3bec9ad1009..7b56aead2345 100644 --- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultLocale.java +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultLocale.java @@ -17,20 +17,21 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import java.util.Locale; import org.apiguardian.api.API; import org.junit.jupiter.api.extension.ExtendWith; /** - * {@code @DefaultLocale} is a JUnit Jupiter extension to change the value - * returned by {@link java.util.Locale#getDefault()} for a test execution. + * {@code @DefaultLocale} is a JUnit Jupiter extension for changing the value + * returned by {@link Locale#getDefault()} for a test execution. * - *

The {@link java.util.Locale} to set as the default locale can be - * configured in several ways:

+ *

The {@link Locale} to set as the default locale can be + * configured in several ways: * *

    - *
  • using a {@link java.util.Locale#forLanguageTag(String) language tag}
  • - *
  • using a {@link java.util.Locale.Builder Locale.Builder} together with + *
  • using a {@link Locale#forLanguageTag(String) language tag}
  • + *
  • using a {@link Locale.Builder Locale.Builder} together with *
      *
    • a language
    • *
    • a language and a county
    • @@ -39,8 +40,9 @@ * *
    * - *

    Please keep in mind that the {@code Locale.Builder} does a syntax check, if you use a variant! - * The given string must match the BCP 47 (or more detailed RFC 5646) syntax.

    + *

    Please keep in mind that the {@code Locale.Builder} does a syntax check, + * if you use a variant. The given string must match the BCP 47 (or more + * detailed RFC 5646) syntax. * *

    If a language tag is set, none of the other fields must be set. Otherwise, an * {@link org.junit.jupiter.api.extension.ExtensionConfigurationException} will @@ -48,24 +50,28 @@ * {@link #variant()} but no {@link #country()} and {@link #language()} will * also cause an {@code ExtensionConfigurationException}. After the annotated * element has been executed, the default {@code Locale} will be restored to - * its original value.

    + * its original value. * *

    {@code @DefaultLocale} can be used on the method and on the class level. It * is inherited from higher-level containers, but can only be used once per method * or class. If a class is annotated, the configured {@code Locale} will be the * default {@code Locale} for all tests inside that class. Any method level - * configurations will override the class level default {@code Locale}.

    + * configurations will override the class level default {@code Locale}. * *

    During - * parallel test execution, - * all tests annotated with {@link DefaultLocale}, {@link ReadsDefaultLocale}, and {@link WritesDefaultLocale} - * are scheduled in a way that guarantees correctness under mutation of shared global state.

    + * parallel test execution, + * all tests annotated with {@link DefaultLocale @DefaultLocale}, + * {@link ReadsDefaultLocale @ReadsDefaultLocale}, and + * {@link WritesDefaultLocale} are scheduled in a way that guarantees + * correctness under mutation of shared global state. * - *

    For more details and examples, see - * the documentation on @DefaultLocale and @DefaultTimeZone.

    + *

    For more details and examples, see the + * User Guide. * * @since 6.1 - * @see java.util.Locale#getDefault() + * @see Locale#getDefault() + * @see ReadsDefaultLocale + * @see WritesDefaultLocale * @see DefaultTimeZone */ @Retention(RetentionPolicy.RUNTIME) @@ -74,42 +80,44 @@ @WritesDefaultLocale @API(status = STABLE, since = "6.1") @ExtendWith(DefaultLocaleExtension.class) +@SuppressWarnings("exports") public @interface DefaultLocale { /** * A language tag string as specified by IETF BCP 47. See - * {@link java.util.Locale#forLanguageTag(String)} for more information - * about valid language tag values. - * - * @since 0.3 + * {@link Locale#forLanguageTag(String)} for more information about valid + * language tag values. */ String value() default ""; /** * An ISO 639 alpha-2 or alpha-3 language code, or a language subtag up to - * 8 characters in length. See the {@link java.util.Locale} class + * 8 characters in length. See the {@link Locale} class * description about valid language values. */ String language() default ""; /** * An ISO 3166 alpha-2 country code or a UN M.49 numeric-3 area code. See - * the {@link java.util.Locale} class description about valid country + * the {@link Locale} class description about valid country * values. */ String country() default ""; /** - * An IETF BCP 47 language string that matches the RFC 5646 syntax. - * It's validated by the {@code Locale.Builder}, using {@code sun.util.locale.LanguageTag#isVariant}. + * An IETF BCP 47 language string that matches the + * RFC 5646 + * syntax. It's validated by the {@code Locale.Builder}, using + * {@code sun.util.locale.LanguageTag#isVariant}. */ String variant() default ""; /** - * A class implementing {@link LocaleProvider} to be used for custom {@code Locale} resolution. - * This is mutually exclusive with other properties, if any other property is given a value it - * will result in an {@link org.junit.jupiter.api.extension.ExtensionConfigurationException}. + * A class implementing {@link LocaleProvider} to be used for custom + * {@code Locale} resolution. This is mutually exclusive with other + * properties, if any other property is given a value it will result in an + * {@link org.junit.jupiter.api.extension.ExtensionConfigurationException}. */ - Class localeProvider() default LocaleProvider.NullLocaleProvider.class; + Class localeProvider() default NullLocaleProvider.class; } diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultLocaleExtension.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultLocaleExtension.java index 46d3e90b55b6..e0c9a2d75f2f 100644 --- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultLocaleExtension.java +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultLocaleExtension.java @@ -10,35 +10,30 @@ package org.junit.jupiter.api.util; -import static org.apiguardian.api.API.Status.STABLE; - import java.util.Locale; import java.util.Optional; -import org.apiguardian.api.API; import org.junit.jupiter.api.extension.AfterEachCallback; import org.junit.jupiter.api.extension.BeforeAllCallback; import org.junit.jupiter.api.extension.BeforeEachCallback; import org.junit.jupiter.api.extension.ExtensionConfigurationException; import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.api.extension.ExtensionContext.Namespace; -import org.junit.jupiter.api.util.LocaleProvider.NullLocaleProvider; import org.junit.platform.commons.support.AnnotationSupport; import org.junit.platform.commons.support.ReflectionSupport; -@API(status = STABLE, since = "6.1") -public final class DefaultLocaleExtension implements BeforeAllCallback, BeforeEachCallback, AfterEachCallback { +/** + * @since 6.1 + */ +final class DefaultLocaleExtension implements BeforeAllCallback, BeforeEachCallback, AfterEachCallback { private static final Namespace NAMESPACE = Namespace.create(DefaultLocaleExtension.class); private static final String CUSTOM_KEY = "CustomLocale"; private static final String DEFAULT_KEY = "DefaultLocale"; - public DefaultLocaleExtension() { - } - @Override - public void beforeAll(ExtensionContext context) throws Exception { + public void beforeAll(ExtensionContext context) { createLocaleFromAnnotation(context) // .ifPresent(locale -> store(context, CUSTOM_KEY, locale)); } diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultTimeZone.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultTimeZone.java index e69d0a83b667..ec5d431a0363 100644 --- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultTimeZone.java +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultTimeZone.java @@ -10,50 +10,58 @@ package org.junit.jupiter.api.util; +import static org.apiguardian.api.API.Status.STABLE; + import java.lang.annotation.ElementType; import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import java.util.TimeZone; import org.apiguardian.api.API; import org.junit.jupiter.api.extension.ExtendWith; /** - * {@code @DefaultTimeZone} is a JUnit Jupiter extension to change the value - * returned by {@link java.util.TimeZone#getDefault()} for a test execution. + * {@code @DefaultTimeZone} is a JUnit Jupiter extension for changing the value + * returned by {@link TimeZone#getDefault()} for a test execution. * - *

    The {@link java.util.TimeZone} to set as the default {@code TimeZone} is - * configured by specifying the {@code TimeZone} ID as defined by - * {@link java.util.TimeZone#getTimeZone(String)}. After the annotated element - * has been executed, the default {@code TimeZone} will be restored to its - * original value.

    + *

    The {@link TimeZone} to set as the default {@code TimeZone} is configured + * by specifying the {@code TimeZone} ID as defined by + * {@link TimeZone#getTimeZone(String)}. After the annotated element has been + * executed, the default {@code TimeZone} will be restored to its original + * value. * *

    {@code @DefaultTimeZone} can be used on the method and on the class * level. It is inherited from higher-level containers, but can only be used * once per method or class. If a class is annotated, the configured * {@code TimeZone} will be the default {@code TimeZone} for all tests inside * that class. Any method level configurations will override the class level - * default {@code TimeZone}.

    + * default {@code TimeZone}. * *

    During - * parallel test execution, - * all tests annotated with {@link DefaultTimeZone}, {@link ReadsDefaultTimeZone}, and {@link WritesDefaultTimeZone} - * are scheduled in a way that guarantees correctness under mutation of shared global state.

    + * parallel test execution, + * all tests annotated with {@link DefaultTimeZone @DefaultTimeZone}, + * {@link ReadsDefaultTimeZone @ReadsDefaultTimeZone}, and + * {@link WritesDefaultTimeZone @WritesDefaultTimeZone} are scheduled in a way that + * guarantees correctness under mutation of shared global state. * - *

    For more details and examples, see - * the documentation on @DefaultLocale and @DefaultTimeZone.

    + *

    For more details and examples, see the + * User Guide. * * @since 6.1 - * @see java.util.TimeZone#getDefault() + * @see TimeZone#getDefault() + * @see ReadsDefaultTimeZone + * @see WritesDefaultTimeZone * @see DefaultLocale */ @Retention(RetentionPolicy.RUNTIME) @Target({ ElementType.METHOD, ElementType.TYPE }) @Inherited @WritesDefaultTimeZone -@API(status = API.Status.STABLE, since = "6.1") +@API(status = STABLE, since = "6.1") @ExtendWith(DefaultTimeZoneExtension.class) +@SuppressWarnings("exports") public @interface DefaultTimeZone { /** @@ -69,6 +77,6 @@ * This is mutually exclusive with other properties, if any other property is given a value it * will result in an {@link org.junit.jupiter.api.extension.ExtensionConfigurationException}. */ - Class timeZoneProvider() default TimeZoneProvider.NullTimeZoneProvider.class; + Class timeZoneProvider() default NullTimeZoneProvider.class; } diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultTimeZoneExtension.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultTimeZoneExtension.java index a235f0cdeec8..ca511918b9ba 100644 --- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultTimeZoneExtension.java +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultTimeZoneExtension.java @@ -10,35 +10,30 @@ package org.junit.jupiter.api.util; -import static org.apiguardian.api.API.Status.STABLE; - import java.util.Optional; import java.util.TimeZone; -import org.apiguardian.api.API; import org.junit.jupiter.api.extension.AfterEachCallback; import org.junit.jupiter.api.extension.BeforeAllCallback; import org.junit.jupiter.api.extension.BeforeEachCallback; import org.junit.jupiter.api.extension.ExtensionConfigurationException; import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.api.extension.ExtensionContext.Namespace; -import org.junit.jupiter.api.util.TimeZoneProvider.NullTimeZoneProvider; import org.junit.platform.commons.support.AnnotationSupport; import org.junit.platform.commons.support.ReflectionSupport; -@API(status = STABLE, since = "6.1") -public final class DefaultTimeZoneExtension implements BeforeAllCallback, BeforeEachCallback, AfterEachCallback { +/** + * @since 6.1 + */ +final class DefaultTimeZoneExtension implements BeforeAllCallback, BeforeEachCallback, AfterEachCallback { private static final Namespace NAMESPACE = Namespace.create(DefaultTimeZoneExtension.class); private static final String CUSTOM_KEY = "CustomTimeZone"; private static final String DEFAULT_KEY = "DefaultTimeZone"; - public DefaultTimeZoneExtension() { - } - @Override - public void beforeAll(ExtensionContext context) throws Exception { + public void beforeAll(ExtensionContext context) { createTimeZoneFromAnnotation(context) // .ifPresent(timeZone -> store(context, CUSTOM_KEY, timeZone)); } diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/JupiterLocaleUtils.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/JupiterLocaleUtils.java index 423d7445fb70..45fae038229b 100644 --- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/JupiterLocaleUtils.java +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/JupiterLocaleUtils.java @@ -17,7 +17,7 @@ * * @since 6.1 */ -class JupiterLocaleUtils { +final class JupiterLocaleUtils { private JupiterLocaleUtils() { // private constructor to prevent instantiation of utility class diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/LocaleProvider.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/LocaleProvider.java index 5916f482e307..8c7caa7bf02e 100644 --- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/LocaleProvider.java +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/LocaleProvider.java @@ -10,15 +10,19 @@ package org.junit.jupiter.api.util; +import static org.apiguardian.api.API.Status.STABLE; + import java.util.Locale; import java.util.function.Supplier; import org.apiguardian.api.API; -@API(status = API.Status.STABLE, since = "6.1") +/** + * Custom {@link Locale} provider for use with + * {@link DefaultLocale#localeProvider()}. + * + * @since 6.1 + */ +@API(status = STABLE, since = "6.1") public interface LocaleProvider extends Supplier { - - interface NullLocaleProvider extends LocaleProvider { - } - } diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/NullLocaleProvider.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/NullLocaleProvider.java new file mode 100644 index 000000000000..7b15e762322e --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/NullLocaleProvider.java @@ -0,0 +1,17 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.api.util; + +/** + * @since 6.1 + */ +interface NullLocaleProvider extends LocaleProvider { +} diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/NullTimeZoneProvider.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/NullTimeZoneProvider.java new file mode 100644 index 000000000000..7c5a09aac2bb --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/NullTimeZoneProvider.java @@ -0,0 +1,17 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.api.util; + +/** + * @since 6.1 + */ +interface NullTimeZoneProvider extends TimeZoneProvider { +} diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/ReadsDefaultLocale.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/ReadsDefaultLocale.java index 6d2d313eee3a..084abbe211a1 100644 --- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/ReadsDefaultLocale.java +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/ReadsDefaultLocale.java @@ -10,6 +10,8 @@ package org.junit.jupiter.api.util; +import static org.apiguardian.api.API.Status.STABLE; + import java.lang.annotation.ElementType; import java.lang.annotation.Inherited; import java.lang.annotation.Retention; @@ -22,22 +24,27 @@ import org.junit.jupiter.api.parallel.Resources; /** - * Marks tests that read the default locale but don't use the locale extension themselves. + * Marks tests that read the default locale but don't use the + * {@link DefaultLocale @DefaultLocale} extension themselves. * *

    During - * parallel test execution, - * all tests annotated with {@link DefaultLocale}, {@link ReadsDefaultLocale}, and {@link WritesDefaultLocale} - * are scheduled in a way that guarantees correctness under mutation of shared global state.

    + * parallel test execution, + * all tests annotated with {@link DefaultLocale @DefaultLocale}, + * {@link ReadsDefaultLocale @ReadsDefaultLocale}, and + * {@link WritesDefaultLocale} are scheduled in a way that guarantees + * correctness under mutation of shared global state. * - *

    For more details and examples, see - * the documentation on @DefaultLocale and @DefaultTimeZone.

    + *

    For more details and examples, see the + * User Guide. * * @since 6.1 + * @see DefaultLocale + * @see WritesDefaultLocale */ @Retention(RetentionPolicy.RUNTIME) @Target({ ElementType.CONSTRUCTOR, ElementType.METHOD, ElementType.PACKAGE, ElementType.TYPE }) @Inherited @ResourceLock(value = Resources.LOCALE, mode = ResourceAccessMode.READ) -@API(status = API.Status.STABLE, since = "6.1") +@API(status = STABLE, since = "6.1") public @interface ReadsDefaultLocale { } diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/ReadsDefaultTimeZone.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/ReadsDefaultTimeZone.java index 22d7b271e0f7..42f29d9132c7 100644 --- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/ReadsDefaultTimeZone.java +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/ReadsDefaultTimeZone.java @@ -10,6 +10,8 @@ package org.junit.jupiter.api.util; +import static org.apiguardian.api.API.Status.STABLE; + import java.lang.annotation.ElementType; import java.lang.annotation.Inherited; import java.lang.annotation.Retention; @@ -22,22 +24,27 @@ import org.junit.jupiter.api.parallel.Resources; /** - * Marks tests that read the default time zone but don't use the time zone extension themselves. + * Marks tests that read the default time zone but don't use the + * {@link DefaultTimeZone @DefaultTimeZone} extension themselves. * *

    During - * parallel test execution, - * all tests annotated with {@link DefaultTimeZone}, {@link ReadsDefaultTimeZone}, and {@link WritesDefaultTimeZone} - * are scheduled in a way that guarantees correctness under mutation of shared global state.

    + * parallel test execution, + * all tests annotated with {@link DefaultTimeZone @DefaultTimeZone}, + * {@link ReadsDefaultTimeZone @ReadsDefaultTimeZone}, and + * {@link WritesDefaultTimeZone @WritesDefaultTimeZone} are scheduled in a way that + * guarantees correctness under mutation of shared global state. * - *

    For more details and examples, see - * the documentation on @DefaultLocale and @DefaultTimeZone.

    + *

    For more details and examples, see the + * User Guide. * * @since 6.1 + * @see DefaultTimeZone + * @see WritesDefaultTimeZone */ @Retention(RetentionPolicy.RUNTIME) @Target({ ElementType.CONSTRUCTOR, ElementType.METHOD, ElementType.PACKAGE, ElementType.TYPE }) @Inherited @ResourceLock(value = Resources.TIME_ZONE, mode = ResourceAccessMode.READ) -@API(status = API.Status.STABLE, since = "6.1") +@API(status = STABLE, since = "6.1") public @interface ReadsDefaultTimeZone { } diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/TimeZoneProvider.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/TimeZoneProvider.java index 8b876f2a80cf..e2f2609b8e95 100644 --- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/TimeZoneProvider.java +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/TimeZoneProvider.java @@ -10,15 +10,19 @@ package org.junit.jupiter.api.util; +import static org.apiguardian.api.API.Status.STABLE; + import java.util.TimeZone; import java.util.function.Supplier; import org.apiguardian.api.API; -@API(status = API.Status.STABLE, since = "6.1") +/** + * Custom {@link TimeZone} provider for use with + * {@link DefaultTimeZone#timeZoneProvider()}. + * + * @since 6.1 + */ +@API(status = STABLE, since = "6.1") public interface TimeZoneProvider extends Supplier { - - interface NullTimeZoneProvider extends TimeZoneProvider { - } - } diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/WritesDefaultLocale.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/WritesDefaultLocale.java index 73cfe3a1e1b7..665fadf39866 100644 --- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/WritesDefaultLocale.java +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/WritesDefaultLocale.java @@ -10,6 +10,8 @@ package org.junit.jupiter.api.util; +import static org.apiguardian.api.API.Status.STABLE; + import java.lang.annotation.ElementType; import java.lang.annotation.Inherited; import java.lang.annotation.Retention; @@ -22,22 +24,27 @@ import org.junit.jupiter.api.parallel.Resources; /** - * Marks tests that write the default locale but don't use the locale extension themselves. + * Marks tests that write the default locale but don't use the + * {@link DefaultLocale @DefaultLocale} extension themselves. * *

    During - * parallel test execution, - * all tests annotated with {@link DefaultLocale}, {@link ReadsDefaultLocale}, and {@link WritesDefaultLocale} - * are scheduled in a way that guarantees correctness under mutation of shared global state.

    + * parallel test execution, + * all tests annotated with {@link DefaultLocale @DefaultLocale}, + * {@link ReadsDefaultLocale @ReadsDefaultLocale}, and + * {@link WritesDefaultLocale} are scheduled in a way that guarantees + * correctness under mutation of shared global state. * - *

    For more details and examples, see - * the documentation on @DefaultLocale and @DefaultTimeZone.

    + *

    For more details and examples, see the + * User Guide. * * @since 6.1 + * @see DefaultLocale + * @see ReadsDefaultLocale */ @Retention(RetentionPolicy.RUNTIME) @Target({ ElementType.CONSTRUCTOR, ElementType.METHOD, ElementType.PACKAGE, ElementType.TYPE }) @Inherited @ResourceLock(value = Resources.LOCALE, mode = ResourceAccessMode.READ_WRITE) -@API(status = API.Status.STABLE, since = "6.1") +@API(status = STABLE, since = "6.1") public @interface WritesDefaultLocale { } diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/WritesDefaultTimeZone.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/WritesDefaultTimeZone.java index a32383232906..b2a55ccafc4c 100644 --- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/WritesDefaultTimeZone.java +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/WritesDefaultTimeZone.java @@ -10,6 +10,8 @@ package org.junit.jupiter.api.util; +import static org.apiguardian.api.API.Status.STABLE; + import java.lang.annotation.ElementType; import java.lang.annotation.Inherited; import java.lang.annotation.Retention; @@ -22,22 +24,27 @@ import org.junit.jupiter.api.parallel.Resources; /** - * Marks tests that write the default time zone but don't use the time zone extension themselves. + * Marks tests that write the default time zone but don't use the + * {@link DefaultTimeZone @DefaultTimeZone} extension themselves. * *

    During - * parallel test execution, - * all tests annotated with {@link DefaultTimeZone}, {@link ReadsDefaultTimeZone}, and {@link WritesDefaultTimeZone} - * are scheduled in a way that guarantees correctness under mutation of shared global state.

    + * parallel test execution, + * all tests annotated with {@link DefaultTimeZone @DefaultTimeZone}, + * {@link ReadsDefaultTimeZone @ReadsDefaultTimeZone}, and + * {@link WritesDefaultTimeZone @WritesDefaultTimeZone} are scheduled in a way that + * guarantees correctness under mutation of shared global state. * - *

    For more details and examples, see - * the documentation on @DefaultLocale and @DefaultTimeZone.

    + *

    For more details and examples, see the + * User Guide. * * @since 6.1 + * @see DefaultTimeZone + * @see WritesDefaultTimeZone */ @Retention(RetentionPolicy.RUNTIME) @Target({ ElementType.CONSTRUCTOR, ElementType.METHOD, ElementType.PACKAGE, ElementType.TYPE }) @Inherited @ResourceLock(value = Resources.TIME_ZONE, mode = ResourceAccessMode.READ_WRITE) -@API(status = API.Status.STABLE, since = "6.1") +@API(status = STABLE, since = "6.1") public @interface WritesDefaultTimeZone { } diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/package-info.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/package-info.java index 2b27a269fda0..2106ecf7df7c 100644 --- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/package-info.java +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/package-info.java @@ -1,5 +1,8 @@ /** - * This package contains all files of the DefaultLocaleExtension and DefaultTimeZoneExtension. + * {@code java.util}-related support in JUnit Jupiter. + * + * @see org.junit.jupiter.api.util.DefaultLocale + * @see org.junit.jupiter.api.util.DefaultTimeZone */ @NullMarked diff --git a/platform-tooling-support-tests/projects/jar-describe-module/junit-jupiter-api.expected.txt b/platform-tooling-support-tests/projects/jar-describe-module/junit-jupiter-api.expected.txt index 547a19d16a52..50e4a7c4f920 100644 --- a/platform-tooling-support-tests/projects/jar-describe-module/junit-jupiter-api.expected.txt +++ b/platform-tooling-support-tests/projects/jar-describe-module/junit-jupiter-api.expected.txt @@ -15,3 +15,4 @@ requires org.junit.platform.commons transitive requires org.opentest4j transitive qualified exports org.junit.jupiter.api.timeout to org.junit.jupiter.engine qualified opens org.junit.jupiter.api.condition to org.junit.platform.commons +qualified opens org.junit.jupiter.api.util to org.junit.platform.commons