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 00000000000000..59bbf7dc939746 --- /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 00000000000000..222cd8d532623a --- /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 00000000000000..b4743de1a93d80 --- /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 00000000000000..7bc34b0d40a7fb --- /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 00000000000000..e1ce1932960279 --- /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 00000000000000..be30268dfdeab7 --- /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 00000000000000..3dd511935c1711 --- /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 9f8e4f7be2ecc8..e163d0f11be5a7 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 00000000000000..7e2b04e6ca9360 --- /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 f93ac8fe98cdae..e137b8418bba8a 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