diff --git a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc index d3ccc0e96312..c47a288fca2c 100644 --- a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc +++ b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc @@ -3826,3 +3826,101 @@ include::{testDir}/example/AutoCloseDemo.java[tags=user_guide_example] <1> Annotate an instance field with `@AutoClose`. <2> `WebClient` implements `java.lang.AutoCloseable` which defines a `close()` method that will be invoked after each `@Test` method. + +[[writing-tests-built-in-extensions-DefaultLocaleAndTimeZone]] +==== The @DefaultLocale and @DefaultTimeZone Extensions + +---- +include::{testDir}/example/DefaultLocaleTimezoneExtensionDemo.java[tags=default_locale_language] +---- + +The `@DefaultLocale` and `@DefaultTimeZone` annotations can be used to change the values returned from `Locale.getDefault()` and `TimeZone.getDefault()`, respectively, which are often used implicitly when no specific locale or time zone is chosen. +Both annotations work on the test class level and on the test method level, and are inherited from higher-level containers. +After the annotated element has been executed, the initial default value is restored. + +===== `@DefaultLocale` + +The default `Locale` can be specified using an {jdk-javadoc-base-url}/java.base/java/util/Locale.html#forLanguageTag-java.lang.String-[IETF BCP 47 language tag string] + +[source,java,indent=0] +---- +include::{testDir}/example/DefaultLocaleTimezoneExtensionDemo.java[tags=default_locale_language] +---- + +Alternatively the default `Locale` can be created using the following attributes of which a {jdk-javadoc-base-url}/java.base/java/util/Locale.Builder.html[Locale Builder] can create an instance with: + +* `language` or +* `language` and `country` or +* `language`, `country`, and `variant` + +NOTE: The variant needs to be a string which follows the https://www.rfc-editor.org/rfc/rfc5646.html[IETF BCP 47 / RFC 5646] syntax! + +[source,java,indent=0] +---- +include::{testDir}/example/DefaultLocaleTimezoneExtensionDemo.java[tag=default_locale_language_alternatives] +---- + +Note that mixing language tag configuration and constructor based configuration will cause an `ExtensionConfigurationException` to be thrown. +Furthermore, a `variant` can only be specified if `country` is also specified. +If `variant` is specified without `country`, an `ExtensionConfigurationException` will be thrown. + +Any method level `@DefaultLocale` configurations will override class level configurations. + +[source,java,indent=0] +---- +include::{testDir}/example/DefaultLocaleTimezoneExtensionDemo.java[tag=default_locale_class_level] +---- + +NOTE: A class-level configuration means that the specified locale is set before and reset after each individual test in the annotated class. + +If your use case is not covered, you can implement the `LocaleProvider` interface. + +[source,java,indent=0] +---- +include::{testDir}/example/DefaultLocaleTimezoneExtensionDemo.java[tag=default_locale_with_provider] +---- + +NOTE: The provider implementation must have a no-args (or the default) constructor. + +===== `@DefaultTimeZone` + +The default `TimeZone` is specified according to the https://docs.oracle.com/javase/8/docs/api/java/util/TimeZone.html#getTimeZone-java.lang.String-[TimeZone.getTimeZone(String)] method. + +[source,java,indent=0] +---- +include::{testDir}/example/DefaultLocaleTimezoneExtensionDemo.java[tag=default_timezone_zone] +---- + +Any method level `@DefaultTimeZone` configurations will override class level configurations: + +[source,java,indent=0] +---- +include::{testDir}/example/DefaultLocaleTimezoneExtensionDemo.java[tag=default_timezone_class_level] +---- + +NOTE: A class-level configuration means that the specified time zone is set before and reset after each individual test in the annotated class. + +If your use case is not covered, you can implement the `TimeZoneProvider` interface. + +[source,java,indent=0] +---- +include::{testDir}/example/DefaultLocaleTimezoneExtensionDemo.java[tag=default_time_zone_with_provider] +---- + +NOTE: The provider implementation must have a no-args (or the default) constructor. + +===== Thread-Safety + +Since default locale and time zone are global state, reading and writing them during https://docs.junit.org/current/user-guide/#writing-tests-parallel-execution[parallel test execution] can lead to unpredictable results and flaky tests. +The `@DefaultLocale` and `@DefaultTimeZone` extensions are prepared for that and tests annotated with them will never execute in parallel (thanks to https://docs.junit.org/current/api/org.junit.jupiter.api/org/junit/jupiter/api/parallel/ResourceLock.html[resource locks]) to guarantee correct test results. + +However, this does not cover all possible cases. +Tested code that reads or writes default locale and time zone _independently_ of the extensions can still run in parallel to them and may thus behave erratically when, for example, it unexpectedly reads a locale set by the extension in another thread. +Tests that cover code that reads or writes the default locale or time zone need to be annotated with the respective annotation: + +* `@ReadsDefaultLocale` +* `@ReadsDefaultTimeZone` +* `@WritesDefaultLocale` +* `@WritesDefaultTimeZone` + +Tests annotated in this way will never execute in parallel with tests annotated with `@DefaultLocale` or `@DefaultTimeZone`. diff --git a/documentation/src/test/java/example/DefaultLocaleTimezoneExtensionDemo.java b/documentation/src/test/java/example/DefaultLocaleTimezoneExtensionDemo.java new file mode 100644 index 000000000000..75a9281c6bf3 --- /dev/null +++ b/documentation/src/test/java/example/DefaultLocaleTimezoneExtensionDemo.java @@ -0,0 +1,143 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package example; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.ZoneOffset; +import java.util.Locale; +import java.util.TimeZone; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.util.DefaultLocale; +import org.junit.jupiter.api.util.DefaultTimeZone; +import org.junit.jupiter.api.util.LocaleProvider; +import org.junit.jupiter.api.util.TimeZoneProvider; + +public class DefaultLocaleTimezoneExtensionDemo { + + // tag::default_locale_language[] + @Test + @DefaultLocale("zh-Hant-TW") + void test_with_language() { + assertThat(Locale.getDefault()).isEqualTo(Locale.forLanguageTag("zh-Hant-TW")); + } + // end::default_locale_language[] + + // tag::default_locale_language_alternatives[] + @Test + @DefaultLocale(language = "en") + void test_with_language_only() { + assertThat(Locale.getDefault()).isEqualTo(new Locale.Builder().setLanguage("en").build()); + } + + @Test + @DefaultLocale(language = "en", country = "EN") + void test_with_language_and_country() { + assertThat(Locale.getDefault()).isEqualTo(new Locale.Builder().setLanguage("en").setRegion("EN").build()); + } + + @Test + @DefaultLocale(language = "ja", country = "JP", variant = "japanese") + void test_with_language_and_country_and_vairant() { + assertThat(Locale.getDefault()).isEqualTo( + new Locale.Builder().setLanguage("ja").setRegion("JP").setVariant("japanese").build()); + } + // end::default_locale_language_alternatives[] + + @Nested + // tag::default_locale_class_level[] + @DefaultLocale(language = "fr") + class MyLocaleTests { + + @Test + void test_with_class_level_configuration() { + assertThat(Locale.getDefault()).isEqualTo(new Locale.Builder().setLanguage("fr").build()); + } + + @Test + @DefaultLocale(language = "en") + void test_with_method_level_configuration() { + assertThat(Locale.getDefault()).isEqualTo(new Locale.Builder().setLanguage("en").build()); + } + + } + // end::default_locale_class_level[] + + // tag::default_locale_with_provider[] + @Test + @DefaultLocale(localeProvider = EnglishProvider.class) + void test_with_locale_provider() { + assertThat(Locale.getDefault()).isEqualTo(new Locale.Builder().setLanguage("en").build()); + } + + static class EnglishProvider implements LocaleProvider { + + @Override + public Locale get() { + return Locale.ENGLISH; + } + + } + // end::default_locale_with_provider[] + + // tag::default_timezone_zone[] + @Test + @DefaultTimeZone("CET") + void test_with_short_zone_id() { + assertThat(TimeZone.getDefault()).isEqualTo(TimeZone.getTimeZone("CET")); + } + + @Test + @DefaultTimeZone("Africa/Juba") + void test_with_long_zone_id() { + assertThat(TimeZone.getDefault()).isEqualTo(TimeZone.getTimeZone("Africa/Juba")); + } + // end::default_timezone_zone[] + + @Nested + // tag::default_timezone_class_level[] + @DefaultTimeZone("CET") + class MyTimeZoneTests { + + @Test + void test_with_class_level_configuration() { + assertThat(TimeZone.getDefault()).isEqualTo(TimeZone.getTimeZone("CET")); + } + + @Test + @DefaultTimeZone("Africa/Juba") + void test_with_method_level_configuration() { + assertThat(TimeZone.getDefault()).isEqualTo(TimeZone.getTimeZone("Africa/Juba")); + } + + } + // end::default_timezone_class_level[] + + // tag::default_time_zone_with_provider[] + @Test + @DefaultTimeZone(timeZoneProvider = UtcTimeZoneProvider.class) + void test_with_time_zone_provider() { + assertThat(TimeZone.getDefault()).isEqualTo(TimeZone.getTimeZone("UTC")); + } + + static class UtcTimeZoneProvider implements TimeZoneProvider { + + @Override + public TimeZone get() { + return TimeZone.getTimeZone(ZoneOffset.UTC); + } + + } + // end::default_time_zone_with_provider[] + +} diff --git a/junit-jupiter-api/src/main/java/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..be73541af8cb --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultLocale.java @@ -0,0 +1,111 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.api.util; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.apiguardian.api.API; + +/** + * {@code @DefaultLocale} is a JUnit Jupiter extension to change the value + * returned by {@link java.util.Locale#getDefault()} for a test execution. + * + *

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

+ * + * + * + *

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

+ * + *

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

+ * + *

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

+ * + *

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

+ * + *

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

+ * + * @since 6.1 + * @see java.util.Locale#getDefault() + * @see DefaultTimeZone + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.TYPE }) +@Inherited +@WritesDefaultLocale +@API(status = API.Status.STABLE, since = "6.1") +public @interface DefaultLocale { + + /** + * A language tag string as specified by IETF BCP 47. See + * {@link java.util.Locale#forLanguageTag(String)} for more information + * about valid language tag values. + * + * @since 0.3 + */ + String value() default ""; + + /** + * An ISO 639 alpha-2 or alpha-3 language code, or a language subtag up to + * 8 characters in length. See the {@link java.util.Locale} class + * description about valid language values. + */ + String language() default ""; + + /** + * An ISO 3166 alpha-2 country code or a UN M.49 numeric-3 area code. See + * the {@link java.util.Locale} class description about valid country + * values. + */ + String country() default ""; + + /** + * An IETF BCP 47 language string that matches the RFC 5646 syntax. + * It's validated by the {@code Locale.Builder}, using {@code sun.util.locale.LanguageTag#isVariant}. + */ + String variant() default ""; + + /** + * A class implementing {@link LocaleProvider} to be used for custom {@code Locale} resolution. + * This is mutually exclusive with other properties, if any other property is given a value it + * will result in an {@link org.junit.jupiter.api.extension.ExtensionConfigurationException}. + */ + Class localeProvider() default LocaleProvider.NullLocaleProvider.class; + +} diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultTimeZone.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultTimeZone.java new file mode 100644 index 000000000000..1c1d87a9caa8 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/DefaultTimeZone.java @@ -0,0 +1,72 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.api.util; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.apiguardian.api.API; + +/** + * {@code @DefaultTimeZone} is a JUnit Jupiter extension to change the value + * returned by {@link java.util.TimeZone#getDefault()} for a test execution. + * + *

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

+ * + *

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

+ * + *

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

+ * + *

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

+ * + * @since 6.1 + * @see java.util.TimeZone#getDefault() + * @see DefaultLocale + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.TYPE }) +@Inherited +@WritesDefaultTimeZone +@API(status = API.Status.STABLE, since = "6.1") +public @interface DefaultTimeZone { + + /** + * The ID for a {@code TimeZone}, either an abbreviation such as "PST", a + * full name such as "America/Los_Angeles", or a custom ID such as + * "GMT-8:00". Note that the support of abbreviations is for JDK 1.1.x + * compatibility only and full names should be used. + */ + String value() default ""; + + /** + * A class implementing {@link TimeZoneProvider} to be used for custom {@code TimeZone} resolution. + * This is mutually exclusive with other properties, if any other property is given a value it + * will result in an {@link org.junit.jupiter.api.extension.ExtensionConfigurationException}. + */ + Class timeZoneProvider() default TimeZoneProvider.NullTimeZoneProvider.class; + +} diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/LocaleProvider.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/LocaleProvider.java new file mode 100644 index 000000000000..5916f482e307 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/LocaleProvider.java @@ -0,0 +1,24 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.api.util; + +import java.util.Locale; +import java.util.function.Supplier; + +import org.apiguardian.api.API; + +@API(status = API.Status.STABLE, since = "6.1") +public interface LocaleProvider extends Supplier { + + interface NullLocaleProvider extends LocaleProvider { + } + +} diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/ReadsDefaultLocale.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/ReadsDefaultLocale.java new file mode 100644 index 000000000000..ff6b3d96de72 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/ReadsDefaultLocale.java @@ -0,0 +1,43 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.api.util; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.apiguardian.api.API; +import org.junit.jupiter.api.parallel.ResourceAccessMode; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.junit.jupiter.api.parallel.Resources; + +/** + * Marks tests that read the default locale but don't use the locale extension themselves. + * + *

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

+ * + *

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

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

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

+ * + *

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

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

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

+ * + *

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

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

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

+ * + *

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

+ * + * @since 6.1 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.CONSTRUCTOR, ElementType.METHOD, ElementType.PACKAGE, ElementType.TYPE }) +@Inherited +@ResourceLock(value = Resources.TIME_ZONE, mode = ResourceAccessMode.READ_WRITE) +@API(status = API.Status.STABLE, since = "6.1") +public @interface WritesDefaultTimeZone { +} diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/DefaultLocaleExtension.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/DefaultLocaleExtension.java new file mode 100644 index 000000000000..02ebf0086115 --- /dev/null +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/DefaultLocaleExtension.java @@ -0,0 +1,130 @@ +/* + * 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.engine.extension; + +import java.lang.reflect.AnnotatedElement; +import java.util.Locale; + +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionConfigurationException; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ExtensionContext.Namespace; +import org.junit.jupiter.api.util.DefaultLocale; +import org.junit.jupiter.api.util.LocaleProvider; +import org.junit.jupiter.api.util.LocaleProvider.NullLocaleProvider; +import org.junit.jupiter.engine.support.JupiterLocaleUtils; +import org.junit.platform.commons.support.AnnotationSupport; +import org.junit.platform.commons.support.ReflectionSupport; + +class DefaultLocaleExtension implements BeforeEachCallback, AfterEachCallback { + + private static final Namespace NAMESPACE = Namespace.create(DefaultLocaleExtension.class); + + private static final String KEY = "DefaultLocale"; + + @Override + public void beforeEach(ExtensionContext context) { + AnnotatedElement element = context.getElement().orElse(null); + AnnotationSupport.findAnnotation(element, DefaultLocale.class).ifPresent( + annotation -> setDefaultLocale(context, annotation)); + } + + private void setDefaultLocale(ExtensionContext context, DefaultLocale annotation) { + Locale configuredLocale = createLocale(annotation); + // defer storing the current default locale until the new locale could be created from the configuration + // (this prevents cases where misconfigured extensions store default locale now and restore it later, + // which leads to race conditions in our tests) + storeDefaultLocale(context); + Locale.setDefault(configuredLocale); + } + + private void storeDefaultLocale(ExtensionContext context) { + context.getStore(NAMESPACE).put(KEY, Locale.getDefault()); + } + + 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); + } + var locale = provider.get(); + if (locale == null) + throw new NullPointerException("LocaleProvider instance returned with null"); + return locale; + } + + @Override + public void afterEach(ExtensionContext context) { + AnnotatedElement element = context.getElement().orElse(null); + AnnotationSupport.findAnnotation(element, DefaultLocale.class).ifPresent(__ -> resetDefaultLocale(context)); + } + + private void resetDefaultLocale(ExtensionContext context) { + Locale defaultLocale = context.getStore(NAMESPACE).get(KEY, Locale.class); + // default locale is null if the extension was misconfigured and execution failed in "before" + if (defaultLocale != null) + Locale.setDefault(defaultLocale); + } + +} diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/DefaultTimeZoneExtension.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/DefaultTimeZoneExtension.java new file mode 100644 index 000000000000..ab9da183ca3b --- /dev/null +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/DefaultTimeZoneExtension.java @@ -0,0 +1,107 @@ +/* + * 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.engine.extension; + +import java.lang.reflect.AnnotatedElement; +import java.util.Optional; +import java.util.TimeZone; + +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionConfigurationException; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ExtensionContext.Namespace; +import org.junit.jupiter.api.extension.ExtensionContext.Store; +import org.junit.jupiter.api.util.DefaultTimeZone; +import org.junit.jupiter.api.util.TimeZoneProvider; +import org.junit.jupiter.api.util.TimeZoneProvider.NullTimeZoneProvider; +import org.junit.platform.commons.support.AnnotationSupport; +import org.junit.platform.commons.support.ReflectionSupport; + +class DefaultTimeZoneExtension implements BeforeEachCallback, AfterEachCallback { + + private static final Namespace NAMESPACE = Namespace.create(DefaultTimeZoneExtension.class); + + private static final String KEY = "DefaultTimeZone"; + + @Override + public void beforeEach(ExtensionContext context) { + AnnotatedElement element = context.getElement().orElse(null); + AnnotationSupport.findAnnotation(element, DefaultTimeZone.class).ifPresent( + annotation -> setDefaultTimeZone(context.getStore(NAMESPACE), annotation)); + } + + private void setDefaultTimeZone(Store store, DefaultTimeZone annotation) { + validateCorrectConfiguration(annotation); + TimeZone defaultTimeZone; + if (annotation.timeZoneProvider() != NullTimeZoneProvider.class) + defaultTimeZone = createTimeZone(annotation.timeZoneProvider()); + else + defaultTimeZone = createTimeZone(annotation.value()); + // defer storing the current default time zone until the new time zone could be created from the configuration + // (this prevents cases where misconfigured extensions store default time zone now and restore it later, + // which leads to race conditions in our tests) + storeDefaultTimeZone(store); + TimeZone.setDefault(defaultTimeZone); + } + + 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 createTimeZone(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 createTimeZone(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); + } + } + + private void storeDefaultTimeZone(Store store) { + store.put(KEY, TimeZone.getDefault()); + } + + @Override + public void afterEach(ExtensionContext context) { + AnnotatedElement element = context.getElement().orElse(null); + AnnotationSupport.findAnnotation(element, DefaultTimeZone.class).ifPresent( + __ -> resetDefaultTimeZone(context.getStore(NAMESPACE))); + } + + private void resetDefaultTimeZone(Store store) { + TimeZone timeZone = store.get(KEY, TimeZone.class); + // default time zone is null if the extension was misconfigured and execution failed in "before" + if (timeZone != null) + TimeZone.setDefault(timeZone); + } + +} diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/MutableExtensionRegistry.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/MutableExtensionRegistry.java index e5037f938262..129384af63dc 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/MutableExtensionRegistry.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/MutableExtensionRegistry.java @@ -83,6 +83,8 @@ public static MutableExtensionRegistry createRegistryWithDefaultExtensions(Jupit DEFAULT_STATELESS_EXTENSIONS.forEach(extensionRegistry::registerDefaultExtension); extensionRegistry.registerDefaultExtension(new TempDirectory(configuration)); + extensionRegistry.registerDefaultExtension(new DefaultLocaleExtension()); + extensionRegistry.registerDefaultExtension(new DefaultTimeZoneExtension()); if (configuration.isExtensionAutoDetectionEnabled()) { registerAutoDetectedExtensions(extensionRegistry, configuration); diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/support/JupiterLocaleUtils.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/support/JupiterLocaleUtils.java new file mode 100644 index 000000000000..4125cd250815 --- /dev/null +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/support/JupiterLocaleUtils.java @@ -0,0 +1,39 @@ +/* + * 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.engine.support; + +import java.util.Locale; + +import org.apiguardian.api.API; + +/** + * Utility class to create {@code Locale}. + */ +@API(status = API.Status.INTERNAL, since = "6.1") +public 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/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/DefaultLocaleTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/DefaultLocaleTests.java new file mode 100644 index 000000000000..2718b4fc0b97 --- /dev/null +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/DefaultLocaleTests.java @@ -0,0 +1,520 @@ +/* + * 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.engine.extension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.testkit.JUnitJupiterTestKit.executeTestClass; +import static org.junit.jupiter.testkit.JUnitJupiterTestKit.executeTestMethod; +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.api.util.DefaultLocale; +import org.junit.jupiter.api.util.LocaleProvider; +import org.junit.jupiter.api.util.ReadsDefaultLocale; +import org.junit.jupiter.api.util.WritesDefaultLocale; +import org.junit.jupiter.engine.support.JupiterLocaleUtils; +import org.junit.jupiter.testkit.ExecutionResults; + +@DisplayName("DefaultLocale extension") +class DefaultLocaleTests { + + 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() { + ExecutionResults results = executeTestClass(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() { + ExecutionResults results = executeTestMethod(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() { + ExecutionResults results = executeTestMethod(MethodLevelInitializationFailureTestCases.class, + "shouldFailMissingCountry"); + + results.testEvents().assertThatEvents().haveAtMost(1, + finishedWithFailure(instanceOf(ExtensionConfigurationException.class))); + } + + @Test + @DisplayName("should fail when languageTag and language is set") + void shouldFailWhenLanguageTagAndLanguageIsSet() { + ExecutionResults results = executeTestMethod(MethodLevelInitializationFailureTestCases.class, + "shouldFailLanguageTagAndLanguage"); + + results.testEvents().assertThatEvents().haveAtMost(1, + finishedWithFailure(instanceOf(ExtensionConfigurationException.class))); + } + + @Test + @DisplayName("should fail when languageTag and country is set") + void shouldFailWhenLanguageTagAndCountryIsSet() { + ExecutionResults results = executeTestMethod(MethodLevelInitializationFailureTestCases.class, + "shouldFailLanguageTagAndCountry"); + + results.testEvents().assertThatEvents().haveAtMost(1, + finishedWithFailure(instanceOf(ExtensionConfigurationException.class))); + } + + @Test + @DisplayName("should fail when languageTag and variant is set") + void shouldFailWhenLanguageTagAndVariantIsSet() { + ExecutionResults results = executeTestMethod(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() { + ExecutionResults results = executeTestMethod(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() { + ExecutionResults results = executeTestClass(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() { + ExecutionResults results = executeTestMethod(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() { + ExecutionResults results = executeTestMethod(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() { + ExecutionResults results = executeTestMethod(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() { + ExecutionResults results = executeTestMethod(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() { + ExecutionResults results = executeTestMethod(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() { + ExecutionResults results = executeTestMethod(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/engine/extension/DefaultTimeZoneTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/DefaultTimeZoneTests.java new file mode 100644 index 000000000000..82e92cf6b8a1 --- /dev/null +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/DefaultTimeZoneTests.java @@ -0,0 +1,380 @@ +/* + * 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.engine.extension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.testkit.JUnitJupiterTestKit.executeTestClass; +import static org.junit.jupiter.testkit.JUnitJupiterTestKit.executeTestMethod; +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.api.util.DefaultTimeZone; +import org.junit.jupiter.api.util.ReadsDefaultTimeZone; +import org.junit.jupiter.api.util.TimeZoneProvider; +import org.junit.jupiter.testkit.ExecutionResults; + +@DisplayName("DefaultTimeZone extension") +class DefaultTimeZoneTests { + + 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() { + ExecutionResults results = executeTestMethod(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() { + ExecutionResults results = executeTestClass(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() { + ExecutionResults results = executeTestMethod(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() { + ExecutionResults results = executeTestMethod(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() { + ExecutionResults results = executeTestMethod(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/jupiter-tests/src/test/java/org/junit/jupiter/testkit/ExecutionResults.java b/jupiter-tests/src/test/java/org/junit/jupiter/testkit/ExecutionResults.java new file mode 100644 index 000000000000..6207e3a2a842 --- /dev/null +++ b/jupiter-tests/src/test/java/org/junit/jupiter/testkit/ExecutionResults.java @@ -0,0 +1,152 @@ +/* + * 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.testkit; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.StreamSupport; + +import org.junit.platform.engine.DiscoverySelector; +import org.junit.platform.engine.discovery.DiscoverySelectors; +import org.junit.platform.testkit.engine.EngineExecutionResults; +import org.junit.platform.testkit.engine.EngineTestKit; +import org.junit.platform.testkit.engine.Events; + +/** + * Pioneers' class to handle JUnit Jupiter's {@link org.junit.platform.testkit.engine.EngineExecutionResults}. + * + *

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 additionalProperties = new HashMap<>(); + private final List selectors = new ArrayList<>(); + + Builder addConfigurationParameters(Map additionalConfig) { + additionalProperties.putAll(additionalConfig); + return this; + } + + Executor selectTestClass(Class testClass) { + selectors.add(DiscoverySelectors.selectClass(testClass)); + return new Executor(); + } + + Executor selectTestClasses(Iterable> testClasses) { + StreamSupport.stream(testClasses.spliterator(), false).map(DiscoverySelectors::selectClass).forEach( + selectors::add); + return new Executor(); + } + + Executor selectTestMethod(Class testClass, String testMethodName) { + selectors.add(DiscoverySelectors.selectMethod(testClass, testMethodName)); + return new Executor(); + } + + Executor selectTestMethodWithParameterTypes(Class testClass, String testMethodName, + String methodParameterTypes) { + selectors.add(DiscoverySelectors.selectMethod(testClass, testMethodName, methodParameterTypes)); + return new Executor(); + } + + Executor selectNestedTestClass(List> enclosingClasses, Class testClass) { + selectors.add(DiscoverySelectors.selectNestedClass(enclosingClasses, testClass)); + return new Executor(); + } + + Executor selectNestedTestMethod(List> enclosingClasses, Class testClass, String testMethodName) { + selectors.add(DiscoverySelectors.selectNestedMethod(enclosingClasses, testClass, testMethodName)); + return new Executor(); + } + + Executor selectNestedTestMethodWithParameterTypes(List> enclosingClasses, Class testClass, + String testMethodName, String methodParameterTypes) { + selectors.add(DiscoverySelectors.selectNestedMethod(enclosingClasses, testClass, testMethodName, + methodParameterTypes)); + return new Executor(); + } + + class Executor { + + ExecutionResults execute() { + return new ExecutionResults(Builder.this.additionalProperties, Builder.this.selectors); + } + + } + + } + + private ExecutionResults(Map additionalProperties, List selectors) { + this.executionResults = getConfiguredJupiterEngine().configurationParameters(additionalProperties).selectors( + selectors.toArray(DiscoverySelector[]::new)).execute(); + } + + static Builder builder() { + return new Builder(); + } + + private EngineTestKit.Builder getConfiguredJupiterEngine() { + return EngineTestKit.engine(JUPITER_ENGINE_NAME) + // to tease out concurrency-related bugs, we want parallel execution of our tests + // (for details, see section "Thread-safety" in CONTRIBUTING.adoc) + .configurationParameter("junit.jupiter.execution.parallel.enabled", "true").configurationParameter( + "junit.jupiter.execution.parallel.mode.default", "concurrent") + // since we have full control over which tests we execute with this engine, + // we can parallelize more aggressively than in the general settings in `junit-platform.properties` + .configurationParameter("junit.jupiter.execution.parallel.mode.classes.default", + "concurrent").configurationParameter("junit.jupiter.execution.parallel.config.strategy", + "dynamic").configurationParameter("junit.jupiter.execution.parallel.config.dynamic.factor", + "1"); + } + + /** + * Get all recorded events. + */ + public Events allEvents() { + return executionResults.allEvents(); + } + + /** + * Get recorded dynamically registered events. + */ + public Events dynamicallyRegisteredEvents() { + return executionResults.allEvents().dynamicallyRegistered(); + } + + /** + * Get recorded events for containers. + * + *

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()}.

+ */ + public Events containerEvents() { + return executionResults.containerEvents(); + } + + /** + * Get recorded events for tests. + * + *

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()}.

+ */ + public Events testEvents() { + return executionResults.testEvents(); + } + +} diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/testkit/JUnitJupiterKitTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/testkit/JUnitJupiterKitTests.java new file mode 100644 index 000000000000..93b1a9e1e380 --- /dev/null +++ b/jupiter-tests/src/test/java/org/junit/jupiter/testkit/JUnitJupiterKitTests.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.testkit; + +import static java.util.Arrays.asList; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.platform.testkit.engine.EventConditions.started; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayName("Execute") +class JUnitJupiterKitTests { + + @Test + @DisplayName("all tests of a class") + void executeTestClass() { + ExecutionResults results = JUnitJupiterTestKit.executeTestClass(DummyClass.class); + + results.testEvents().assertThatEvents().haveExactly(1, started()); + } + + @Test + @DisplayName("all tests of all given classes") + void executeTestClasses() { + ExecutionResults results = JUnitJupiterTestKit.executeTestClasses( + asList(DummyClass.class, SecondDummyClass.class)); + + results.testEvents().assertThatEvents().haveExactly(2, started()); + } + + @Test + @DisplayName("a specific method") + void executeTestMethod() { + ExecutionResults results = JUnitJupiterTestKit.executeTestMethod(DummyClass.class, "nothing"); + + results.testEvents().assertThatEvents().haveExactly(1, started()); + } + + @Nested + @DisplayName("a specific parametrized method") + class ExecuteTestMethodWithParametersTests { + + @Test + @DisplayName(" where parameter is a single class") + void executeTestMethodWithParameterTypes_singleParameterType() { + ExecutionResults results = JUnitJupiterTestKit.executeTestMethodWithParameterTypes(DummyPropertyClass.class, + "single", String.class); + + results.testEvents().assertThatEvents().haveExactly(1, started()); + } + + @Test + @DisplayName(" where parameter is an array of classes") + void executeTestMethodWithParameterTypes_parameterTypeAsArray() { + Class[] classes = { String.class }; + + ExecutionResults results = JUnitJupiterTestKit.executeTestMethodWithParameterTypes(DummyPropertyClass.class, + "single", classes); + + results.testEvents().assertThatEvents().haveExactly(1, started()); + } + + @Test + @DisplayName("without parameter results in IllegalArgumentException") + void executeTestMethodWithParameterTypes_parameterArrayIsNull_NullPointerException() { + assertThatThrownBy(() -> JUnitJupiterTestKit.executeTestMethodWithParameterTypes(DummyPropertyClass.class, + "single", (Class) null)).isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("without parameter results in IllegalArgumentException") + void executeTestMethodWithParameterTypes_singleParameterIsNull_IllegalArgumentException() { + assertThatThrownBy(() -> JUnitJupiterTestKit.executeTestMethodWithParameterTypes(DummyPropertyClass.class, + "single", (Class[]) null)).isInstanceOf(IllegalArgumentException.class).hasMessage( + "methodParameterTypes must not be null"); + } + + } + + static class DummyPropertyClass { + + @ParameterizedTest(name = "See if enabled with {0}") + @ValueSource(strings = { "parameter" }) + void single(String reason) { + // Do nothing + } + + } + + static class DummyClass { + + @Test + void nothing() { + // Do nothing + } + + } + + static class SecondDummyClass { + + @Test + void nothing() { + // Do nothing + } + + } + +} diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/testkit/JUnitJupiterTestKit.java b/jupiter-tests/src/test/java/org/junit/jupiter/testkit/JUnitJupiterTestKit.java new file mode 100644 index 000000000000..6bfd575ac3fc --- /dev/null +++ b/jupiter-tests/src/test/java/org/junit/jupiter/testkit/JUnitJupiterTestKit.java @@ -0,0 +1,163 @@ +/* + * 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.testkit; + +import static java.util.Arrays.stream; +import static java.util.stream.Collectors.joining; + +import java.util.List; +import java.util.Map; + +import org.opentest4j.TestAbortedException; + +public class JUnitJupiterTestKit { + + /** + * Returns the execution results of the given test class. + * + * @param testClass The test class instance + * @return The execution results + */ + public static ExecutionResults executeTestClass(Class testClass) { + return ExecutionResults.builder().selectTestClass(testClass).execute(); + } + + /** + * Returns the execution results of the given test classes. + * + * @param testClasses The collection of test class instances + * @return The execution results + */ + public static ExecutionResults executeTestClasses(Iterable> testClasses) { + return ExecutionResults.builder().selectTestClasses(testClasses).execute(); + } + + /** + * Returns the execution results of the given method of a given test class. + * + * @param testClass The test class instance + * @param testMethodName Name of the test method (of the given class) + * @return The execution results + */ + public static ExecutionResults executeTestMethod(Class testClass, String testMethodName) { + return ExecutionResults.builder().selectTestMethod(testClass, testMethodName).execute(); + } + + /** + * Returns the execution results of the given method of a given test class. + * + * @param testClass The test class instance + * @param testMethodName Name of the test method (of the given class) + * @param methodParameterTypes Class type(s) of the parameter(s) + * @return The execution results + * @throws IllegalArgumentException when methodParameterTypes is null + * This method only checks parameters which are not part of the underlying + * Jupiter TestKit. The Jupiter TestKit may throw other exceptions! + */ + public static ExecutionResults executeTestMethodWithParameterTypes(Class testClass, String testMethodName, + Class... methodParameterTypes) { + + String allTypeNames = toMethodParameterTypesString(methodParameterTypes); + + return ExecutionResults.builder().selectTestMethodWithParameterTypes(testClass, testMethodName, + allTypeNames).execute(); + } + + /** + * Returns the execution results of the given nested test class. + * + * @param enclosingClasses List of the enclosing classes + * @param testClass Name of the test class, the results should be returned + * @return The execution results + */ + public static ExecutionResults executeNestedTestClass(List> enclosingClasses, Class testClass) { + return ExecutionResults.builder().selectNestedTestClass(enclosingClasses, testClass).execute(); + } + + /** + * Returns the execution results of the given method of a given nested test class. + * + * @param enclosingClasses List of the enclosing classes + * @param testClass Name of the test class + * @param testMethodName Name of the test method (of the given class) + * @return The execution results + */ + public static ExecutionResults executeNestedTestMethod(List> enclosingClasses, Class testClass, + String testMethodName) { + return ExecutionResults.builder().selectNestedTestMethod(enclosingClasses, testClass, testMethodName).execute(); + } + + /** + * Returns the execution results of the given method of a given nested test class. + * + * @param enclosingClasses List of the enclosing classes + * @param testClass Name of the test class + * @param testMethodName Name of the test method (of the given class) + * @param methodParameterTypes Class type(s) of the parameter(s) + * @return The execution results + * @throws IllegalArgumentException when methodParameterTypes is null + * This method only checks parameters which are not part of the underlying + * Jupiter TestKit. The Jupiter TestKit may throw other exceptions! + */ + public static ExecutionResults executeNestedTestMethodWithParameterTypes(List> enclosingClasses, + Class testClass, String testMethodName, Class... methodParameterTypes) { + + String allTypeNames = toMethodParameterTypesString(methodParameterTypes); + + return ExecutionResults.builder().selectNestedTestMethodWithParameterTypes(enclosingClasses, testClass, + testMethodName, allTypeNames).execute(); + } + + /** + * Returns the execution results of the given method of a given test class + * and passes the additional configuration parameters. + * + * @param configurationParameters additional configuration parameters + * @param testClass The test class instance + * @param testMethodName Name of the test method (of the given class) + * @param methodParameterTypes Class type(s) of the parameter(s) + * @return The execution results + * @throws IllegalArgumentException when methodParameterTypes is null + * This method only checks parameters which are not part of the underlying + * Jupiter TestKit. The Jupiter TestKit may throw other exceptions! + */ + public static ExecutionResults executeTestMethodWithParameterTypesAndConfigurationParameters( + Map configurationParameters, Class testClass, String testMethodName, + Class... methodParameterTypes) { + + String allTypeNames = toMethodParameterTypesString(methodParameterTypes); + + return ExecutionResults.builder().addConfigurationParameters( + configurationParameters).selectTestMethodWithParameterTypes(testClass, testMethodName, + allTypeNames).execute(); + } + + private static String toMethodParameterTypesString(Class... methodParameterTypes) { + // throw IllegalArgumentException for a `null` array instead of NPE + // (hence no use of `Objects::requireNonNull`) + if (methodParameterTypes == null) { + throw new IllegalArgumentException("methodParameterTypes must not be null"); + } + + // Concatenating all type names, because DiscoverySelectors.selectMethod only takes String as a parameter. + return stream(methodParameterTypes).map(Class::getName).collect(joining(",")); + } + + /** + * Aborts the test execution. Makes the test code a little nicer. + * + * @throws TestAbortedException always throws this + */ + public static void abort() { + throw new TestAbortedException(); + } + +}