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