Skip to content

Commit

Permalink
Introduce the ability to mock @singleton beans
Browse files Browse the repository at this point in the history
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
  • Loading branch information
geoand committed Apr 16, 2021
1 parent 1321e3f commit b12adf3
Show file tree
Hide file tree
Showing 11 changed files with 285 additions and 1 deletion.
@@ -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();
}
}
@@ -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());
}
}
@@ -0,0 +1,6 @@
package io.quarkus.it.mockbean;

public interface MessageServiceSingleton {

String getMessage();
}
@@ -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";
}
}
@@ -0,0 +1,8 @@
package io.quarkus.it.mockbean;

public class SuffixServiceSingleton {

String getSuffix() {
return "";
}
}
@@ -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();
}
}
@@ -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());
}
}
Expand Up @@ -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;
}
Expand Up @@ -12,6 +12,12 @@ 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(allowScopeConversion=true)' instead of '@InjectMock'");
}
}
}
@@ -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<BuildChainBuilder> produce(Index testClassesIndex) {
return new Consumer<BuildChainBuilder>() {

@Override
public void accept(BuildChainBuilder buildChainBuilder) {
buildChainBuilder.addBuildStep(new BuildStep() {
@Override
public void execute(BuildContext context) {
Set<DotName> mockTypes = new HashSet<>();
List<AnnotationInstance> 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<AnnotationInstance> {
@Override
public boolean test(AnnotationInstance annotationInstance) {
return annotationInstance.name().equals(DotNames.SINGLETON);
}
}
}
@@ -1 +1,2 @@
io.quarkus.test.junit.mockito.internal.UnremoveableMockTestBuildChainCustomizerProducer
io.quarkus.test.junit.mockito.internal.SingletonToApplicationScopedTestBuildChainCustomizerProducer

0 comments on commit b12adf3

Please sign in to comment.