diff --git a/documentation/modules/ROOT/pages/writing-tests/built-in-extensions.adoc b/documentation/modules/ROOT/pages/writing-tests/built-in-extensions.adoc index 3d5fa42d45c5..19abd0ab2526 100644 --- a/documentation/modules/ROOT/pages/writing-tests/built-in-extensions.adoc +++ b/documentation/modules/ROOT/pages/writing-tests/built-in-extensions.adoc @@ -292,3 +292,119 @@ locale or time zone need to be annotated with the respective annotation: Tests annotated in this way will never execute in parallel with tests annotated with `@DefaultLocale` or `@DefaultTimeZone`. + +[[writing-tests-built-in-extensions-SystemProperty]] +==== `@ClearSystemProperty` and `@SetSystemProperty` + +The `@ClearSystemProperty` and `@SetSystemProperty` annotations can be used to clear and set, respectively, the values of system properties for a test execution. +Both annotations work on the test method and class level, are repeatable, combinable, and inherited from higher-level containers. +After the annotated method has been executed, the properties mentioned in the annotation will be restored to their original value or the value of the higher-level container, or will be cleared if they didn't have one before. +Other system properties that are changed during the test, are *not* restored (unless the `@RestoreSystemProperties` is used). + +For example, clearing a system property for a test execution can be done as follows: + +[source,java,indent=0] +---- +include::example$java/example/SystemPropertyExtensionDemo.java[tag=systemproperty_clear_simple] +---- + +And setting a system property for a test execution: + +[source,java,indent=0] +---- +include::example$java/example/SystemPropertyExtensionDemo.java[tag=systemproperty_set_simple] +---- + +As mentioned before, both annotations are repeatable, and they can also be combined: + +[source,java,indent=0] +---- +include::example$java/example/SystemPropertyExtensionDemo.java[tag=systemproperty_using_set_and_clear] + +---- + +Note that class-level configurations are overwritten by method-level configurations: + +[source,java,indent=0] +---- +include::example$java/example/SystemPropertyExtensionDemo.java[tag=systemproperty_using_at_class_level] +---- + +[NOTE] +==== +Method-level configurations are visible in both `@BeforeEach` setup methods and `@AfterEach` teardown methods (see <>). + +A class-level configuration means that the specified system properties are cleared/set before and reset after each individual test in the annotated class. +==== + +== `@RestoreSystemProperties` + +`@RestoreSystemProperties` can be used to restore changes to system properties made directly in code. +While `@ClearSystemProperty` and `@SetSystemProperty` set or clear specific properties and values, they don't allow property values to be calculated or parameterized, thus there are times you may want to directly set properties in your test code. +`@RestoreSystemProperties` can be placed on test methods or test classes and will completely restore all system properties to their original state after a test or test class is complete. + +In this example, `@RestoreSystemProperties` is used on a test method, ensuring any changes made in that method are restored: + +[source,java,indent=0] +---- +include::example$java/example/SystemPropertyExtensionDemo.java[tag=systemproperty_restore_test] +---- + +When `@RestoreSystemProperties` is used on a test class, any system properties changes made during the entire lifecycle of the test class, including test methods, `@BeforeAll`, `@BeforeEach` and 'after' methods, are restored after the test class' lifecycle is complete. +In addition, the annotation is inherited by each test method just as if each one was annotated with `@RestoreSystemProperties`. + +In the following example, both test methods see the system property changes made in `@BeforeAll` and `@BeforeEach`, however, the test methods are isolated from each other (`isolatedTest2` does not 'see' changes made in `isolatedTest1`). +As shown in the second example below, the class-level `@RestoreSystemProperties` ensures that system property changes made within the annotated class are completely restored after the class's lifecycle, ensuring that changes are not visible to `SomeOtherTestClass`. +Note that `SomeOtherTestClass` uses the `@ReadsSystemProperty` annotation: This ensures that JUnit does not schedule the class to run during any test known to modify system properties (see <>). + +[source,java,indent=0] +---- +include::example$java/example/SystemPropertyExtensionDemo.java[tag=systemproperty_class_restore_setup] +---- + +Some other test class, running later: + +[source,java,indent=0] +---- +include::example$java/example/SystemPropertyExtensionDemo.java[tag=systemproperty_class_restore_isolated_class] +---- + +== Using `@ClearSystemProperty`, `@SetSystemProperty`, and `@RestoreSystemProperties` together + +All three annotations can be combined, which could be used when some system properties are parameterized (i.e. need to be set in code) and others are not. +For instance, imagine testing an image generation utility that takes configuration from system properties. +Basic configuration can be specified using `Set` and `Clear` and the image size parameterized: + +[source,java,indent=0] +---- +include::example$java/example/SystemPropertyExtensionDemo.java[tag=systemproperty_method_combine_all_test] +---- + +[NOTE] +==== +Using `@RestoreSystemProperties` is not necessary to restore system properties modified via `@ClearSystemProperty` or `@SetSystemProperty` - they each automatically restore the referenced properties. +'Restore', is only needed if system properties are modified in some way _other than_ Clear and Set during a test. +==== + +=== `@RestoreSystemProperties` Limitations + +The system `Properties` object is normally just a hashmap of strings, however, it is technically possible to store non-string values and create {jdk-javadoc-base-url}/java.base/java/util/Properties.html#%3Cinit%3E(java.util.Properties)[nested `Properties` with inherited default values]. +`@RestoreSystemProperties` restores the original `Properties` object with all of its potential richness _after_ the annotated scope is complete. +However, for use during the test _within_ the test scope it provides a cloned `Properties` object with these limitations: + +- Properties with non-string values are removed +- Nested `Properties` are flattened into a non-nested instance that has the same effective values, but not necessarily the same structure + +== Thread-Safety + +Since system properties are global state, reading and writing them during <> can lead to unpredictable results and flaky tests. +The system property extension is prepared for that and tests annotated with `@ClearSystemProperty`, `@SetSystemProperty`, or `@RestoreSystemProperties` will never execute in parallel (thanks to https://docs.junit.org/current/api[resource locks]) to guarantee correct test results. + +However, this does not cover all possible cases. +Tested code that reads or writes system properties _independently_ of the extension can still run in parallel to it and may thus behave erratically when, for example, it unexpectedly reads a property set by the extension in another thread. +Tests that cover code that reads or writes system properties need to be annotated with the respective annotation: + +* `@ReadsSystemProperty` +* `@WritesSystemProperty` (though consider using `@RestoreSystemProperties` instead) + +Tests annotated in this way will never execute in parallel with tests annotated with `@ClearSystemProperty`, `@SetSystemProperty`, or `@RestoreSystemProperties`. diff --git a/documentation/src/test/java/example/SystemPropertyExtensionDemo.java b/documentation/src/test/java/example/SystemPropertyExtensionDemo.java new file mode 100644 index 000000000000..2edf62d17dba --- /dev/null +++ b/documentation/src/test/java/example/SystemPropertyExtensionDemo.java @@ -0,0 +1,153 @@ +/* + * 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 org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.ClassOrderer; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestClassOrder; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.util.ClearSystemProperty; +import org.junit.jupiter.api.util.ReadsSystemProperty; +import org.junit.jupiter.api.util.RestoreSystemProperties; +import org.junit.jupiter.api.util.SetSystemProperty; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +public class SystemPropertyExtensionDemo { + + // tag::systemproperty_clear_simple[] + @Test + @ClearSystemProperty(key = "some property") + void testClearingProperty() { + assertThat(System.getProperty("some property")).isNull(); + } + // end::systemproperty_clear_simple[] + + // tag::systemproperty_set_simple[] + @Test + @SetSystemProperty(key = "some property", value = "new value") + void testSettingProperty() { + assertThat(System.getProperty("some property")).isEqualTo("new value"); + } + // end::systemproperty_set_simple[] + + // tag::systemproperty_using_set_and_clear[] + @Test + @ClearSystemProperty(key = "1st property") + @ClearSystemProperty(key = "2nd property") + @SetSystemProperty(key = "3rd property", value = "new value") + void testClearingAndSettingProperty() { + assertThat(System.getProperty("1st property")).isNull(); + assertThat(System.getProperty("2nd property")).isNull(); + assertThat(System.getProperty("3rd property")).isEqualTo("new value"); + } + // end::systemproperty_using_set_and_clear[] + + @Nested + // tag::systemproperty_using_at_class_level[] + @ClearSystemProperty(key = "some property") + class MySystemPropertyTest { + + @Test + @SetSystemProperty(key = "some property", value = "new value") + void clearedAtClasslevel() { + assertThat(System.getProperty("some property")).isEqualTo("new value"); + } + + } + // end::systemproperty_using_at_class_level[] + + // tag::systemproperty_restore_test[] + @ParameterizedTest + @ValueSource(strings = { "foo", "bar" }) + @RestoreSystemProperties + void parameterizedTest(String value) { + System.setProperty("some parameterized property", value); + System.setProperty("some other dynamic property", "my code calculates somehow"); + } + // end::systemproperty_restore_test[] + + @Nested + @TestClassOrder(ClassOrderer.OrderAnnotation.class) + class SystemPropertyRestoreExample { + + @Nested + @Order(1) + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + // tag::systemproperty_class_restore_setup[] + @RestoreSystemProperties + class MySystemPropertyRestoreTest { + + @BeforeAll + void beforeAll() { + System.setProperty("A", "A value"); + } + + @BeforeEach + void beforeEach() { + System.setProperty("B", "B value"); + } + + @Test + void isolatedTest1() { + System.setProperty("C", "C value"); + } + + @Test + void isolatedTest2() { + assertThat(System.getProperty("A")).isEqualTo("A value"); + assertThat(System.getProperty("B")).isEqualTo("B value"); + + // Class-level @RestoreSystemProperties restores "C" to original state + assertThat(System.getProperty("C")).isNull(); + } + + } + // end::systemproperty_class_restore_setup[] + + @Nested + @Order(2) + // tag::systemproperty_class_restore_isolated_class[] + @ReadsSystemProperty + class SomeOtherTestClass { + + @Test + void isolatedTest() { + assertThat(System.getProperty("A")).isNull(); + assertThat(System.getProperty("B")).isNull(); + assertThat(System.getProperty("C")).isNull(); + } + + } + + // end::systemproperty_class_restore_isolated_class[] + } + + // tag::systemproperty_method_combine_all_test[] + @ParameterizedTest + @ValueSource(ints = { 100, 500, 1000 }) + @RestoreSystemProperties + @SetSystemProperty(key = "DISABLE_CACHE", value = "TRUE") + @ClearSystemProperty(key = "COPYWRITE_OVERLAY_TEXT") + void imageGenerationTest(int imageSize) { + System.setProperty("IMAGE_SIZE", String.valueOf(imageSize)); // Requires restore + + // Test your image generation utility with the current system properties + } + // end::systemproperty_method_combine_all_test[] + +} diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/AbstractEntryBasedExtension.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/AbstractEntryBasedExtension.java new file mode 100644 index 000000000000..a3143f7877d3 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/AbstractEntryBasedExtension.java @@ -0,0 +1,333 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.api.util; + +import static java.util.stream.Collectors.joining; +import static java.util.stream.Collectors.toMap; +import static org.junit.jupiter.api.util.SystemPropertyExtensionUtils.findAllContexts; + +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Stream; + +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionConfigurationException; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ExtensionContext.Namespace; +import org.junit.jupiter.api.extension.ExtensionContext.Store; +import org.junit.platform.commons.support.AnnotationSupport; + +/** + * An abstract base class for entry-based extensions, where entries (key-value + * pairs) can be cleared, set, or restored. + * + * @param The entry key type. + * @param The entry value type. + * @param The clear annotation type. + * @param The set annotation type. + * @param The restore annotation type. + */ +abstract class AbstractEntryBasedExtension + implements BeforeEachCallback, AfterEachCallback, BeforeAllCallback, AfterAllCallback { + + /** + * Key to indicate storage is for an incremental backup object. + */ + private static final String INCREMENTAL_KEY = "inc"; + + /** + * Key to indicate storage is for a complete backup object. + */ + private static final String COMPLETE_KEY = "full"; + + @Override + public void beforeAll(ExtensionContext context) { + applyForAllContexts(context); + } + + @Override + public void beforeEach(ExtensionContext context) { + applyForAllContexts(context); + } + + private void applyForAllContexts(ExtensionContext originalContext) { + boolean fullRestore = AnnotationSupport.findAnnotation(originalContext.getElement(), + getRestoreAnnotationType()).isPresent(); + + if (fullRestore) { + Properties bulk = this.prepareToEnterRestorableContext(); + storeOriginalCompleteEntries(originalContext, bulk); + } + + /* + * We cannot use PioneerAnnotationUtils#findAllEnclosingRepeatableAnnotations(ExtensionContext, Class) or the + * like as clearing and setting might interfere. Therefore, we have to apply the extension from the outermost + * to the innermost ExtensionContext. + */ + List contexts = findAllContexts(originalContext); + Collections.reverse(contexts); + contexts.forEach(currentContext -> clearAndSetEntries(currentContext, originalContext, !fullRestore)); + } + + private void clearAndSetEntries(ExtensionContext currentContext, ExtensionContext originalContext, + boolean doIncrementalBackup) { + currentContext.getElement().ifPresent(element -> { + Set entriesToClear; + Map entriesToSet; + + try { + entriesToClear = findEntriesToClear(element); + entriesToSet = findEntriesToSet(element); + preventClearAndSetSameEntries(entriesToClear, entriesToSet.keySet()); + } + catch (IllegalStateException ex) { + throw new ExtensionConfigurationException("Don't clear/set the same entry more than once.", ex); + } + + if (entriesToClear.isEmpty() && entriesToSet.isEmpty()) + return; + + reportWarning(currentContext); + + // Only backup original values if we didn't already do bulk storage of the original state + if (doIncrementalBackup) { + storeOriginalIncrementalEntries(originalContext, entriesToClear, entriesToSet.keySet()); + } + + clearEntries(entriesToClear); + setEntries(entriesToSet); + }); + } + + private Set findEntriesToClear(AnnotatedElement element) { + return findAnnotations(element, getClearAnnotationType()).map(clearKeyMapper()).collect( + SystemPropertyExtensionUtils.distinctToSet()); + } + + private Map findEntriesToSet(AnnotatedElement element) { + return findAnnotations(element, getSetAnnotationType()).collect(toMap(setKeyMapper(), setValueMapper())); + } + + private Stream findAnnotations(AnnotatedElement element, Class clazz) { + return AnnotationSupport.findRepeatableAnnotations(element, clazz).stream(); + } + + @SuppressWarnings("unchecked") + private Class getClearAnnotationType() { + return (Class) getActualTypeArgumentAt(2); + } + + @SuppressWarnings("unchecked") + private Class getSetAnnotationType() { + return (Class) getActualTypeArgumentAt(3); + } + + @SuppressWarnings("unchecked") + private Class getRestoreAnnotationType() { + return (Class) getActualTypeArgumentAt(4); + } + + private Type getActualTypeArgumentAt(int index) { + ParameterizedType abstractEntryBasedExtensionType = (ParameterizedType) getClass().getGenericSuperclass(); + Type type = abstractEntryBasedExtensionType.getActualTypeArguments()[index]; + if (type instanceof ParameterizedType parameterizedType) { + return parameterizedType.getRawType(); + } + else { + return type; + } + } + + private void preventClearAndSetSameEntries(Collection entriesToClear, Collection entriesToSet) { + String duplicateEntries = entriesToClear.stream().filter(entriesToSet::contains).map(Object::toString).collect( + joining(", ")); + if (!duplicateEntries.isEmpty()) + throw new IllegalStateException( + "Cannot clear and set the following entries at the same time: " + duplicateEntries); + } + + private void storeOriginalIncrementalEntries(ExtensionContext context, Collection entriesToClear, + Collection entriesToSet) { + getStore(context).put(getStoreKey(context, INCREMENTAL_KEY), new EntriesBackup(entriesToClear, entriesToSet)); + } + + private void storeOriginalCompleteEntries(ExtensionContext context, Properties originalEntries) { + getStore(context).put(getStoreKey(context, COMPLETE_KEY), originalEntries); + } + + /** + * Restore the complete original state of the entries as they were prior to this {@code ExtensionContext}, + * if the complete state was initially stored in a before all/each event. + * + * @param context The {@code ExtensionContext} which may have a bulk backup stored. + * @return true if a complete backup exists and was used to restore, false if not. + */ + private boolean restoreOriginalCompleteEntries(ExtensionContext context) { + Properties bulk = getStore(context).get(getStoreKey(context, COMPLETE_KEY), Properties.class); + + if (bulk == null) { + // No complete backup - false will let the caller know to continue w/ an incremental restore + return false; + } + else { + this.prepareToExitRestorableContext(bulk); + return true; + } + } + + private void clearEntries(Collection entriesToClear) { + entriesToClear.forEach(this::clearEntry); + } + + private void setEntries(Map entriesToSet) { + entriesToSet.forEach(this::setEntry); + } + + @Override + public void afterEach(ExtensionContext context) { + restoreForAllContexts(context); + } + + @Override + public void afterAll(ExtensionContext context) { + restoreForAllContexts(context); + } + + private void restoreForAllContexts(ExtensionContext originalContext) { + // Try a complete restore first + if (!restoreOriginalCompleteEntries(originalContext)) { + // A complete backup is not available, so restore incrementally from innermost to outermost + findAllContexts(originalContext).forEach(__ -> restoreOriginalIncrementalEntries(originalContext)); + } + } + + private void restoreOriginalIncrementalEntries(ExtensionContext originalContext) { + getStore(originalContext).getOrDefault(getStoreKey(originalContext, INCREMENTAL_KEY), EntriesBackup.class, + new EntriesBackup()).restoreBackup(); + } + + private Store getStore(ExtensionContext context) { + return context.getStore(Namespace.create(getClass())); + } + + private String getStoreKey(ExtensionContext context, String discriminator) { + return context.getUniqueId() + "-" + this.getClass().getSimpleName() + "-" + discriminator; + } + + private class EntriesBackup { + + private final Set entriesToClear = new HashSet<>(); + private final Map entriesToSet = new HashMap<>(); + + EntriesBackup() { + // empty backup + } + + EntriesBackup(Collection entriesToClear, Collection entriesToSet) { + Stream.concat(entriesToClear.stream(), entriesToSet.stream()).forEach(entry -> { + V backup = AbstractEntryBasedExtension.this.getEntry(entry); + if (backup == null) + this.entriesToClear.add(entry); + else + this.entriesToSet.put(entry, backup); + }); + } + + void restoreBackup() { + entriesToClear.forEach(AbstractEntryBasedExtension.this::clearEntry); + entriesToSet.forEach(AbstractEntryBasedExtension.this::setEntry); + } + + } + + /** + * @return Mapper function to get the key from a clear annotation. + */ + protected abstract Function clearKeyMapper(); + + /** + * @return Mapper function to get the key from a set annotation. + */ + protected abstract Function setKeyMapper(); + + /** + * @return Mapper function to get the value from a set annotation. + */ + protected abstract Function setValueMapper(); + + /** + * Removes the entry indicated by the specified key. + */ + protected abstract void clearEntry(K key); + + /** + * Gets the entry indicated by the specified key. + */ + protected abstract V getEntry(K key); + + /** + * Sets the entry indicated by the specified key. + */ + protected abstract void setEntry(K key, V value); + + /** + * Reports a warning about potentially unsafe practices. + */ + protected void reportWarning(ExtensionContext context) { + // nothing reported by default + } + + /** + * Prepare the entry-based environment for entering a context that must be restorable. + * + *

Implementations may choose one of two strategies:

+ * + *
    + *
  • Post swap, where the original entry-based environment is left in place and a clone is returned. + * In this case {@link #prepareToExitRestorableContext} will restore the clone. + *
  • Preemptive swap, where the current entry-based environment is replaced with a clone and the + * original is returned. + * In this case the {@link #prepareToExitRestorableContext} will restore the original environment.
  • + *
+ * + *

The returned {@code Properties} must not be null and its key-value pairs must follow the rules for + * entries of its type. E.g., environment variables contain only Strings while System {@code Properties} + * may contain Objects.

+ * + * @return A non-null {@code Properties} that contains all entries of the entry environment. + */ + protected abstract Properties prepareToEnterRestorableContext(); + + /** + * Prepare to exit a restorable context for the entry based environment. + * + *

The entry environment will be restored to the state passed in as {@code Properties}. + * The {@code Properties} entries must follow the rules for entries of this environment, + * e.g., environment variables contain only Strings while System {@code Properties} may contain Objects.

+ * + * @param entries A non-null {@code Properties} that contains all entries of the entry environment. + */ + protected abstract void prepareToExitRestorableContext(Properties entries); +} diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/ClearSystemProperty.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/ClearSystemProperty.java new file mode 100644 index 000000000000..b0db6a4ffa8f --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/ClearSystemProperty.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.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.apiguardian.api.API; + +/** + * {@code @ClearSystemProperty} is a JUnit Jupiter extension to clear the value + * of a system property for a test execution. + * + *

The key of the system property to be cleared must be specified via {@link #key()}. + * After the annotated element has been executed, the original value or the value of the + * higher-level container is restored.

+ * + *

{@code ClearSystemProperty} can be used on the method and on the class level. + * It is repeatable and inherited from higher-level containers. If a class is + * annotated, the configured property will be cleared before every test inside that + * class.

+ * + *
+ * + *

For more details and examples, see + * the documentation on @ClearSystemProperty and @SetSystemProperty.

+ * + * @since 6.1.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.TYPE }) +@Inherited +@Repeatable(ClearSystemProperty.ClearSystemProperties.class) +@WritesSystemProperty +@API(status = API.Status.STABLE, since = "6.1") +public @interface ClearSystemProperty { + + /** + * The key of the system property to be cleared. + */ + String key(); + + /** + * Containing annotation of repeatable {@code @ClearSystemProperty}. + */ + @Retention(RetentionPolicy.RUNTIME) + @Target({ ElementType.METHOD, ElementType.TYPE }) + @Inherited + @WritesSystemProperty + @API(status = API.Status.STABLE, since = "6.1") + @interface ClearSystemProperties { + + ClearSystemProperty[] value(); + + } + +} diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/ReadsSystemProperty.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/ReadsSystemProperty.java new file mode 100644 index 000000000000..c74050909748 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/ReadsSystemProperty.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 system properties but don't use the system property extension themselves. + * + *

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

+ * + *

For more details and examples, see + * the documentation on @ClearSystemProperty and @SetSystemProperty.

+ * + * @since 6.1.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.CONSTRUCTOR, ElementType.METHOD, ElementType.PACKAGE, ElementType.TYPE }) +@Inherited +@ResourceLock(value = Resources.SYSTEM_PROPERTIES, mode = ResourceAccessMode.READ) +@API(status = API.Status.STABLE, since = "6.1") +public @interface ReadsSystemProperty { +} diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/RestoreSystemProperties.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/RestoreSystemProperties.java new file mode 100644 index 000000000000..3de1b2487847 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/RestoreSystemProperties.java @@ -0,0 +1,71 @@ +/* + * 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 @RestoreSystemProperties} is a JUnit Jupiter extension to restore the entire set of + * system properties to the original value, or the value of the higher-level container, after the + * annotated element is complete. + * + *

Use this annotation when there is a need programmatically modify system properties in a test + * method or in {@code @BeforeAll} / {@code @BeforeEach} blocks. + * To simply set or clear a system property, consider {@link SetSystemProperty @SetSystemProperty} or + * {@link ClearSystemProperty @ClearSystemProperty} instead.

+ * + *

{@code RestoreSystemProperties} can be used on the method and on the class level. + * When placed on a test method, a snapshot of system properties is stored prior to that test. + * The snapshot is created before any {@code @BeforeEach} blocks in scope and before any + * {@link SetSystemProperty @SetSystemProperty} or {@link ClearSystemProperty @ClearSystemProperty} + * annotations on that method. After the test, system properties are restored from the + * snapshot after any {@code @AfterEach} have completed. + * + *

When placed on a test class, a snapshot of system properties is stored prior to any + * {@code @BeforeAll} blocks in scope and before any {@link SetSystemProperty @SetSystemProperty} + * or {@link ClearSystemProperty @ClearSystemProperty} annotations on that class. + * After the test class completes, system properties are restored from the snapshot after any + * {@code @AfterAll} blocks have completed. + * In addition, a class level annotation is inherited by each test method just as if each one was + * annotated with {@code RestoreSystemProperties}. + * + *

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

+ * + *

For more details and examples, see + * the documentation on + * @ClearSystemProperty, @SetSystemProperty, and @RestoreSystemProperties.

+ * + *

Note: System properties are normally just a hashmap of strings, however, it is + * technically possible to store non-string values and create nested {@code Properties} with inherited / + * default values. Within the context of an element annotated with {@link RestoreSystemProperties}, + * non-String values are not preserved and the structure of nested defaults are flattened. + * After the annotated context is exited, the original Properties object is restored with + * all its potential (non-standard) richness.

+ * + * @since 6.1.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.TYPE }) +@Inherited +@WritesSystemProperty +@API(status = API.Status.STABLE, since = "6.1") +public @interface RestoreSystemProperties { +} diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/SetSystemProperty.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/SetSystemProperty.java new file mode 100644 index 000000000000..93565323ce94 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/SetSystemProperty.java @@ -0,0 +1,80 @@ +/* + * 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.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.apiguardian.api.API; + +/** + * {@code @SetSystemProperty} is a JUnit Jupiter extension to set the value of a + * system property for a test execution. + * + *

The key and value of the system property to be set must be specified via + * {@link #key()} and {@link #value()}. After the annotated method has been + * executed, the original value or the value of the higher-level container is + * restored.

+ * + *

{@code SetSystemProperty} can be used on the method and on the class level. + * It is repeatable and inherited from higher-level containers. If a class is + * annotated, the configured property will be set before every test inside that + * class. Any method-level configurations will override the class-level + * configurations.

+ * + *

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

+ * + *

For more details and examples, see + * the documentation on @ClearSystemProperty and @SetSystemProperty.

+ * + * @since 6.1.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.TYPE }) +@Inherited +@Repeatable(SetSystemProperty.SetSystemProperties.class) +@WritesSystemProperty +@API(status = API.Status.STABLE, since = "6.1") +public @interface SetSystemProperty { + + /** + * The key of the system property to be set. + */ + String key(); + + /** + * The value of the system property to be set. + */ + String value(); + + /** + * Containing annotation of repeatable {@code @SetSystemProperty}. + */ + @Retention(RetentionPolicy.RUNTIME) + @Target({ ElementType.METHOD, ElementType.TYPE }) + @Inherited + @WritesSystemProperty + @API(status = API.Status.STABLE, since = "6.1") + @interface SetSystemProperties { + + SetSystemProperty[] value(); + + } + +} diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/SystemPropertyExtension.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/SystemPropertyExtension.java new file mode 100644 index 000000000000..02c92399ef80 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/SystemPropertyExtension.java @@ -0,0 +1,115 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.api.util; + +import java.util.Properties; +import java.util.function.Function; + +/** + * @since 6.1 + */ +final class SystemPropertyExtension extends + AbstractEntryBasedExtension { + + @Override + protected Function clearKeyMapper() { + return ClearSystemProperty::key; + } + + @Override + protected Function setKeyMapper() { + return SetSystemProperty::key; + } + + @Override + protected Function setValueMapper() { + return SetSystemProperty::value; + } + + @Override + protected void clearEntry(String key) { + System.clearProperty(key); + } + + @Override + protected String getEntry(String key) { + return System.getProperty(key); + } + + @Override + protected void setEntry(String key, String value) { + System.setProperty(key, value); + } + + /** + * This implementation uses the "Preemptive swap" strategy. + * + *

Since {@link Properties} allows a wrapped default instance and Object values, + * cloning is difficult:

+ * + *
    + *
  • It is difficult to tell which values are defaults and which are "top level", + * thus a clone might contain the same effective values, but be flattened without defaults.
  • + *
  • Object values in a wrapped default instance cannot be accessed without reflection.
  • + *
+ * + *

The "Preemptive swap" strategy ensure that the original Properties are restored, however + * complex they were. Any artifacts resulting from a flattened default structure are limited + * to the context of the test.

+ * + *

See {@link AbstractEntryBasedExtension#prepareToEnterRestorableContext} for more details.

+ * + * @return The original {@link System#getProperties} object + */ + @Override + protected Properties prepareToEnterRestorableContext() { + Properties current = System.getProperties(); + Properties clone = createEffectiveClone(current); + + System.setProperties(clone); + + return current; + } + + @Override + protected void prepareToExitRestorableContext(Properties properties) { + System.setProperties(properties); + } + + /** + * A clone of the String values of the passed {@code Properties}, including defaults. + * + *

The clone will have the same effective values, but may not use the same nested + * structure as the original. Object values, which are technically possible, + * are not included in the clone.

+ * + * @param original {@code Properties} to be cloned. + * @return A new {@code Properties} instance containing the same effective entries as the original. + */ + static Properties createEffectiveClone(Properties original) { + Properties clone = new Properties(); + + // This implementation is used because: + // System.getProperties() returns the actual Properties object, not a copy. + // Clone doesn't include nested defaults, but propertyNames() does. + original.propertyNames().asIterator().forEachRemaining(k -> { + String v = original.getProperty(k.toString()); + + if (v != null) { + // v will be null if the actual value was an object + clone.put(k, original.getProperty(k.toString())); + } + }); + + return clone; + } + +} diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/SystemPropertyExtensionUtils.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/SystemPropertyExtensionUtils.java new file mode 100644 index 000000000000..1f0ca1e72a9d --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/SystemPropertyExtensionUtils.java @@ -0,0 +1,62 @@ +/* + * 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.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collector; + +import org.junit.jupiter.api.extension.ExtensionContext; + +/** + * Utility methods for the SystemPropertiesExtension. + */ +final class SystemPropertyExtensionUtils { + + private SystemPropertyExtensionUtils() { + // private constructor to prevent instantiation of utility class + } + + /** + * A {@link java.util.stream.Collectors#toSet() toSet} collector that throws an {@link IllegalStateException} + * on duplicate elements (according to {@link Object#equals(Object) equals}). + */ + public static Collector, Set> distinctToSet() { + return Collector.of(HashSet::new, SystemPropertyExtensionUtils::addButThrowIfDuplicate, (left, right) -> { + right.forEach(element -> addButThrowIfDuplicate(left, element)); + return left; + }); + } + + private static void addButThrowIfDuplicate(Set set, T element) { + boolean newElement = set.add(element); + if (!newElement) { + throw new IllegalStateException("Duplicate element '" + element + "'."); + } + } + + /** + * Find all (parent) {@code ExtensionContext}s via {@link ExtensionContext#getParent()}. + * + * @param context the context for which to find all (parent) contexts; never {@code null} + * @return a list of all contexts, "outwards" in the {@link ExtensionContext#getParent() getParent}-order, + * beginning with the given context; never {@code null} or empty + */ + public static List findAllContexts(ExtensionContext context) { + List allContexts = new ArrayList<>(); + for (var c = context; c != null; c = c.getParent().orElse(null)) { + allContexts.add(c); + } + return allContexts; + } +} diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/WritesSystemProperty.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/WritesSystemProperty.java new file mode 100644 index 000000000000..0f36d10897f8 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/WritesSystemProperty.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 system properties but don't use the system property extension themselves. + * + *

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

+ * + *

For more details and examples, see + * the documentation on @ClearSystemProperty and @SetSystemProperty.

+ * + * @since 6.1.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.CONSTRUCTOR, ElementType.METHOD, ElementType.PACKAGE, ElementType.TYPE }) +@Inherited +@ResourceLock(value = Resources.SYSTEM_PROPERTIES, mode = ResourceAccessMode.READ_WRITE) +@API(status = API.Status.STABLE, since = "6.1") +public @interface WritesSystemProperty { +} diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/package-info.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/package-info.java index 2106ecf7df7c..7779e53db48f 100644 --- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/package-info.java +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/util/package-info.java @@ -3,6 +3,7 @@ * * @see org.junit.jupiter.api.util.DefaultLocale * @see org.junit.jupiter.api.util.DefaultTimeZone + * @see org.junit.jupiter.api.util.SetSystemProperty */ @NullMarked diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/api/util/PropertiesAssertions.java b/jupiter-tests/src/test/java/org/junit/jupiter/api/util/PropertiesAssertions.java new file mode 100644 index 000000000000..7b5e8230ef11 --- /dev/null +++ b/jupiter-tests/src/test/java/org/junit/jupiter/api/util/PropertiesAssertions.java @@ -0,0 +1,210 @@ +/* + * 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.reflect.Field; +import java.util.Properties; + +import org.assertj.core.api.AbstractAssert; +import org.jspecify.annotations.Nullable; +import org.junit.platform.commons.support.HierarchyTraversalMode; +import org.junit.platform.commons.support.ReflectionSupport; + +/** + * Allows comparison of {@link Properties} with optional awareness of their structure, + * rather than just treating them as Maps. Object values, which are marginally supported + * by {@code Properties}, are supported in assertions as much as possible. + */ +public class PropertiesAssertions extends AbstractAssert { + + /** + * Make an assertion on a {@link Properties} instance. + * + * @param actual The {@link Properties} instance the assertion is made with respect to + * @return Assertion instance + */ + public static PropertiesAssertions assertThat(Properties actual) { + return new PropertiesAssertions(actual); + } + + PropertiesAssertions(Properties actual) { + super(actual, PropertiesAssertions.class); + } + + /** + * Assert Properties has the same effective values as the passed instance, but not + * the same nested default structure. + * + *

Properties are considered effectively equal if they have the same property + * names returned by {@code Properties.propertyNames()} and the same values returned by + * {@code getProperty(name)}. Properties may come from the properties instance itself, + * or from a nested default instance, indiscriminately. + * + *

Properties partially supports object values, but return null for {@code getProperty(name)} + * when the value is a non-string. This assertion follows the same rules: Any non-String + * value is considered null for comparison purposes. + * + * @param expected The actual is expected to be effectively the same as this Properties + * @return Assertion instance + */ + public PropertiesAssertions isEffectivelyEqualsTo(Properties expected) { + + // Compare values present in actual + actual.propertyNames().asIterator().forEachRemaining(k -> { + + String kStr = k.toString(); + + String actValue = actual.getProperty(kStr); + String expValue = expected.getProperty(kStr); + + if (actValue == null) { + if (expValue != null) { + // An object value is the only way to get a null from getProperty() + throw failure("For the property '<%s>', " + + "the actual value was an object but the expected the string '<%s>'.", + k, expValue); + } + } + else if (!actValue.equals(expValue)) { + throw failure("For the property '<%s>', the actual value was <%s> but <%s> was expected", k, actValue, + expValue); + } + }); + + // Compare values present in expected - Anything not matching must not have been present in actual + expected.propertyNames().asIterator().forEachRemaining(k -> { + + String kStr = k.toString(); + + String actValue = actual.getProperty(kStr); + String expValue = expected.getProperty(kStr); + + if (expValue == null) { + if (actValue != null) { + + // An object value is the only way to get a null from getProperty() + throw failure("For the property '<%s>', " + + "the actual value was the string '<%s>', but an object was expected.", + k, actValue); + } + } + else if (!expValue.equals(actValue)) { + throw failure("The property <%s> was expected to be <%s>, but was missing", k, expValue); + } + }); + + return this; + } + + /** + * The converse of isEffectivelyEqualTo. + * + * @param expected The actual is expected to NOT be effectively equal to this Properties + * @return Assertion instance + */ + public PropertiesAssertions isNotEffectivelyEqualTo(Properties expected) { + try { + isEffectivelyEqualsTo(expected); + } + catch (AssertionError ae) { + return this; // Expected + } + + throw failure("The actual Properties should not be effectively equal to the expected one."); + } + + /** + * Compare values directly present in Properties and recursively into default Properties. + * + * @param expected The actual is expected to be strictly equal to this Properties + * @return Assertion instance + */ + public PropertiesAssertions isStrictlyEqualTo(Properties expected) { + + // Compare values present in actual + actual.keySet().forEach(k -> { + + // not null check only added, because "compileTestJava"-goal does not recognize that they can't be null + if (null != actual.get(k) && null != expected.get(k)) { + if (!actual.get(k).equals(expected.get(k))) { + throw failure("For the property <%s> the actual value was <%s> but <%s> was expected", k, + actual.get(k), expected.get(k)); + } + } + }); + + // Compare values present in expected - Anything not matching must not have been present in actual + expected.keySet().forEach(k -> { + // not null check only added, because "compileTestJava"-goal does not recognize that they can't be null + if (null != actual.get(k) && null != expected.get(k)) { + if (!expected.get(k).equals(actual.get(k))) { + throw failure("The property <%s> was expected to be <%s>, but was missing", k, expected.get(k)); + } + } + }); + + // Dig down into the nested defaults + Properties actualDefault = getDefaultFieldValue(actual); + Properties expectedDefault = getDefaultFieldValue(expected); + + if (actualDefault != null && expectedDefault != null) { + return new PropertiesAssertions(actualDefault).isStrictlyEqualTo(expectedDefault); + } + else if (actualDefault != null) { + throw failure("The actual Properties had non-null defaults, but none were expected"); + } + else if (expectedDefault != null) { + throw failure("The expected Properties had non-null defaults, but none were in actual"); + } + + return this; + } + + /** + * Simple converse of isStrictlyEqualTo. + * + * @param expected The actual is expected to NOT be strictly equal to this Properties + * @return Assertion instance + */ + public PropertiesAssertions isNotStrictlyEqualTo(Properties expected) { + try { + isStrictlyEqualTo(expected); + } + catch (AssertionError ae) { + return this; // Expected + } + + throw failure("The actual Properties should not be strictly the same as the expected one."); + } + + /** + * Use reflection to grab the {@code defaults} field from a java.utils.Properties instance. + * + * @param parent The Properties to fetch default values from + * @return The Properties instance that was stored as defaults in the parent. + */ + protected @Nullable Properties getDefaultFieldValue(Properties parent) { + Field field = ReflectionSupport.findFields(Properties.class, f -> f.getName().equals("defaults"), + HierarchyTraversalMode.BOTTOM_UP).stream().findFirst().get(); + + field.setAccessible(true); + + try { + return (Properties) ReflectionSupport.tryToReadFieldValue(field, parent).get(); + } + catch (Exception e) { + throw new RuntimeException("Unable to access the java.util.Properties.defaults field by reflection. " + + "Please adjust your local environment to allow this.", + e); + } + } + +} diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/api/util/SystemPropertyExtensionTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/api/util/SystemPropertyExtensionTests.java new file mode 100644 index 000000000000..347a451bdb4f --- /dev/null +++ b/jupiter-tests/src/test/java/org/junit/jupiter/api/util/SystemPropertyExtensionTests.java @@ -0,0 +1,727 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.api.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.parallel.ExecutionMode.SAME_THREAD; +import static org.junit.jupiter.api.util.PropertiesAssertions.assertThat; +import static org.junit.platform.engine.discovery.DiscoverySelectors.selectMethod; +import static org.junit.platform.testkit.engine.EventConditions.finishedWithFailure; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.instanceOf; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.message; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.Properties; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.ClassOrderer; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestClassOrder; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.extension.ExtensionConfigurationException; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.engine.AbstractJupiterTestEngineTests; +import org.junit.platform.testkit.engine.EngineExecutionResults; + +@DisplayName("SystemProperty extension") +class SystemPropertyExtensionTests extends AbstractJupiterTestEngineTests { + + @BeforeAll + static void globalSetUp() { + System.setProperty("A", "old A"); + System.setProperty("B", "old B"); + System.setProperty("C", "old C"); + + System.clearProperty("clear prop D"); + System.clearProperty("clear prop E"); + System.clearProperty("clear prop F"); + } + + @AfterAll + static void globalTearDown() { + System.clearProperty("A"); + System.clearProperty("B"); + System.clearProperty("C"); + + assertThat(System.getProperty("clear prop D")).isNull(); + assertThat(System.getProperty("clear prop E")).isNull(); + assertThat(System.getProperty("clear prop F")).isNull(); + } + + @Nested + @DisplayName("used with ClearSystemProperty") + @ClearSystemProperty(key = "A") + class ClearSystemPropertyTests { + + @Test + @DisplayName("should clear system property") + @ClearSystemProperty(key = "B") + void shouldClearSystemProperty() { + assertThat(System.getProperty("A")).isNull(); + assertThat(System.getProperty("B")).isNull(); + assertThat(System.getProperty("C")).isEqualTo("old C"); + + assertThat(System.getProperty("clear prop D")).isNull(); + assertThat(System.getProperty("clear prop E")).isNull(); + assertThat(System.getProperty("clear prop F")).isNull(); + } + + @Test + @DisplayName("should be repeatable") + @ClearSystemProperty(key = "B") + @ClearSystemProperty(key = "C") + void shouldBeRepeatable() { + assertThat(System.getProperty("A")).isNull(); + assertThat(System.getProperty("B")).isNull(); + assertThat(System.getProperty("C")).isNull(); + + assertThat(System.getProperty("clear prop D")).isNull(); + assertThat(System.getProperty("clear prop E")).isNull(); + assertThat(System.getProperty("clear prop F")).isNull(); + } + + } + + @Nested + @DisplayName("used with SetSystemProperty") + @SetSystemProperty(key = "A", value = "new A") + class SetSystemPropertyTests { + + @Test + @DisplayName("should set system property to value") + @SetSystemProperty(key = "B", value = "new B") + void shouldSetSystemPropertyToValue() { + assertThat(System.getProperty("A")).isEqualTo("new A"); + assertThat(System.getProperty("B")).isEqualTo("new B"); + assertThat(System.getProperty("C")).isEqualTo("old C"); + + assertThat(System.getProperty("clear prop D")).isNull(); + assertThat(System.getProperty("clear prop E")).isNull(); + assertThat(System.getProperty("clear prop F")).isNull(); + } + + @Test + @DisplayName("should be repeatable") + @SetSystemProperty(key = "B", value = "new B") + @SetSystemProperty(key = "clear prop D", value = "new D") + void shouldBeRepeatable() { + assertThat(System.getProperty("A")).isEqualTo("new A"); + assertThat(System.getProperty("B")).isEqualTo("new B"); + assertThat(System.getProperty("C")).isEqualTo("old C"); + + assertThat(System.getProperty("clear prop D")).isEqualTo("new D"); + assertThat(System.getProperty("clear prop E")).isNull(); + assertThat(System.getProperty("clear prop F")).isNull(); + } + + } + + @Nested + @DisplayName("used with both ClearSystemProperty and SetSystemProperty") + @ClearSystemProperty(key = "A") + @SetSystemProperty(key = "clear prop D", value = "new D") + class CombinedClearAndSetTests { + + @Test + @DisplayName("should be combinable") + @ClearSystemProperty(key = "B") + @SetSystemProperty(key = "clear prop E", value = "new E") + void clearAndSetSystemPropertyShouldBeCombinable() { + assertThat(System.getProperty("A")).isNull(); + assertThat(System.getProperty("B")).isNull(); + assertThat(System.getProperty("C")).isEqualTo("old C"); + + assertThat(System.getProperty("clear prop D")).isEqualTo("new D"); + assertThat(System.getProperty("clear prop E")).isEqualTo("new E"); + assertThat(System.getProperty("clear prop F")).isNull(); + } + + @Test + @DisplayName("method level should overwrite class level") + @ClearSystemProperty(key = "clear prop D") + @SetSystemProperty(key = "A", value = "new A") + void methodLevelShouldOverwriteClassLevel() { + assertThat(System.getProperty("A")).isEqualTo("new A"); + assertThat(System.getProperty("B")).isEqualTo("old B"); + assertThat(System.getProperty("C")).isEqualTo("old C"); + + assertThat(System.getProperty("clear prop D")).isNull(); + assertThat(System.getProperty("clear prop E")).isNull(); + assertThat(System.getProperty("clear prop F")).isNull(); + } + + @Test + @DisplayName("method level should not clash (in terms of duplicate entries) with class level") + @SetSystemProperty(key = "A", value = "new A") + void methodLevelShouldNotClashWithClassLevel() { + assertThat(System.getProperty("A")).isEqualTo("new A"); + assertThat(System.getProperty("B")).isEqualTo("old B"); + assertThat(System.getProperty("C")).isEqualTo("old C"); + assertThat(System.getProperty("clear prop D")).isEqualTo("new D"); + + assertThat(System.getProperty("clear prop E")).isNull(); + assertThat(System.getProperty("clear prop F")).isNull(); + } + + } + + @Nested + @DisplayName("used with Clear, Set and Restore") + @WritesSystemProperty // Many of these tests write, many also access + @Execution(SAME_THREAD) // Uses instance state + @TestInstance(TestInstance.Lifecycle.PER_CLASS) // Uses instance state + @TestClassOrder(ClassOrderer.OrderAnnotation.class) + class CombinedClearSetRestoreTests { + + Properties initialState; // Stateful + + @BeforeAll + void beforeAll() { + initialState = System.getProperties(); + } + + @Nested + @Order(1) + @DisplayName("Set, Clear & Restore on class") + @ClearSystemProperty(key = "A") + @SetSystemProperty(key = "clear prop D", value = "new D") + @RestoreSystemProperties + @TestMethodOrder(OrderAnnotation.class) + @TestInstance(TestInstance.Lifecycle.PER_CLASS) // Uses instance state + class SetClearRestoreOnClass { + + @AfterAll + void afterAll() { + System.setProperties(new Properties()); // Really blow it up after this class + } + + @Test + @Order(1) + @DisplayName("Set, Clear on method w/ direct set Sys Prop") + @ClearSystemProperty(key = "B") + @SetSystemProperty(key = "clear prop E", value = "new E") + void clearSetRestoreShouldBeCombinable() { + assertThat(System.getProperties()).withFailMessage( + "Restore should swap out the Sys Properties instance").isNotSameAs(initialState); + + // Direct modification - shouldn't be visible in next test + System.setProperty("Restore", "Restore Me"); + System.getProperties().put("XYZ", this); + + assertThat(System.getProperty("Restore")).isEqualTo("Restore Me"); + assertThat(System.getProperties().get("XYZ")).isSameAs(this); + + // All the others + assertThat(System.getProperty("A")).isNull(); + assertThat(System.getProperty("B")).isNull(); + assertThat(System.getProperty("C")).isEqualTo("old C"); + + assertThat(System.getProperty("clear prop D")).isEqualTo("new D"); + assertThat(System.getProperty("clear prop E")).isEqualTo("new E"); + assertThat(System.getProperty("clear prop F")).isNull(); + } + + @Test + @DisplayName("Restore from class should restore direct mods") + @Order(2) + void restoreShouldHaveRevertedDirectModification() { + assertThat(System.getProperty("Restore")).isNull(); + assertThat(System.getProperties().get("XYZ")).isNull(); + } + + } + + @Nested + @Order(2) + @DisplayName("Prior nested class changes should be restored}") + class priorNestedChangesRestored { + + @Test + @DisplayName("Restore from class should restore direct mods") + void restoreShouldHaveRevertedDirectModification() { + assertThat(System.getProperties()).isStrictlyEqualTo(initialState); + } + + } + + @Nested + @Order(3) + @DisplayName("Set & Clear on class, Restore on method") + @ClearSystemProperty(key = "A") + @SetSystemProperty(key = "clear prop D", value = "new D") + @TestMethodOrder(OrderAnnotation.class) + @TestInstance(TestInstance.Lifecycle.PER_CLASS) // Uses instance state + class SetAndClearOnClass { + + Properties initialState; // Stateful + + @BeforeAll + void beforeAll() { + initialState = System.getProperties(); + } + + @Test + @Order(1) + @DisplayName("Set, Clear & Restore on method w/ direct set Sys Prop") + @ClearSystemProperty(key = "B") + @SetSystemProperty(key = "clear prop E", value = "new E") + @RestoreSystemProperties + void clearSetRestoreShouldBeCombinable() { + assertThat(System.getProperties()).withFailMessage( + "Restore should swap out the Sys Properties instance").isNotSameAs(initialState); + + // Direct modification - shouldn't be visible in the next test + System.setProperty("Restore", "Restore Me"); + System.getProperties().put("XYZ", this); + + // All the others + assertThat(System.getProperty("A")).isNull(); + assertThat(System.getProperty("B")).isNull(); + assertThat(System.getProperty("C")).isEqualTo("old C"); + + assertThat(System.getProperty("clear prop D")).isEqualTo("new D"); + assertThat(System.getProperty("clear prop E")).isEqualTo("new E"); + assertThat(System.getProperty("clear prop F")).isNull(); + } + + @Test + @DisplayName("Restore from prior method should restore direct mods") + @Order(2) + void restoreShouldHaveRevertedDirectModification() { + assertThat(System.getProperty("Restore")).isNull(); + assertThat(System.getProperties().get("XYZ")).isNull(); + assertThat(System.getProperties()).isStrictlyEqualTo(initialState); + } + + } + + } + + @Nested + @DisplayName("RestoreSystemProperties individual methods tests") + @WritesSystemProperty // Many of these tests write, many also access + class RestoreSystemPropertiesUnitTests { + + SystemPropertyExtension spe; + + @BeforeEach + void beforeEach() { + spe = new SystemPropertyExtension(); + } + + @Nested + @DisplayName("Attributes of RestoreSystemProperties Annotation") + class BasicAttributesOfRestoreSystemProperties { + + @Test + @DisplayName("Restore annotation has correct markers") + void restoreHasCorrectMarkers() { + assertThat(RestoreSystemProperties.class).hasAnnotations(Inherited.class, WritesSystemProperty.class); + } + + @Test + @DisplayName("Restore annotation has correct retention") + void restoreHasCorrectRetention() { + assertThat(RestoreSystemProperties.class.getAnnotation(Retention.class).value()).isEqualTo( + RetentionPolicy.RUNTIME); + } + + @Test + @DisplayName("Restore annotation has correct targets") + void restoreHasCorrectTargets() { + assertThat(RestoreSystemProperties.class.getAnnotation(Target.class).value()).containsExactlyInAnyOrder( + ElementType.METHOD, ElementType.TYPE); + } + + } + + @Nested + @DisplayName("cloneProperties Tests") + class ClonePropertiesTests { + + Properties inner; + Properties outer; //Created w/ inner as nested defaults + + @BeforeEach + void beforeEach() { + inner = new Properties(); + outer = new Properties(inner); + + inner.setProperty("A", "is A"); + outer.setProperty("B", "is B"); + } + + @Test + @DisplayName("Nested defaults handled") + void nestedDefaultsHandled() { + Properties cloned = SystemPropertyExtension.createEffectiveClone(outer); + assertThat(cloned).isEffectivelyEqualsTo(outer); + } + + @Test + @DisplayName("Object values are skipped") + void objectValuesAreSkipped() { + inner.put("inner_obj", new Object()); + outer.put("outer_obj", new Object()); + Properties cloned = SystemPropertyExtension.createEffectiveClone(outer); + + assertThat(cloned).isEffectivelyEqualsTo(outer); + assertThat(cloned.contains("inner_obj")).isFalse(); + assertThat(cloned.contains("outer_obj")).isFalse(); + } + + } + + @Nested + @DisplayName("RestorableContext Workflow Tests") + class RestorableContextWorkflowTests { + + @Test + @DisplayName("Workflow of RestorableContext") + void workflowOfRestorableContexts() { + Properties initialState = System.getProperties(); //This is a live reference + + try { + Properties returnedFromPrepareToEnter = spe.prepareToEnterRestorableContext(); + Properties postPrepareToEnterSysProps = System.getProperties(); + spe.prepareToExitRestorableContext(initialState); + Properties postPrepareToExitSysProps = System.getProperties(); + + assertThat(returnedFromPrepareToEnter).withFailMessage( + "prepareToEnterRestorableContext should return actual original or deep copy").isStrictlyEqualTo( + initialState); + + assertThat(returnedFromPrepareToEnter).withFailMessage( + "prepareToEnterRestorableContext should replace the actual Sys Props").isNotSameAs( + postPrepareToEnterSysProps); + + assertThat(postPrepareToEnterSysProps).isEffectivelyEqualsTo(initialState); + + // Could assert isSameAs, but a deep copy would also be allowed + assertThat(postPrepareToExitSysProps).isStrictlyEqualTo(initialState); + + } + finally { + System.setProperties(initialState); // Ensure complete recovery + } + } + + } + + } + + @Nested + @DisplayName("with nested classes") + @ClearSystemProperty(key = "A") + @SetSystemProperty(key = "B", value = "new B") + class NestedSystemPropertyTests { + + @Nested + @TestMethodOrder(OrderAnnotation.class) + @DisplayName("without SystemProperty annotations") + class NestedClass { + + @Test + @Order(1) + @ReadsSystemProperty + @DisplayName("system properties should be set from enclosed class when they are not provided in nested") + void shouldSetSystemPropertyFromEnclosedClass() { + assertThat(System.getProperty("A")).isNull(); + assertThat(System.getProperty("B")).isEqualTo("new B"); + } + + @Test + @Order(2) + @ReadsSystemProperty + @DisplayName("system properties should be set from enclosed class after restore") + void shouldSetSystemPropertyFromEnclosedClassAfterRestore() { + assertThat(System.getProperty("A")).isNull(); + assertThat(System.getProperty("B")).isEqualTo("new B"); + } + + } + + @Nested + @SetSystemProperty(key = "B", value = "newer B") + @DisplayName("with SetSystemProperty annotation") + class AnnotatedNestedClass { + + @Test + @ReadsSystemProperty + @DisplayName("system property should be set from nested class when it is provided") + void shouldSetSystemPropertyFromNestedClass() { + assertThat(System.getProperty("B")).isEqualTo("newer B"); + } + + @Test + @SetSystemProperty(key = "B", value = "newest B") + @DisplayName("system property should be set from method when it is provided") + void shouldSetSystemPropertyFromMethodOfNestedClass() { + assertThat(System.getProperty("B")).isEqualTo("newest B"); + } + + } + + } + + @Nested + @SetSystemProperty(key = "A", value = "new A") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class ResettingSystemPropertyTests { + + @Nested + @SetSystemProperty(key = "A", value = "newer A") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class ResettingSystemPropertyAfterEachNestedTests { + + @BeforeEach + void changeShouldBeVisible() { + // We already see "newest A" because BeforeEachCallBack is invoked before @BeforeEach + // See https://junit.org/junit5/docs/current/user-guide/#extensions-execution-order-overview + assertThat(System.getProperty("A")).isEqualTo("newest A"); + } + + @Test + @SetSystemProperty(key = "A", value = "newest A") + void setForTestMethod() { + assertThat(System.getProperty("A")).isEqualTo("newest A"); + } + + @AfterEach + @ReadsSystemProperty + void resetAfterTestMethodExecution() { + // We still see "newest A" because AfterEachCallBack is invoked after @AfterEach + // See https://junit.org/junit5/docs/current/user-guide/#extensions-execution-order-overview + assertThat(System.getProperty("A")).isEqualTo("newest A"); + } + + } + + @Nested + @SetSystemProperty(key = "A", value = "newer A") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class ResettingSystemPropertyAfterAllNestedTests { + + @BeforeAll + void changeShouldBeVisible() { + assertThat(System.getProperty("A")).isEqualTo("newer A"); + } + + @Test + @SetSystemProperty(key = "A", value = "newest A") + void setForTestMethod() { + assertThat(System.getProperty("A")).isEqualTo("newest A"); + } + + @AfterAll + @ReadsSystemProperty + void resetAfterTestMethodExecution() { + assertThat(System.getProperty("A")).isEqualTo("newer A"); + } + + } + + @AfterAll + @ReadsSystemProperty + void resetAfterTestContainerExecution() { + assertThat(System.getProperty("A")).isEqualTo("new A"); + } + + } + + @Nested + @DisplayName("used with incorrect configuration") + class ConfigurationFailureTests { + + @Test + @DisplayName("should fail when clear and set same system property") + void shouldFailWhenClearAndSetSameSystemProperty() { + EngineExecutionResults results = executeTests(selectMethod(MethodLevelInitializationFailureTestCases.class, + "shouldFailWhenClearAndSetSameSystemProperty")); + results.testEvents().assertThatEvents().haveAtMost(1, + finishedWithFailure(instanceOf(ExtensionConfigurationException.class), + message(it -> it.contains("@DefaultTimeZone not configured correctly.")))); + } + + @Test + @DisplayName("should fail when clear same system property twice") + @Disabled("This can't happen at the moment, because Jupiter's annotation tooling " + + "deduplicates identical annotations like the ones required for this test: " + + "https://github.com/junit-team/junit5/issues/2131") + void shouldFailWhenClearSameSystemPropertyTwice() { + EngineExecutionResults results = executeTests(selectMethod(MethodLevelInitializationFailureTestCases.class, + "shouldFailWhenClearSameSystemPropertyTwice")); + + results.testEvents().assertThatEvents().haveAtMost(1, + finishedWithFailure(instanceOf(ExtensionConfigurationException.class))); + } + + @Test + @DisplayName("should fail when set same system property twice") + void shouldFailWhenSetSameSystemPropertyTwice() { + EngineExecutionResults results = executeTests(selectMethod(MethodLevelInitializationFailureTestCases.class, + "shouldFailWhenSetSameSystemPropertyTwice")); + results.testEvents().assertThatEvents().haveAtMost(1, + finishedWithFailure(instanceOf(ExtensionConfigurationException.class))); + } + + } + + static class MethodLevelInitializationFailureTestCases { + + @Test + @DisplayName("clearing and setting the same property") + @ClearSystemProperty(key = "A") + @SetSystemProperty(key = "A", value = "new A") + void shouldFailWhenClearAndSetSameSystemProperty() { + } + + @Test + @ClearSystemProperty(key = "A") + @ClearSystemProperty(key = "A") + void shouldFailWhenClearSameSystemPropertyTwice() { + } + + @Test + @SetSystemProperty(key = "A", value = "new A") + @SetSystemProperty(key = "A", value = "new B") + void shouldFailWhenSetSameSystemPropertyTwice() { + } + + } + + @Nested + @DisplayName("used with inheritance") + class InheritanceClearAndSetTests extends InheritanceClearAndSetBaseTest { + + @Test + @DisplayName("should inherit clear and set annotations") + void shouldInheritClearAndSetProperty() { + assertThat(System.getProperty("A")).isNull(); + assertThat(System.getProperty("B")).isNull(); + assertThat(System.getProperty("clear prop D")).isEqualTo("new D"); + assertThat(System.getProperty("clear prop E")).isEqualTo("new E"); + } + + } + + @Nested + @DisplayName("used with inheritance") + @TestMethodOrder(OrderAnnotation.class) + @TestClassOrder(ClassOrderer.OrderAnnotation.class) + @Execution(SAME_THREAD) // Uses instance state + @TestInstance(TestInstance.Lifecycle.PER_CLASS) // Uses instance state + class InheritanceClearSetRestoreTests extends InheritanceClearSetRestoreBaseTest { + + Properties initialState; // Stateful + + @BeforeAll + void beforeAll() { + initialState = System.getProperties(); + } + + @Test + @Order(1) + @DisplayName("should inherit clear and set annotations") + void shouldInheritClearSetRestore() { + // Direct modification - shouldn't be visible in the next test + System.setProperty("Restore", "Restore Me"); + System.getProperties().put("XYZ", this); + + assertThat(System.getProperty("A")).isNull(); // The rest are checked elsewhere + } + + @Test + @Order(2) + @DisplayName("Restore from class should restore direct mods") + void restoreShouldHaveRevertedDirectModification() { + assertThat(System.getProperty("Restore")).isNull(); + assertThat(System.getProperties().get("XYZ")).isNull(); + assertThat(System.getProperties()).isStrictlyEqualTo(initialState); + } + + @Nested + @Order(1) + @DisplayName("Set props to ensure inherited restore") + @TestMethodOrder(OrderAnnotation.class) + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class SetSomeValuesToRestore { + + @AfterAll + void afterAll() { + System.setProperty("RestoreAll", "Restore Me"); // This should also be restored + } + + @Test + @Order(1) + @DisplayName("Inherit values and restore behavior") + void shouldInheritInNestedClass() { + assertThat(System.getProperty("A")).isNull(); + + // Shouldn't be visible in the next test + System.setProperty("Restore", "Restore Me"); + } + + @Test + @Order(2) + @DisplayName("Verify restore behavior bt methods") + void verifyRestoreBetweenMethods() { + assertThat(System.getProperty("Restore")).isNull(); + } + + } + + @Nested + @Order(2) + @DisplayName("Verify props are restored") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class VerifyValuesAreRestored { + + @Test + @DisplayName("Inherit values and restore behavior") + void shouldInheritInNestedClass() { + assertThat(System.getProperty("RestoreAll")).isNull(); // Should be restored + } + + } + + } + + @ClearSystemProperty(key = "A") + @ClearSystemProperty(key = "B") + @SetSystemProperty(key = "clear prop D", value = "new D") + @SetSystemProperty(key = "clear prop E", value = "new E") + static class InheritanceClearAndSetBaseTest { + + } + + @ClearSystemProperty(key = "A") + @ClearSystemProperty(key = "B") + @SetSystemProperty(key = "clear prop D", value = "new D") + @SetSystemProperty(key = "clear prop E", value = "new E") + @RestoreSystemProperties + static class InheritanceClearSetRestoreBaseTest { + } + +}

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