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 19, 2021
1 parent 086c700 commit a337455
Show file tree
Hide file tree
Showing 11 changed files with 289 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package io.quarkus.it.mockbean;

public interface MessageServiceSingleton {

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

public class SuffixServiceSingleton {

String getSuffix() {
return "";
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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).");
}
}
}
Original file line number Diff line number Diff line change
@@ -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("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<AnnotationInstance> {
@Override
public boolean test(AnnotationInstance annotationInstance) {
return annotationInstance.name().equals(DotNames.SINGLETON);
}
}
}
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
io.quarkus.test.junit.mockito.internal.UnremoveableMockTestBuildChainCustomizerProducer
io.quarkus.test.junit.mockito.internal.SingletonToApplicationScopedTestBuildChainCustomizerProducer

0 comments on commit a337455

Please sign in to comment.