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 4f40bc523b81..98494758aac0 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 @@ -45,8 +42,8 @@ repository on GitHub. [[release-notes-6.1.0-M2-junit-jupiter-new-features-and-improvements]] ==== New Features and Improvements -* ❓ - +* 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..9dde8f572ddb --- /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.locale.DefaultLocale; +import org.junit.jupiter.api.locale.LocaleProvider; +import org.junit.jupiter.api.timezone.DefaultTimeZone; +import org.junit.jupiter.api.timezone.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..5b030fcfbee5 100644 --- a/junit-jupiter-api/src/main/java/module-info.java +++ b/junit-jupiter-api/src/main/java/module-info.java @@ -29,8 +29,10 @@ exports org.junit.jupiter.api.extension.support; exports org.junit.jupiter.api.function; exports org.junit.jupiter.api.io; + exports org.junit.jupiter.api.locale; exports org.junit.jupiter.api.parallel; exports org.junit.jupiter.api.timeout to org.junit.jupiter.engine; + exports org.junit.jupiter.api.timezone; opens org.junit.jupiter.api.condition to org.junit.platform.commons; } diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/locale/DefaultLocale.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/locale/DefaultLocale.java new file mode 100644 index 000000000000..dd13dbf23f41 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/locale/DefaultLocale.java @@ -0,0 +1,116 @@ +/* + * 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.locale; + +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; +import org.junit.jupiter.api.timezone.DefaultTimeZone; + +/** + * {@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.
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.
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.
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.
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.
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.
Instantiate with the static factory methods in {@link JUnitJupiterTestKit}.
+ */
+public class ExecutionResults {
+
+ private final EngineExecutionResults executionResults;
+
+ private static final String JUPITER_ENGINE_NAME = "junit-jupiter";
+
+ static class Builder {
+
+ private final Map In this context, the word "container" applies to {@link org.junit.platform.engine.TestDescriptor
+ * TestDescriptors} that return {@code true} from {@link org.junit.platform.engine.TestDescriptor#isContainer()}. In this context, the word "test" applies to {@link org.junit.platform.engine.TestDescriptor
+ * TestDescriptors} that return {@code true} from {@link org.junit.platform.engine.TestDescriptor#isTest()}.