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:
+ *
+ *
+ * - using a {@link java.util.Locale#forLanguageTag(String) language tag}
+ * - using a {@link java.util.Locale.Builder Locale.Builder} together with
+ *
+ * - a language
+ * - a language and a county
+ * - a language, a county, and a variant
+ *
+ *
+ *
+ *
+ * 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 extends LocaleProvider> 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 extends TimeZoneProvider> 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 extends TimeZoneProvider> 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();
+ }
+
+}