Skip to content

Commit

Permalink
Merge pull request #16574 from geoand/allow-mocking-singletons
Browse files Browse the repository at this point in the history
Introduce the ability to mock @singleton beans
  • Loading branch information
geoand authored Apr 20, 2021
2 parents acd116b + a337455 commit 4856fc1
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 4856fc1

Please sign in to comment.