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 e573a715c887..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 @@ -9,7 +9,6 @@ For a complete list of all _closed_ issues and pull requests for this release, c 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 +27,6 @@ repository on GitHub. * ❓ - [[release-notes-6.1.0-M2-junit-jupiter]] === JUnit Jupiter @@ -46,7 +44,9 @@ repository on GitHub. ==== 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 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 920b418c997e..18eddfd55b43 100644 --- a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc +++ b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc @@ -3824,3 +3824,117 @@ 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 + +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. + +[[writing-tests-built-in-extensions-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]. + +[source,java,indent=0] +---- +include::{testDir}/example/DefaultLocaleTimezoneExtensionDemo.java[tags=default_locale_language] +---- + +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 + +[source,java,indent=0] +---- +include::{testDir}/example/DefaultLocaleTimezoneExtensionDemo.java[tag=default_locale_language_alternatives] +---- + +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. + +[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. + +[[writing-tests-built-in-extensions-DefaultTimeZone]] +===== @DefaultTimeZone + +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] +---- +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 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: + +* `{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..9f7912706406 --- /dev/null +++ b/documentation/src/test/java/example/DefaultLocaleTimezoneExtensionDemo.java @@ -0,0 +1,139 @@ +/* + * 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..daca4e18bdff 100644 --- a/junit-jupiter-api/src/main/java/module-info.java +++ b/junit-jupiter-api/src/main/java/module-info.java @@ -31,6 +31,8 @@ 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; + 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 new file mode 100644 index 000000000000..7b56aead2345 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultLocale.java @@ -0,0 +1,123 @@ +/* + * 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 java.util.Locale; + +import org.apiguardian.api.API; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * {@code @DefaultLocale} is a JUnit Jupiter extension for changing the value + * returned by {@link Locale#getDefault()} for a test execution. + * + *

The {@link 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 @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 + * User Guide. + * + * @since 6.1 + * @see Locale#getDefault() + * @see ReadsDefaultLocale + * @see WritesDefaultLocale + * @see DefaultTimeZone + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.TYPE }) +@Inherited +@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 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 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 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 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..e0c9a2d75f2f --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultLocaleExtension.java @@ -0,0 +1,144 @@ +/* + * 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.Optional; + +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.platform.commons.support.AnnotationSupport; +import org.junit.platform.commons.support.ReflectionSupport; + +/** + * @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"; + + @Override + public void beforeAll(ExtensionContext context) { + 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..ec5d431a0363 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultTimeZone.java @@ -0,0 +1,82 @@ +/* + * 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 java.util.TimeZone; + +import org.apiguardian.api.API; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * {@code @DefaultTimeZone} is a JUnit Jupiter extension for changing the value + * returned by {@link TimeZone#getDefault()} for a test execution. + * + *

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}. + * + *

During + * 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 + * User Guide. + * + * @since 6.1 + * @see TimeZone#getDefault() + * @see ReadsDefaultTimeZone + * @see WritesDefaultTimeZone + * @see DefaultLocale + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.TYPE }) +@Inherited +@WritesDefaultTimeZone +@API(status = STABLE, since = "6.1") +@ExtendWith(DefaultTimeZoneExtension.class) +@SuppressWarnings("exports") +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 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..ca511918b9ba --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultTimeZoneExtension.java @@ -0,0 +1,120 @@ +/* + * 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.Optional; +import java.util.TimeZone; + +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.platform.commons.support.AnnotationSupport; +import org.junit.platform.commons.support.ReflectionSupport; + +/** + * @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"; + + @Override + public void beforeAll(ExtensionContext context) { + 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..45fae038229b --- /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 + */ +final 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..8c7caa7bf02e --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/LocaleProvider.java @@ -0,0 +1,28 @@ +/* + * 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.function.Supplier; + +import org.apiguardian.api.API; + +/** + * Custom {@link Locale} provider for use with + * {@link DefaultLocale#localeProvider()}. + * + * @since 6.1 + */ +@API(status = STABLE, since = "6.1") +public interface LocaleProvider extends Supplier { +} 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 new file mode 100644 index 000000000000..084abbe211a1 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/ReadsDefaultLocale.java @@ -0,0 +1,50 @@ +/* + * 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.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 + * {@link DefaultLocale @DefaultLocale} extension themselves. + * + *

During + * 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 + * 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 = 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..42f29d9132c7 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/ReadsDefaultTimeZone.java @@ -0,0 +1,50 @@ +/* + * 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.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 + * {@link DefaultTimeZone @DefaultTimeZone} extension themselves. + * + *

During + * 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 + * 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 = 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..e2f2609b8e95 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/TimeZoneProvider.java @@ -0,0 +1,28 @@ +/* + * 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.TimeZone; +import java.util.function.Supplier; + +import org.apiguardian.api.API; + +/** + * Custom {@link TimeZone} provider for use with + * {@link DefaultTimeZone#timeZoneProvider()}. + * + * @since 6.1 + */ +@API(status = STABLE, since = "6.1") +public interface TimeZoneProvider extends Supplier { +} 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..665fadf39866 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/WritesDefaultLocale.java @@ -0,0 +1,50 @@ +/* + * 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.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 + * {@link DefaultLocale @DefaultLocale} extension themselves. + * + *

During + * 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 + * 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 = 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..b2a55ccafc4c --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/WritesDefaultTimeZone.java @@ -0,0 +1,50 @@ +/* + * 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.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 + * {@link DefaultTimeZone @DefaultTimeZone} extension themselves. + * + *

During + * 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 + * 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 = 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..2106ecf7df7c --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/package-info.java @@ -0,0 +1,11 @@ +/** + * {@code java.util}-related support in JUnit Jupiter. + * + * @see org.junit.jupiter.api.util.DefaultLocale + * @see org.junit.jupiter.api.util.DefaultTimeZone + */ + +@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..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 @@ -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 @@ -14,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