From a337455d96d12eeb0f0403796ca17c613d3d3773 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 | 9 ++ .../SetMockitoMockAsBeanMockCallback.java | 9 +- ...copedTestBuildChainCustomizerProducer.java | 121 ++++++++++++++++++ ...uildchain.TestBuildChainCustomizerProducer | 1 + 11 files changed, 289 insertions(+), 1 deletion(-) 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..4597a40fcf14f --- /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(convertScopes = true) + MessageServiceSingleton messageService; + + @InjectMock(convertScopes = true) + SuffixServiceSingleton suffixService; + + @InjectMock(convertScopes = 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..57729d7171dd2 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,13 @@ @Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) public @interface InjectMock { + + /** + * If true, then Quarkus will change the scope of the target {@code Singleton} bean to {@code ApplicationScoped} + * to make the mockable. + * This is an advanced setting and should only be used if you don't rely on the differences between {@code Singleton} + * and {@code ApplicationScoped} beans (for example it is invalid to read fields of {@code ApplicationScoped} beans + * as a proxy stands in place of the actual implementation) + */ + boolean convertScopes() default false; } diff --git a/test-framework/junit5-mockito/src/main/java/io/quarkus/test/junit/mockito/internal/SetMockitoMockAsBeanMockCallback.java b/test-framework/junit5-mockito/src/main/java/io/quarkus/test/junit/mockito/internal/SetMockitoMockAsBeanMockCallback.java index 174118b56743c..354a0d28378cc 100644 --- a/test-framework/junit5-mockito/src/main/java/io/quarkus/test/junit/mockito/internal/SetMockitoMockAsBeanMockCallback.java +++ b/test-framework/junit5-mockito/src/main/java/io/quarkus/test/junit/mockito/internal/SetMockitoMockAsBeanMockCallback.java @@ -12,6 +12,13 @@ public void beforeEach(QuarkusTestMethodContext context) { } private void installMock(MockitoMocksTracker.Mocked mocked) { - QuarkusMock.installMockForInstance(mocked.mock, mocked.beanInstance); + try { + QuarkusMock.installMockForInstance(mocked.mock, mocked.beanInstance); + } catch (Exception e) { + throw new RuntimeException(mocked.beanInstance + + " is not a normal scoped CDI bean, make sure the bean is a normal scope like @ApplicationScoped or @RequestScoped." + + " Alternatively you can use '@InjectMock(convertScopes=true)' instead of '@InjectMock' if you would like" + + " Quarkus to automatically make that conversion (you should only use this if you understand the implications)."); + } } } 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..69f3043c92994 --- /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("convertScopes"); + 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