From f0eabb050b1325624bac9858cac3f147ade989cc Mon Sep 17 00:00:00 2001 From: Stuart Douglas Date: Thu, 11 Jun 2020 13:08:25 +1000 Subject: [PATCH] Add ability to run tests with different config profiles --- .../asciidoc/getting-started-testing.adoc | 63 ++++++++++++++++-- integration-tests/main/pom.xml | 7 ++ .../io/quarkus/it/rest/GreetingEndpoint.java | 23 +++++++ .../io/quarkus/it/rest/GreetingService.java | 10 +++ .../io/quarkus/it/main/BonjourService.java | 16 +++++ .../it/main/GreetingNormalTestCase.java | 21 ++++++ .../it/main/GreetingProfileTestCase.java | 50 +++++++++++++++ .../test/junit/QuarkusTestExtension.java | 64 ++++++++++++++++++- .../test/junit/QuarkusTestProfile.java | 42 ++++++++++++ .../io/quarkus/test/junit/TestProfile.java | 31 +++++++++ 10 files changed, 320 insertions(+), 7 deletions(-) create mode 100644 integration-tests/main/src/main/java/io/quarkus/it/rest/GreetingEndpoint.java create mode 100644 integration-tests/main/src/main/java/io/quarkus/it/rest/GreetingService.java create mode 100644 integration-tests/main/src/test/java/io/quarkus/it/main/BonjourService.java create mode 100644 integration-tests/main/src/test/java/io/quarkus/it/main/GreetingNormalTestCase.java create mode 100644 integration-tests/main/src/test/java/io/quarkus/it/main/GreetingProfileTestCase.java create mode 100644 test-framework/junit5/src/main/java/io/quarkus/test/junit/QuarkusTestProfile.java create mode 100644 test-framework/junit5/src/main/java/io/quarkus/test/junit/TestProfile.java diff --git a/docs/src/main/asciidoc/getting-started-testing.adoc b/docs/src/main/asciidoc/getting-started-testing.adoc index f8dc72fa240b0e..384d91a72229db 100644 --- a/docs/src/main/asciidoc/getting-started-testing.adoc +++ b/docs/src/main/asciidoc/getting-started-testing.adoc @@ -303,6 +303,61 @@ public class TestStereotypeTestCase { } ---- +== Testing Different Profiles + +So far in all our examples we only start Quarkus once for all tests. Before the first test is run Quarkus will boot, +then all tests will run, then Quarkus will shutdown at the end. This makes for a very fast testing experience however +it is a bit limited as you can't test different configurations. + +To get around this Quarkus supports the idea of a test profile. If a test has a different profile to the previously +run test then Quarkus will be shut down and started with the new profile before running the tests. This is obviously +a bit slower, as it adds a shutdown/startup cycle to the test time, but gives a great deal of flexibility. + +NOTE: In order to reduce the amount of times Quarkus needs to restart it is recommended that you place all tests +that need a specific profile into their own package, and then run tests alphabetically. + +=== Writing a Profile + +To implement a test profile we need to implement `io.quarkus.test.junit.QuarkusTestProfile`: + +[source,java] +---- +package org.acme.getting.started.testing; + +import java.util.Collections; +import java.util.Map; +import java.util.Set; + +import io.quarkus.test.junit.QuarkusTestProfile; + +public class MockGreetingProfile implements QuarkusTestProfile { + + @Override + public Map getConfigOverrides() { <1> + return Collections.singletonMap("quarkus.resteasy.path","/api"); + } + + @Override + public Set> getEnabledAlternatives() { <2> + return Collections.singleton(MockGreetingService.class); + } + + + @Override + public String getConfigProfile() { <3> + return "test"; + } +} +---- +<1> This method allows us to override configuration properties. Here we are changing the JAX-RS root path. +<2> This method allows us to enable CDI `@Alternative` beans. This makes it easy to mock out certain beans functionality. +<3> This can be used to change the config profile. As this default is `test` this does nothing, but is included for completeness. + +Now we have defined our profile we need to include it on our test class. We do this with `@TestProfile(MockGreetingProfile.class)`. + +All the test profile config is stored in a single class, which makes it easy to tell if the previous test ran with the +same configuration. + == Mock Support @@ -311,9 +366,9 @@ mock out a bean for all test classes, or use `QuarkusMock` to mock out beans on === CDI `@Alternative` mechanism. -To use this simply override the bean you wish to mock with a class in the `src/test/java` directory, and put the `@Alternative` and `@Priority(1)` annotations on the bean. +To use this simply override the bean you wish to mock with a class in the `src/test/java` directory, and put the `@Alternative` and `@Priority(1)` annotations on the bean. Alternatively, a convenient `io.quarkus.test.Mock` stereotype annotation could be used. -This built-in stereotype declares `@Alternative`, `@Priority(1)` and `@Dependent`. +This built-in stereotype declares `@Alternative`, `@Priority(1)` and `@Dependent`. For example if I have the following service: [source,java] @@ -596,7 +651,7 @@ public class SpyGreetingServiceTest { ==== Using `@InjectMock` with `@RestClient` -The `@RegisterRestClient` registers the implementation of the rest-client at runtime, and because the bean needs to be a regular scope, you have to annotate your interface with `@ApplicationScoped`. +The `@RegisterRestClient` registers the implementation of the rest-client at runtime, and because the bean needs to be a regular scope, you have to annotate your interface with `@ApplicationScoped`. [source,java] ---- @@ -612,7 +667,7 @@ public interface GreetingService { } ---- -For the test class here is an example: +For the test class here is an example: [source,java] ---- diff --git a/integration-tests/main/pom.xml b/integration-tests/main/pom.xml index c3027219a681d7..e70412a10fd5f0 100644 --- a/integration-tests/main/pom.xml +++ b/integration-tests/main/pom.xml @@ -199,6 +199,13 @@ + + org.apache.maven.plugins + maven-surefire-plugin + + alphabetical + + diff --git a/integration-tests/main/src/main/java/io/quarkus/it/rest/GreetingEndpoint.java b/integration-tests/main/src/main/java/io/quarkus/it/rest/GreetingEndpoint.java new file mode 100644 index 00000000000000..c083c65ac55db1 --- /dev/null +++ b/integration-tests/main/src/main/java/io/quarkus/it/rest/GreetingEndpoint.java @@ -0,0 +1,23 @@ +package io.quarkus.it.rest; + +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; + +import org.jboss.resteasy.annotations.jaxrs.PathParam; + +@Path("/greeting") +public class GreetingEndpoint { + + @Inject + GreetingService greetingService; + + @GET + @Produces(MediaType.TEXT_PLAIN) + @Path("{name}") + public String greet(@PathParam String name) { + return greetingService.greet(name); + } +} diff --git a/integration-tests/main/src/main/java/io/quarkus/it/rest/GreetingService.java b/integration-tests/main/src/main/java/io/quarkus/it/rest/GreetingService.java new file mode 100644 index 00000000000000..0551a7d62a2e4c --- /dev/null +++ b/integration-tests/main/src/main/java/io/quarkus/it/rest/GreetingService.java @@ -0,0 +1,10 @@ +package io.quarkus.it.rest; + +import javax.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class GreetingService { + public String greet(String greeting) { + return "Hello " + greeting; + } +} diff --git a/integration-tests/main/src/test/java/io/quarkus/it/main/BonjourService.java b/integration-tests/main/src/test/java/io/quarkus/it/main/BonjourService.java new file mode 100644 index 00000000000000..1a7b9bfbcb80c0 --- /dev/null +++ b/integration-tests/main/src/test/java/io/quarkus/it/main/BonjourService.java @@ -0,0 +1,16 @@ +package io.quarkus.it.main; + +import javax.enterprise.context.ApplicationScoped; +import javax.enterprise.inject.Alternative; + +import io.quarkus.it.rest.GreetingService; + +@ApplicationScoped +@Alternative +public class BonjourService extends GreetingService { + + @Override + public String greet(String greeting) { + return "Bonjour " + greeting; + } +} diff --git a/integration-tests/main/src/test/java/io/quarkus/it/main/GreetingNormalTestCase.java b/integration-tests/main/src/test/java/io/quarkus/it/main/GreetingNormalTestCase.java new file mode 100644 index 00000000000000..71caa9a3cac60f --- /dev/null +++ b/integration-tests/main/src/test/java/io/quarkus/it/main/GreetingNormalTestCase.java @@ -0,0 +1,21 @@ +package io.quarkus.it.main; + +import static org.hamcrest.Matchers.is; + +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; + +@QuarkusTest +public class GreetingNormalTestCase { + + @Test + public void included() { + RestAssured.when() + .get("/greeting/Stu") + .then() + .statusCode(200) + .body(is("Hello Stu")); + } +} diff --git a/integration-tests/main/src/test/java/io/quarkus/it/main/GreetingProfileTestCase.java b/integration-tests/main/src/test/java/io/quarkus/it/main/GreetingProfileTestCase.java new file mode 100644 index 00000000000000..ed4115bd2740d3 --- /dev/null +++ b/integration-tests/main/src/test/java/io/quarkus/it/main/GreetingProfileTestCase.java @@ -0,0 +1,50 @@ +package io.quarkus.it.main; + +import static org.hamcrest.Matchers.is; + +import java.util.Collections; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.TestProfile; +import io.restassured.RestAssured; + +/** + * Tests that QuarkusTestProfile works as expected + */ +@QuarkusTest +@TestProfile(GreetingProfileTestCase.MyProfile.class) +public class GreetingProfileTestCase { + + @Test + public void included() { + RestAssured.when() + .get("/greeting/Stu") + .then() + .statusCode(200) + .body(is("Bonjour Stu")); + } + + @Test + public void testPortTakesEffect() { + Assertions.assertEquals(7777, RestAssured.port); + } + + public static class MyProfile implements QuarkusTestProfile { + + @Override + public Map getConfigOverrides() { + return Collections.singletonMap("quarkus.http.test-port", "7777"); + } + + @Override + public Set> getEnabledAlternatives() { + return Collections.singleton(BonjourService.class); + } + } +} diff --git a/test-framework/junit5/src/main/java/io/quarkus/test/junit/QuarkusTestExtension.java b/test-framework/junit5/src/main/java/io/quarkus/test/junit/QuarkusTestExtension.java index fc10cacaebe9d9..2d22e5da6df437 100644 --- a/test-framework/junit5/src/main/java/io/quarkus/test/junit/QuarkusTestExtension.java +++ b/test-framework/junit5/src/main/java/io/quarkus/test/junit/QuarkusTestExtension.java @@ -15,11 +15,15 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.ServiceLoader; import java.util.concurrent.LinkedBlockingDeque; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Predicate; +import java.util.stream.Collectors; + +import javax.enterprise.inject.Alternative; import org.eclipse.microprofile.config.spi.ConfigProviderResolver; import org.jboss.jandex.AnnotationInstance; @@ -54,6 +58,7 @@ import io.quarkus.deployment.builditem.TestAnnotationBuildItem; import io.quarkus.deployment.builditem.TestClassBeanBuildItem; import io.quarkus.deployment.builditem.TestClassPredicateBuildItem; +import io.quarkus.runtime.configuration.ProfileManager; import io.quarkus.test.common.PathTestHelper; import io.quarkus.test.common.PropertyTestUtil; import io.quarkus.test.common.RestAssuredURLManager; @@ -90,10 +95,12 @@ public class QuarkusTestExtension private static List beforeEachCallbacks = new ArrayList<>(); private static List afterEachCallbacks = new ArrayList<>(); private static Class quarkusTestMethodContextClass; + private static Class quarkusTestProfile; private static DeepClone deepClone; - private ExtensionState doJavaStart(ExtensionContext context) throws Throwable { + private ExtensionState doJavaStart(ExtensionContext context, Class profile) throws Throwable { + quarkusTestProfile = profile; Closeable testResourceManager = null; try { final LinkedBlockingDeque shutdownTasks = new LinkedBlockingDeque<>(); @@ -114,10 +121,38 @@ private ExtensionState doJavaStart(ExtensionContext context) throws Throwable { } } originalCl = Thread.currentThread().getContextClassLoader(); + Map sysPropRestore = new HashMap<>(); + sysPropRestore.put(ProfileManager.QUARKUS_TEST_PROFILE_PROP, + System.getProperty(ProfileManager.QUARKUS_TEST_PROFILE_PROP)); final QuarkusBootstrap.Builder runnerBuilder = QuarkusBootstrap.builder() .setIsolateDeployment(true) .setMode(QuarkusBootstrap.Mode.TEST); + if (profile != null) { + QuarkusTestProfile profileInstance = profile.newInstance(); + Map additional = new HashMap<>(profileInstance.getConfigOverrides()); + if (!profileInstance.getEnabledAlternatives().isEmpty()) { + additional.put("quarkus.arc.selected-alternatives", profileInstance.getEnabledAlternatives().stream() + .peek((c) -> { + if (!c.isAnnotationPresent(Alternative.class)) { + throw new RuntimeException( + "Enabled alternative " + c + " is not annotated with @Alternative"); + } + }) + .map(Class::getName).collect(Collectors.joining(","))); + } + if (profileInstance.getConfigProfile() != null) { + System.setProperty(ProfileManager.QUARKUS_TEST_PROFILE_PROP, profileInstance.getConfigProfile()); + } + //we just use system properties for now + //its a lot simpler + for (Map.Entry i : additional.entrySet()) { + sysPropRestore.put(i.getKey(), System.getProperty(i.getKey())); + } + for (Map.Entry i : additional.entrySet()) { + System.setProperty(i.getKey(), i.getValue()); + } + } runnerBuilder.setProjectRoot(Paths.get("").normalize().toAbsolutePath()); @@ -176,6 +211,14 @@ public void close() throws IOException { shutdownTasks.pop().run(); } } finally { + for (Map.Entry entry : sysPropRestore.entrySet()) { + String val = entry.getValue(); + if (val == null) { + System.clearProperty(entry.getKey()); + } else { + System.setProperty(entry.getKey(), val); + } + } tm.close(); } } @@ -305,10 +348,25 @@ private ExtensionState ensureStarted(ExtensionContext extensionContext) { ExtensionContext root = extensionContext.getRoot(); ExtensionContext.Store store = root.getStore(ExtensionContext.Namespace.GLOBAL); ExtensionState state = store.get(ExtensionState.class.getName(), ExtensionState.class); - if (state == null && !failedBoot) { + TestProfile annotation = extensionContext.getRequiredTestClass().getAnnotation(TestProfile.class); + Class selectedProfile = null; + if (annotation != null) { + selectedProfile = annotation.value(); + } + boolean wrongProfile = !Objects.equals(selectedProfile, quarkusTestProfile); + if ((state == null && !failedBoot) || wrongProfile) { + if (wrongProfile) { + if (state != null) { + try { + state.close(); + } catch (Throwable throwable) { + throwable.printStackTrace(); + } + } + } PropertyTestUtil.setLogFileProperty(); try { - state = doJavaStart(extensionContext); + state = doJavaStart(extensionContext, selectedProfile); store.put(ExtensionState.class.getName(), state); } catch (Throwable e) { diff --git a/test-framework/junit5/src/main/java/io/quarkus/test/junit/QuarkusTestProfile.java b/test-framework/junit5/src/main/java/io/quarkus/test/junit/QuarkusTestProfile.java new file mode 100644 index 00000000000000..f370cc0f929f67 --- /dev/null +++ b/test-framework/junit5/src/main/java/io/quarkus/test/junit/QuarkusTestProfile.java @@ -0,0 +1,42 @@ +package io.quarkus.test.junit; + +import java.util.Collections; +import java.util.Map; +import java.util.Set; + +/** + * Defines a 'test profile'. Tests run under a test profile + * will have different configuration options to other tests. + * + */ +public interface QuarkusTestProfile { + + /** + * Returns additional config to be applied to the test. This + * will override any existing config (including in application.properties), + * however existing config will be merged with this (i.e. application.properties + * config will still take effect, unless a specific config key has been overridden). + */ + default Map getConfigOverrides() { + return Collections.emptyMap(); + } + + /** + * Returns enabled alternatives. + * + * This has the same effect as setting the 'quarkus.arc.selected-alternatives' config key, + * however it may be more convenient. + */ + default Set> getEnabledAlternatives() { + return Collections.emptySet(); + } + + /** + * Allows the default config profile to be overridden. This basically just sets the quarkus.test.profile system + * property before the test is run. + * + */ + default String getConfigProfile() { + return null; + } +} diff --git a/test-framework/junit5/src/main/java/io/quarkus/test/junit/TestProfile.java b/test-framework/junit5/src/main/java/io/quarkus/test/junit/TestProfile.java new file mode 100644 index 00000000000000..5279e1a7fc0936 --- /dev/null +++ b/test-framework/junit5/src/main/java/io/quarkus/test/junit/TestProfile.java @@ -0,0 +1,31 @@ +package io.quarkus.test.junit; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Defines a 'test profile'. Tests run under a test profile + * will have different configuration options to other tests. + * + * Due to the global nature of Quarkus if a previous test was + * run under a different profile then Quarkus will need to be + * restarted when the profile changes. Unfortunately there + * is currently no way to order tests based on profile, however + * this can be done manually by running tests in alphabetical + * order and putting all tests with the same profile in the same + * package. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface TestProfile { + + /** + * The test profile to use. If subsequent tests use the same + * profile then Quarkus will not be restarted between tests, + * giving a faster execution. + */ + Class value(); + +}