From e46b0b713798798155c5bb5c8af26b5f9b793157 Mon Sep 17 00:00:00 2001 From: Georgios Andrianakis Date: Fri, 16 Apr 2021 09:45:39 +0300 Subject: [PATCH] Introduce the ability to mock @Singleton beans This is done by giving users the ability to configure whether or not Quarkus can change the scope of the bean, so Quarkus can then change the scope from @Singleton to @ApplicationScoped --- .../mockbean/CapitalizerServiceSingleton.java | 11 ++ .../mockbean/GreetingResourceSingleton.java | 26 ++++ .../it/mockbean/MessageServiceSingleton.java | 6 + .../mockbean/MessageServiceSingletonImpl.java | 12 ++ .../it/mockbean/SuffixServiceSingleton.java | 8 ++ .../SuffixServiceSingletonProducer.java | 13 ++ .../GreetingSingletonResourceTest.java | 74 +++++++++++ .../test/junit/mockito/InjectMock.java | 6 + ...copedTestBuildChainCustomizerProducer.java | 121 ++++++++++++++++++ ...uildchain.TestBuildChainCustomizerProducer | 1 + 10 files changed, 278 insertions(+) create mode 100644 integration-tests/injectmock/src/main/java/io/quarkus/it/mockbean/CapitalizerServiceSingleton.java create mode 100644 integration-tests/injectmock/src/main/java/io/quarkus/it/mockbean/GreetingResourceSingleton.java create mode 100644 integration-tests/injectmock/src/main/java/io/quarkus/it/mockbean/MessageServiceSingleton.java create mode 100644 integration-tests/injectmock/src/main/java/io/quarkus/it/mockbean/MessageServiceSingletonImpl.java create mode 100644 integration-tests/injectmock/src/main/java/io/quarkus/it/mockbean/SuffixServiceSingleton.java create mode 100644 integration-tests/injectmock/src/main/java/io/quarkus/it/mockbean/SuffixServiceSingletonProducer.java create mode 100644 integration-tests/injectmock/src/test/java/io/quarkus/it/mockbean/GreetingSingletonResourceTest.java create mode 100644 test-framework/junit5-mockito/src/main/java/io/quarkus/test/junit/mockito/internal/SingletonToApplicationScopedTestBuildChainCustomizerProducer.java diff --git a/integration-tests/injectmock/src/main/java/io/quarkus/it/mockbean/CapitalizerServiceSingleton.java b/integration-tests/injectmock/src/main/java/io/quarkus/it/mockbean/CapitalizerServiceSingleton.java new file mode 100644 index 0000000000000..59bbf7dc93974 --- /dev/null +++ b/integration-tests/injectmock/src/main/java/io/quarkus/it/mockbean/CapitalizerServiceSingleton.java @@ -0,0 +1,11 @@ +package io.quarkus.it.mockbean; + +import javax.inject.Singleton; + +@Singleton +public class CapitalizerServiceSingleton { + + public String capitalize(String input) { + return input.toUpperCase(); + } +} diff --git a/integration-tests/injectmock/src/main/java/io/quarkus/it/mockbean/GreetingResourceSingleton.java b/integration-tests/injectmock/src/main/java/io/quarkus/it/mockbean/GreetingResourceSingleton.java new file mode 100644 index 0000000000000..222cd8d532623 --- /dev/null +++ b/integration-tests/injectmock/src/main/java/io/quarkus/it/mockbean/GreetingResourceSingleton.java @@ -0,0 +1,26 @@ +package io.quarkus.it.mockbean; + +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; + +@Path("greetingSingleton") +public class GreetingResourceSingleton { + + final MessageServiceSingleton messageService; + final SuffixServiceSingleton suffixService; + final CapitalizerServiceSingleton capitalizerService; + + public GreetingResourceSingleton(MessageServiceSingleton messageService, SuffixServiceSingleton suffixService, + CapitalizerServiceSingleton capitalizerService) { + this.messageService = messageService; + this.suffixService = suffixService; + this.capitalizerService = capitalizerService; + } + + @GET + @Produces("text/plain") + public String greet() { + return capitalizerService.capitalize(messageService.getMessage() + suffixService.getSuffix()); + } +} diff --git a/integration-tests/injectmock/src/main/java/io/quarkus/it/mockbean/MessageServiceSingleton.java b/integration-tests/injectmock/src/main/java/io/quarkus/it/mockbean/MessageServiceSingleton.java new file mode 100644 index 0000000000000..b4743de1a93d8 --- /dev/null +++ b/integration-tests/injectmock/src/main/java/io/quarkus/it/mockbean/MessageServiceSingleton.java @@ -0,0 +1,6 @@ +package io.quarkus.it.mockbean; + +public interface MessageServiceSingleton { + + String getMessage(); +} diff --git a/integration-tests/injectmock/src/main/java/io/quarkus/it/mockbean/MessageServiceSingletonImpl.java b/integration-tests/injectmock/src/main/java/io/quarkus/it/mockbean/MessageServiceSingletonImpl.java new file mode 100644 index 0000000000000..7bc34b0d40a7f --- /dev/null +++ b/integration-tests/injectmock/src/main/java/io/quarkus/it/mockbean/MessageServiceSingletonImpl.java @@ -0,0 +1,12 @@ +package io.quarkus.it.mockbean; + +import javax.inject.Singleton; + +@Singleton +public class MessageServiceSingletonImpl implements MessageServiceSingleton { + + @Override + public String getMessage() { + return "hello"; + } +} diff --git a/integration-tests/injectmock/src/main/java/io/quarkus/it/mockbean/SuffixServiceSingleton.java b/integration-tests/injectmock/src/main/java/io/quarkus/it/mockbean/SuffixServiceSingleton.java new file mode 100644 index 0000000000000..e1ce193296027 --- /dev/null +++ b/integration-tests/injectmock/src/main/java/io/quarkus/it/mockbean/SuffixServiceSingleton.java @@ -0,0 +1,8 @@ +package io.quarkus.it.mockbean; + +public class SuffixServiceSingleton { + + String getSuffix() { + return ""; + } +} diff --git a/integration-tests/injectmock/src/main/java/io/quarkus/it/mockbean/SuffixServiceSingletonProducer.java b/integration-tests/injectmock/src/main/java/io/quarkus/it/mockbean/SuffixServiceSingletonProducer.java new file mode 100644 index 0000000000000..be30268dfdeab --- /dev/null +++ b/integration-tests/injectmock/src/main/java/io/quarkus/it/mockbean/SuffixServiceSingletonProducer.java @@ -0,0 +1,13 @@ +package io.quarkus.it.mockbean; + +import javax.enterprise.inject.Produces; +import javax.inject.Singleton; + +public class SuffixServiceSingletonProducer { + + @Produces + @Singleton + public SuffixServiceSingleton dummyService() { + return new SuffixServiceSingleton(); + } +} diff --git a/integration-tests/injectmock/src/test/java/io/quarkus/it/mockbean/GreetingSingletonResourceTest.java b/integration-tests/injectmock/src/test/java/io/quarkus/it/mockbean/GreetingSingletonResourceTest.java new file mode 100644 index 0000000000000..3dd511935c171 --- /dev/null +++ b/integration-tests/injectmock/src/test/java/io/quarkus/it/mockbean/GreetingSingletonResourceTest.java @@ -0,0 +1,74 @@ +package io.quarkus.it.mockbean; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.anyString; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.mockito.InjectMock; + +@QuarkusTest +class GreetingSingletonResourceTest { + + @InjectMock(allowScopeConversion = true) + MessageServiceSingleton messageService; + + @InjectMock(allowScopeConversion = true) + SuffixServiceSingleton suffixService; + + @InjectMock(allowScopeConversion = true) + CapitalizerServiceSingleton capitalizerService; + + @Test + public void testGreet() { + Mockito.when(messageService.getMessage()).thenReturn("hi"); + Mockito.when(suffixService.getSuffix()).thenReturn("!"); + mockCapitalizerService(); + + given() + .when().get("/greetingSingleton") + .then() + .statusCode(200) + .body(is("hi!")); + } + + @Test + public void testGreetAgain() { + Mockito.when(messageService.getMessage()).thenReturn("yolo"); + Mockito.when(suffixService.getSuffix()).thenReturn("!!!"); + mockCapitalizerService(); + + given() + .when().get("/greetingSingleton") + .then() + .statusCode(200) + .body(is("yolo!!!")); + } + + @Test + public void testMocksNotSet() { + // when mocks are not configured, they return the Mockito default response + Assertions.assertNull(messageService.getMessage()); + Assertions.assertNull(suffixService.getSuffix()); + + given() + .when().get("/greetingSingleton") + .then() + .statusCode(204); + } + + private void mockCapitalizerService() { + Mockito.doAnswer(new Answer() { // don't upper case the string, leave it as it is + @Override + public Object answer(InvocationOnMock invocationOnMock) throws Throwable { + return invocationOnMock.getArgument(0); + } + }).when(capitalizerService).capitalize(anyString()); + } +} diff --git a/test-framework/junit5-mockito/src/main/java/io/quarkus/test/junit/mockito/InjectMock.java b/test-framework/junit5-mockito/src/main/java/io/quarkus/test/junit/mockito/InjectMock.java index 9f8e4f7be2ecc..e163d0f11be5a 100644 --- a/test-framework/junit5-mockito/src/main/java/io/quarkus/test/junit/mockito/InjectMock.java +++ b/test-framework/junit5-mockito/src/main/java/io/quarkus/test/junit/mockito/InjectMock.java @@ -12,4 +12,10 @@ @Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) public @interface InjectMock { + + /** + * If true, then Quarkus will change the scope of {@code Singleton} beans to {@code ApplicationScoped} + * to make the mockable + */ + boolean allowScopeConversion() default false; } diff --git a/test-framework/junit5-mockito/src/main/java/io/quarkus/test/junit/mockito/internal/SingletonToApplicationScopedTestBuildChainCustomizerProducer.java b/test-framework/junit5-mockito/src/main/java/io/quarkus/test/junit/mockito/internal/SingletonToApplicationScopedTestBuildChainCustomizerProducer.java new file mode 100644 index 0000000000000..7e2b04e6ca936 --- /dev/null +++ b/test-framework/junit5-mockito/src/main/java/io/quarkus/test/junit/mockito/internal/SingletonToApplicationScopedTestBuildChainCustomizerProducer.java @@ -0,0 +1,121 @@ +package io.quarkus.test.junit.mockito.internal; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Predicate; + +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.AnnotationTarget; +import org.jboss.jandex.AnnotationValue; +import org.jboss.jandex.ClassInfo; +import org.jboss.jandex.DotName; +import org.jboss.jandex.Index; +import org.jboss.jandex.MethodInfo; + +import io.quarkus.arc.deployment.AnnotationsTransformerBuildItem; +import io.quarkus.arc.processor.AnnotationsTransformer; +import io.quarkus.arc.processor.DotNames; +import io.quarkus.builder.BuildChainBuilder; +import io.quarkus.builder.BuildContext; +import io.quarkus.builder.BuildStep; +import io.quarkus.test.junit.buildchain.TestBuildChainCustomizerProducer; +import io.quarkus.test.junit.mockito.InjectMock; + +public class SingletonToApplicationScopedTestBuildChainCustomizerProducer implements TestBuildChainCustomizerProducer { + + private static final DotName INJECT_MOCK = DotName.createSimple(InjectMock.class.getName()); + + @Override + public Consumer produce(Index testClassesIndex) { + return new Consumer() { + + @Override + public void accept(BuildChainBuilder buildChainBuilder) { + buildChainBuilder.addBuildStep(new BuildStep() { + @Override + public void execute(BuildContext context) { + Set mockTypes = new HashSet<>(); + List instances = testClassesIndex.getAnnotations(INJECT_MOCK); + for (AnnotationInstance instance : instances) { + if (instance.target().kind() != AnnotationTarget.Kind.FIELD) { + continue; + } + AnnotationValue allowScopeConversionValue = instance.value("allowScopeConversion"); + if ((allowScopeConversionValue != null) && allowScopeConversionValue.asBoolean()) { + // we need to fetch the type of the bean, so we need to look at the type of the field + mockTypes.add(instance.target().asField().type().name()); + } + } + if (mockTypes.isEmpty()) { + return; + } + + // TODO: this annotation transformer is too simplistic and should be replaced + // by whatever build item comes out of the implementation + // of https://github.com/quarkusio/quarkus/issues/16572 + context.produce(new AnnotationsTransformerBuildItem(new AnnotationsTransformer() { + @Override + public boolean appliesTo(AnnotationTarget.Kind kind) { + return (kind == AnnotationTarget.Kind.CLASS) || (kind == AnnotationTarget.Kind.METHOD); + } + + @Override + public void transform(TransformationContext transformationContext) { + AnnotationTarget target = transformationContext.getTarget(); + if (target.kind() == AnnotationTarget.Kind.CLASS) { // scope on bean case + ClassInfo classInfo = target.asClass(); + if (isMatchingBean(classInfo)) { + if (classInfo.classAnnotation(DotNames.SINGLETON) != null) { + replaceSingletonWithApplicationScoped(transformationContext); + } + } + } else if (target.kind() == AnnotationTarget.Kind.METHOD) { // CDI producer case + MethodInfo methodInfo = target.asMethod(); + if ((methodInfo.annotation(DotNames.PRODUCES) != null) + && (methodInfo.annotation(DotNames.SINGLETON) != null)) { + DotName returnType = methodInfo.returnType().name(); + if (mockTypes.contains(returnType)) { + replaceSingletonWithApplicationScoped(transformationContext); + } + } + } + } + + private void replaceSingletonWithApplicationScoped(TransformationContext transformationContext) { + transformationContext.transform().remove(new IsSingletonPredicate()) + .add(DotNames.APPLICATION_SCOPED).done(); + } + + // this is very simplistic and is the main reason why the annotation transformer strategy + // is fine with most cases, but it can't cover all cases + private boolean isMatchingBean(ClassInfo classInfo) { + // class type matches + if (mockTypes.contains(classInfo.name())) { + return true; + } + if (mockTypes.contains(classInfo.superName())) { + return true; + } + for (DotName iface : classInfo.interfaceNames()) { + if (mockTypes.contains(iface)) { + return true; + } + } + return false; + } + })); + } + }).produces(AnnotationsTransformerBuildItem.class).build(); + } + }; + } + + private static class IsSingletonPredicate implements Predicate { + @Override + public boolean test(AnnotationInstance annotationInstance) { + return annotationInstance.name().equals(DotNames.SINGLETON); + } + } +} diff --git a/test-framework/junit5-mockito/src/main/resources/META-INF/services/io.quarkus.test.junit.buildchain.TestBuildChainCustomizerProducer b/test-framework/junit5-mockito/src/main/resources/META-INF/services/io.quarkus.test.junit.buildchain.TestBuildChainCustomizerProducer index f93ac8fe98cda..e137b8418bba8 100644 --- a/test-framework/junit5-mockito/src/main/resources/META-INF/services/io.quarkus.test.junit.buildchain.TestBuildChainCustomizerProducer +++ b/test-framework/junit5-mockito/src/main/resources/META-INF/services/io.quarkus.test.junit.buildchain.TestBuildChainCustomizerProducer @@ -1 +1,2 @@ io.quarkus.test.junit.mockito.internal.UnremoveableMockTestBuildChainCustomizerProducer +io.quarkus.test.junit.mockito.internal.SingletonToApplicationScopedTestBuildChainCustomizerProducer