From 19369094ac8dc2e74e52c93ce000e332bac96c5c Mon Sep 17 00:00:00 2001 From: Sam Brannen Date: Sat, 27 Aug 2016 19:43:28 +0200 Subject: [PATCH] Revise @DisabledIf support for JUnit Jupiter - Extracted stand-alone DisabledIfCondition from the SpringExtension so that the condition is only evaluated when necessary. - Simplified implementation of DisabledIfCondition. - Overhauled and extended logging in DisabledIfCondition. - DisabledIfCondition now throws an IllegalStateException if @DisabledIf is not present on the test element or if the expression does not evaluate to a String or Boolean. - Each generated ConditionEvaluationResult now includes the actual expression in the default reason. - @DisabledIf is now auto-configured to be evaluated by the DisabledIfCondition since it is now meta-annotated with @ExtendWith(DisabledIfCondition.class) - Overhauled documentation for @DisabledIf and provided standard examples as well as an @DisabledOnMac annotation to demonstrate support for custom composed annotations. Issue: SPR-14614 --- .../context/junit/jupiter/DisabledIf.java | 75 ++++++++-- .../junit/jupiter/DisabledIfCondition.java | 137 ++++++++++++++++++ .../junit/jupiter/SpringExtension.java | 81 +---------- .../junit/jupiter/DisabledIfTestCase.java | 33 +++-- .../context/junit/jupiter/DisabledOnMac.java | 38 +++++ 5 files changed, 266 insertions(+), 98 deletions(-) create mode 100644 spring-test/src/main/java/org/springframework/test/context/junit/jupiter/DisabledIfCondition.java create mode 100644 spring-test/src/test/java/org/springframework/test/context/junit/jupiter/DisabledOnMac.java diff --git a/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/DisabledIf.java b/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/DisabledIf.java index eefbd43bfc0..e5da0195723 100644 --- a/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/DisabledIf.java +++ b/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/DisabledIf.java @@ -16,43 +16,96 @@ package org.springframework.test.context.junit.jupiter; -import org.springframework.core.annotation.AliasFor; - import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.core.annotation.AliasFor; + /** - * Disable JUnit 5(Jupiter) tests when evaluated condition returns "true" - * that can be either case insensitive {@code String} or {@code Boolean#TRUE}. + * {@code @DisabledIf} is used to signal that the annotated test class or test + * method is disabled and should not be executed if the supplied + * {@link #expression} evaluates to {@code true}. * + *

When applied at the class level, all test methods within that class + * are automatically disabled as well. + * + *

For basic examples, see the Javadoc for {@link #expression}. + * + *

This annotation may be used as a meta-annotation to create + * custom composed annotations. For example, a custom + * {@code @DisabledOnMac} annotation can be created as follows. + * + *

+ * {@literal @}Target({ ElementType.TYPE, ElementType.METHOD })
+ * {@literal @}Retention(RetentionPolicy.RUNTIME)
+ * {@literal @}DisabledIf(
+ *     expression = "#{systemProperties['os.name'].toLowerCase().contains('mac')}",
+ *     reason = "Disabled on Mac OS"
+ * )
+ * public {@literal @}interface DisabledOnMac {}
+ * 
+ * + * @author Sam Brannen * @author Tadaya Tsuyukubo * @since 5.0 * @see SpringExtension + * @see org.junit.jupiter.api.Disabled */ @Target({ ElementType.TYPE, ElementType.METHOD }) @Retention(RetentionPolicy.RUNTIME) @Documented +@ExtendWith(DisabledIfCondition.class) public @interface DisabledIf { /** - * Alias for {@link #condition()}. + * Alias for {@link #expression}; only intended to be used if an + * explicit {@link #reason} is not provided. + * + * @see #expression */ - @AliasFor("condition") + @AliasFor("expression") String value() default ""; /** - * Condition to disable test. + * The expression that will be evaluated to determine if the annotated test + * class or test method is disabled. * - *

When case insensitive {@code String} "true" or {@code Boolean#TRUE} is returned, - * annotated test method or class is disabled. - *

SpEL expression can be used. + *

If the expression evaluates to {@link Boolean#TRUE} or a {@link String} + * equal to {@code "true"} (ignoring case), the test will be disabled. + * + *

Expressions can be any of the following. + * + *

+ * + *

Note, however, that a text literal which is not the result of + * dynamic resolution of a property placeholder is of zero practical value + * since {@code @DisabledIf("true")} is equivalent to {@code @Disabled} + * and {@code @DisabledIf("false")} is logically meaningless. + * + * @see #reason + * @see #value */ @AliasFor("value") - String condition() default ""; + String expression() default ""; + /** + * The reason this test is disabled. + * + * @see #expression + */ String reason() default ""; } diff --git a/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/DisabledIfCondition.java b/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/DisabledIfCondition.java new file mode 100644 index 00000000000..ada71169241 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/DisabledIfCondition.java @@ -0,0 +1,137 @@ +/* + * Copyright 2002-2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.test.context.junit.jupiter; + +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; +import java.util.Optional; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.junit.jupiter.api.extension.ConditionEvaluationResult; +import org.junit.jupiter.api.extension.ContainerExecutionCondition; +import org.junit.jupiter.api.extension.ContainerExtensionContext; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.TestExecutionCondition; +import org.junit.jupiter.api.extension.TestExtensionContext; + +import org.springframework.beans.factory.config.BeanExpressionContext; +import org.springframework.beans.factory.config.BeanExpressionResolver; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * {@code DisabledIfCondition} is a composite {@link ContainerExecutionCondition} + * and {@link TestExecutionCondition} that supports the {@link DisabledIf @DisabledIf} + * annotation when using the Spring TestContext Framework in conjunction + * with JUnit 5's Jupiter programming model. + * + *

Any attempt to use {@code DisabledIfCondition} without the presence of + * {@link DisabledIf @DisabledIf} will result in an {@link IllegalStateException}. + * + * @author Sam Brannen + * @author Tadaya Tsuyukubo + * @since 5.0 + * @see org.springframework.test.context.junit.jupiter.DisabledIf + * @see org.springframework.test.context.junit.jupiter.SpringExtension + */ +public class DisabledIfCondition implements ContainerExecutionCondition, TestExecutionCondition { + + private static final Log logger = LogFactory.getLog(DisabledIfCondition.class); + + + /** + * Containers are disabled if {@code @DisabledIf} is present on the test class + * and the configured expression evaluates to {@code true}. + */ + @Override + public ConditionEvaluationResult evaluate(ContainerExtensionContext context) { + return evaluateDisabledIf(context); + } + + /** + * Tests are disabled if {@code @DisabledIf} is present on the test method + * and the configured expression evaluates to {@code true}. + */ + @Override + public ConditionEvaluationResult evaluate(TestExtensionContext context) { + return evaluateDisabledIf(context); + } + + private ConditionEvaluationResult evaluateDisabledIf(ExtensionContext extensionContext) { + AnnotatedElement element = extensionContext.getElement().get(); + Optional disabledIf = findMergedAnnotation(element, DisabledIf.class); + Assert.state(disabledIf.isPresent(), () -> "@DisabledIf must be present on " + element); + + String expression = disabledIf.get().expression().trim(); + + if (isDisabled(expression, extensionContext)) { + String reason = disabledIf.map(DisabledIf::reason).filter(StringUtils::hasText).orElseGet( + () -> String.format("%s is disabled because @DisabledIf(\"%s\") evaluated to true", element, + expression)); + logger.info(reason); + return ConditionEvaluationResult.disabled(reason); + } + else { + String reason = String.format("%s is enabled because @DisabledIf(\"%s\") did not evaluate to true", + element, expression); + logger.debug(reason); + return ConditionEvaluationResult.enabled(reason); + } + } + + private boolean isDisabled(String expression, ExtensionContext extensionContext) { + ApplicationContext applicationContext = SpringExtension.getApplicationContext(extensionContext); + + if (!(applicationContext instanceof ConfigurableApplicationContext)) { + if (logger.isWarnEnabled()) { + String contextType = (applicationContext != null ? applicationContext.getClass().getName() : "null"); + logger.warn(String.format("@DisabledIf(\"%s\") could not be evaluated on [%s] since the test " + + "ApplicationContext [%s] is not a ConfigurableApplicationContext", + expression, extensionContext.getElement(), contextType)); + } + return false; + } + + ConfigurableBeanFactory configurableBeanFactory = ((ConfigurableApplicationContext) applicationContext).getBeanFactory(); + BeanExpressionResolver expressionResolver = configurableBeanFactory.getBeanExpressionResolver(); + BeanExpressionContext beanExpressionContext = new BeanExpressionContext(configurableBeanFactory, null); + + Object result = expressionResolver.evaluate(configurableBeanFactory.resolveEmbeddedValue(expression), + beanExpressionContext); + + Assert.state((result instanceof Boolean || result instanceof String), () -> + String.format("@DisabledIf(\"%s\") must evaluate to a String or a Boolean, not %s", expression, + (result != null ? result.getClass().getName() : "null"))); + + boolean disabled = (result instanceof Boolean && ((Boolean) result).booleanValue()) || + (result instanceof String && Boolean.parseBoolean((String) result)); + + return disabled; + } + + private static Optional findMergedAnnotation(AnnotatedElement element, + Class annotationType) { + return Optional.ofNullable(AnnotatedElementUtils.findMergedAnnotation(element, annotationType)); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/SpringExtension.java b/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/SpringExtension.java index d3a52c90c39..93d81bb1e82 100644 --- a/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/SpringExtension.java +++ b/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/SpringExtension.java @@ -16,39 +16,28 @@ package org.springframework.test.context.junit.jupiter; -import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Constructor; import java.lang.reflect.Executable; import java.lang.reflect.Method; import java.lang.reflect.Parameter; -import java.util.Optional; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; import org.junit.jupiter.api.extension.AfterAllCallback; import org.junit.jupiter.api.extension.AfterEachCallback; import org.junit.jupiter.api.extension.AfterTestExecutionCallback; import org.junit.jupiter.api.extension.BeforeAllCallback; import org.junit.jupiter.api.extension.BeforeEachCallback; import org.junit.jupiter.api.extension.BeforeTestExecutionCallback; -import org.junit.jupiter.api.extension.ConditionEvaluationResult; -import org.junit.jupiter.api.extension.ContainerExecutionCondition; import org.junit.jupiter.api.extension.ContainerExtensionContext; 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.extension.ParameterContext; import org.junit.jupiter.api.extension.ParameterResolver; -import org.junit.jupiter.api.extension.TestExecutionCondition; import org.junit.jupiter.api.extension.TestExtensionContext; import org.junit.jupiter.api.extension.TestInstancePostProcessor; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.config.BeanExpressionContext; -import org.springframework.beans.factory.config.BeanExpressionResolver; -import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.context.ApplicationContext; -import org.springframework.context.ConfigurableApplicationContext; import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.test.context.TestContextManager; import org.springframework.util.Assert; @@ -61,17 +50,15 @@ import org.springframework.util.Assert; * {@code @ExtendWith(SpringExtension.class)}. * * @author Sam Brannen - * @author Tadaya Tsuyukubo * @since 5.0 + * @see org.springframework.test.context.junit.jupiter.DisabledIf * @see org.springframework.test.context.junit.jupiter.SpringJUnitConfig * @see org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig * @see org.springframework.test.context.TestContextManager - * @see DisabledIf */ public class SpringExtension implements BeforeAllCallback, AfterAllCallback, TestInstancePostProcessor, BeforeEachCallback, AfterEachCallback, BeforeTestExecutionCallback, AfterTestExecutionCallback, - ParameterResolver, - ContainerExecutionCondition, TestExecutionCondition { + ParameterResolver { /** * {@link Namespace} in which {@code TestContextManagers} are stored, keyed @@ -79,11 +66,6 @@ public class SpringExtension implements BeforeAllCallback, AfterAllCallback, Tes */ private static final Namespace namespace = Namespace.create(SpringExtension.class); - private static final ConditionEvaluationResult TEST_ENABLED = ConditionEvaluationResult.enabled( - "@DisabledIf condition didn't match"); - - private static final Log logger = LogFactory.getLog(SpringExtension.class); - /** * Delegates to {@link TestContextManager#beforeTestClass}. @@ -195,61 +177,6 @@ public class SpringExtension implements BeforeAllCallback, AfterAllCallback, Tes return ParameterAutowireUtils.resolveDependency(parameter, testClass, applicationContext); } - @Override - public ConditionEvaluationResult evaluate(ContainerExtensionContext context) { - return evaluateDisabledIf(context); - } - - @Override - public ConditionEvaluationResult evaluate(TestExtensionContext context) { - return evaluateDisabledIf(context); - } - - private ConditionEvaluationResult evaluateDisabledIf(ExtensionContext extensionContext) { - Optional element = extensionContext.getElement(); - if (!element.isPresent()) { - return TEST_ENABLED; - } - - DisabledIf disabledIf = AnnotatedElementUtils.findMergedAnnotation(element.get(), DisabledIf.class); - if (disabledIf == null) { - return TEST_ENABLED; - } - - String condition = disabledIf.condition(); - if (condition.trim().length() == 0) { - return TEST_ENABLED; - } - - ApplicationContext applicationContext = getApplicationContext(extensionContext); - if (!(applicationContext instanceof ConfigurableApplicationContext)) { - return TEST_ENABLED; - } - - ConfigurableBeanFactory configurableBeanFactory = ((ConfigurableApplicationContext) applicationContext) - .getBeanFactory(); - BeanExpressionResolver expressionResolver = configurableBeanFactory.getBeanExpressionResolver(); - BeanExpressionContext beanExpressionContext = new BeanExpressionContext(configurableBeanFactory, null); - - Object result = expressionResolver - .evaluate(configurableBeanFactory.resolveEmbeddedValue(condition), beanExpressionContext); - - if (result == null || !Boolean.valueOf(result.toString())) { - return TEST_ENABLED; - } - - String reason = disabledIf.reason(); - if (reason.trim().length() == 0) { - String testTarget = extensionContext.getTestMethod().map(Method::getName) - .orElseGet(() -> extensionContext.getTestClass().get().getSimpleName()); - reason = String.format("%s is disabled. condition=%s", testTarget, condition); - } - - logger.info(String.format("%s is disabled. reason=%s", element.get(), reason)); - return ConditionEvaluationResult.disabled(reason); - - } - /** * Get the {@link ApplicationContext} associated with the supplied * {@code ExtensionContext}. @@ -259,7 +186,7 @@ public class SpringExtension implements BeforeAllCallback, AfterAllCallback, Tes * application context * @see org.springframework.test.context.TestContext#getApplicationContext() */ - private ApplicationContext getApplicationContext(ExtensionContext context) { + static ApplicationContext getApplicationContext(ExtensionContext context) { return getTestContextManager(context).getTestContext().getApplicationContext(); } @@ -268,7 +195,7 @@ public class SpringExtension implements BeforeAllCallback, AfterAllCallback, Tes * {@code ExtensionContext}. * @return the {@code TestContextManager}; never {@code null} */ - private TestContextManager getTestContextManager(ExtensionContext context) { + private static TestContextManager getTestContextManager(ExtensionContext context) { Assert.notNull(context, "ExtensionContext must not be null"); Class testClass = context.getTestClass().get(); Store store = context.getStore(namespace); diff --git a/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/DisabledIfTestCase.java b/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/DisabledIfTestCase.java index 21c11178db1..152bcbd3001 100644 --- a/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/DisabledIfTestCase.java +++ b/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/DisabledIfTestCase.java @@ -18,27 +18,27 @@ package org.springframework.test.context.junit.jupiter; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; + import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.TestPropertySource; -import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.*; /** - * Integration tests which demonstrate usage of {@link DisabledIf @DisabledIf} - * enabled by {@link SpringExtension} in a JUnit 5 (Jupiter) environment. + * Integration tests which verify support for {@link DisabledIf @DisabledIf} + * in conjunction with the {@link SpringExtension} in a JUnit 5 (Jupiter) + * environment. * * @author Tadaya Tsuyukubo + * @author Sam Brannen * @since 5.0 * @see DisabledIf * @see SpringExtension */ class DisabledIfTestCase { - @ExtendWith(SpringExtension.class) - @ContextConfiguration(classes = Config.class) + @SpringJUnitConfig(Config.class) @TestPropertySource(properties = "foo = true") @Nested class DisabledIfOnMethodTestCase { @@ -73,6 +73,18 @@ class DisabledIfTestCase { fail("This test must be disabled"); } + @Test + @DisabledIf("#{6 * 7 == 42}") + void disabledBySpelMathematicalComparison() { + fail("This test must be disabled"); + } + + @Test + @DisabledOnMac + void disabledBySpelOsCheckInCustomComposedAnnotation() { + assertFalse(System.getProperty("os.name").contains("Mac"), "This test must be disabled on Mac OS"); + } + @Test @DisabledIf("#{@booleanTrueBean}") void disabledBySpelBooleanTrueBean() { @@ -87,8 +99,7 @@ class DisabledIfTestCase { } - @ExtendWith(SpringExtension.class) - @ContextConfiguration(classes = Config.class) + @SpringJUnitConfig(Config.class) @Nested @DisabledIf("true") class DisabledIfOnClassTestCase { @@ -98,7 +109,8 @@ class DisabledIfTestCase { fail("This test must be disabled"); } - // Even though method level condition is not disabling test, class level condition should take precedence + // Even though method level condition is not disabling test, class level condition + // should take precedence @Test @DisabledIf("false") void bar() { @@ -109,6 +121,7 @@ class DisabledIfTestCase { @Configuration static class Config { + @Bean Boolean booleanTrueBean() { return Boolean.TRUE; diff --git a/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/DisabledOnMac.java b/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/DisabledOnMac.java new file mode 100644 index 00000000000..6a62db34012 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/DisabledOnMac.java @@ -0,0 +1,38 @@ +/* + * Copyright 2002-2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.test.context.junit.jupiter; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Demo composed annotation for {@link DisabledIf @DisabledIf} that + * disables a test class or test method if the current operating system is + * Mac OS. + * + * @author Sam Brannen + * @since 5.0 + */ +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@DisabledIf(expression = "#{systemProperties['os.name'].toLowerCase().contains('mac')}", reason = "Disabled on Mac OS") +public @interface DisabledOnMac { +}