Skip to content

Commit ef47508

Browse files
committed
QuarkusComponentTest: integrate seamlessly with quarkus-panache-mock
1 parent 79f6e73 commit ef47508

File tree

9 files changed

+389
-1
lines changed

9 files changed

+389
-1
lines changed

docs/src/main/asciidoc/hibernate-orm-panache.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1164,6 +1164,7 @@ public class PersonRepository implements PanacheRepositoryBase<Person,Integer> {
11641164

11651165
== Mocking
11661166

1167+
[[mocking_active_record]]
11671168
=== Using the active record pattern
11681169

11691170
If you are using the active record pattern you cannot use Mockito directly as it does not support mocking static methods,

docs/src/main/asciidoc/testing-components.adoc

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Therefore, Quarkus provides `QuarkusComponentTestExtension` - a JUnit extension
1818
Unlike `@QuarkusTest` this extension does not start a full Quarkus application but merely the CDI container and the configuration service.
1919
You can find more details in the <<lifecycle>> section.
2020

21-
TIP: This extension is available in the `quarkus-junit5-component` dependency.
21+
TIP: This JUnit extension is available in the `quarkus-junit5-component` dependency.
2222

2323
== Basic example
2424

@@ -303,6 +303,101 @@ By default, only the config properties from `application.properties` and propert
303303
System properties and ENV variables are _not_ included in the test config by default.
304304
However, you can use `@QuarkusComponentTest#useSystemConfigSources()` or `QuarkusComponentTestExtensionBuilder#useSystemConfigSources()` to configure this behavior.
305305

306+
== Panache entities using the active record pattern
307+
308+
If you are using the active record pattern you cannot easily leverage `Mockito#mockStatic()` to mock static methods declared on `PanacheEntityBase`.
309+
In a normal Quarkus app, these static methods are automatically overridden in subclasses.
310+
However, `QuarkusComponentTest` does not start a full Quarkus application.
311+
Nevertheless, you can use the `quarkus-panache-mock` module which integrates seamlessly with `QuarkusComponentTest`.
312+
313+
Add this dependency to your `pom.xml`:
314+
315+
[source,xml]
316+
----
317+
<dependency>
318+
<groupId>io.quarkus</groupId>
319+
<artifactId>quarkus-panache-mock</artifactId>
320+
<scope>test</scope>
321+
</dependency>
322+
----
323+
324+
Given this simple entity:
325+
326+
[source,java]
327+
----
328+
@Entity
329+
public class Person extends PanacheEntity {
330+
331+
public String name;
332+
333+
public Person(String name) {
334+
this.name = name;
335+
}
336+
337+
public static List<Person> findOrdered() {
338+
return find("ORDER BY name").list();
339+
}
340+
}
341+
----
342+
343+
That is used in a simple bean:
344+
345+
[source,java]
346+
----
347+
public class PersonService {
348+
349+
public List<Person> getPersons(boolen sorted) {
350+
if (sorted) {
351+
return Person.findOrdered();
352+
}
353+
return Person.listAll();
354+
}
355+
}
356+
----
357+
358+
You can write your component test like:
359+
360+
.Nested test
361+
[source, java]
362+
----
363+
import static org.junit.jupiter.api.Assertions.assertEquals;
364+
365+
import jakarta.inject.Inject;
366+
import io.quarkus.test.component.QuarkusComponentTest;
367+
import io.quarkus.panache.mock.MockPanacheEntities;
368+
import org.junit.jupiter.api.Test;
369+
import org.mockito.Mockito;
370+
371+
@QuarkusComponentTest <1>
372+
@MockPanacheEntities(Person.class) <2>
373+
public class PersonServiceTest {
374+
375+
@Inject
376+
PersonService personService; <3>
377+
378+
@Test
379+
public void testGetPersons() {
380+
Mockito.when(Person.listAll()).thenReturn(List.of(new Person("Tom")));
381+
List<Person> list = personService.getPersons(false);
382+
assertEquals(1, list.size());
383+
assertEquals("Tom", list.get(0).name);
384+
385+
Mockito.when(Person.findOrdered()).thenReturn(List.of(new Person("Tom")));
386+
list = personService.getPersons(true);
387+
assertEquals(1, list.size());
388+
assertEquals("Tom", list.get(0).name);
389+
}
390+
391+
}
392+
----
393+
<1> The `QuarkusComponentTest` annotation registers the JUnit extension.
394+
<2> `@MockPanacheEntities` installs mocks for the given entity classes.
395+
<3> The test injects the component under the test - `PersonService`.
396+
397+
TIP: See also xref:hibernate-orm-panache.adoc#mocking_active_record[Simplified Hibernate ORM with Panache - Using the active record pattern] for more information.
398+
399+
NOTE: Currently, only the integration with Hibernate ORM is supported. Other variants of the Panache API, such as MongoDB and Hibernate Reactive, are not supported yet.
400+
306401
== Mocking CDI Interceptors
307402

308403
If a tested component class declares an interceptor binding then you might need to mock the interception too.

extensions/panache/panache-mock/pom.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,20 @@
3030
<groupId>io.quarkus</groupId>
3131
<artifactId>quarkus-junit5-mockito</artifactId>
3232
</dependency>
33+
<dependency>
34+
<groupId>io.quarkus</groupId>
35+
<artifactId>quarkus-junit5-component</artifactId>
36+
<optional>true</optional>
37+
</dependency>
3338
<dependency>
3439
<groupId>org.mockito</groupId>
3540
<artifactId>mockito-core</artifactId>
3641
</dependency>
42+
<dependency>
43+
<groupId>io.quarkus</groupId>
44+
<artifactId>quarkus-hibernate-orm-panache</artifactId>
45+
<scope>test</scope>
46+
</dependency>
3747
</dependencies>
3848
<build>
3949
<plugins>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package io.quarkus.panache.mock;
2+
3+
import static java.lang.annotation.ElementType.TYPE;
4+
import static java.lang.annotation.RetentionPolicy.RUNTIME;
5+
6+
import java.lang.annotation.Retention;
7+
import java.lang.annotation.Target;
8+
9+
/**
10+
* Install mocks for the given entity classes.
11+
* <p>
12+
* This annotation only works together with {@code QuarkusComponentTest}.
13+
*
14+
* @see PanacheMock#mock(Class...)
15+
*/
16+
@Retention(RUNTIME)
17+
@Target(TYPE)
18+
public @interface MockPanacheEntities {
19+
20+
/**
21+
* The entity classes.
22+
*/
23+
Class<?>[] value() default {};
24+
}
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package io.quarkus.panache.mock.impl;
2+
3+
import java.lang.reflect.Modifier;
4+
import java.util.ArrayList;
5+
import java.util.HashMap;
6+
import java.util.List;
7+
import java.util.Map;
8+
import java.util.function.BiFunction;
9+
10+
import org.jboss.jandex.AnnotationInstance;
11+
import org.jboss.jandex.ClassInfo;
12+
import org.jboss.jandex.DotName;
13+
import org.jboss.jandex.MethodInfo;
14+
import org.objectweb.asm.ClassVisitor;
15+
import org.objectweb.asm.Opcodes;
16+
17+
import io.quarkus.arc.processor.BytecodeTransformer;
18+
import io.quarkus.gizmo.BytecodeCreator;
19+
import io.quarkus.gizmo.CatchBlockCreator;
20+
import io.quarkus.gizmo.ClassTransformer;
21+
import io.quarkus.gizmo.FieldDescriptor;
22+
import io.quarkus.gizmo.MethodCreator;
23+
import io.quarkus.gizmo.MethodDescriptor;
24+
import io.quarkus.gizmo.ResultHandle;
25+
import io.quarkus.gizmo.TryBlock;
26+
import io.quarkus.panache.common.deployment.PanacheConstants;
27+
import io.quarkus.panache.mock.MockPanacheEntities;
28+
import io.quarkus.panache.mock.PanacheMock;
29+
import io.quarkus.test.component.QuarkusComponentTestCallbacks;
30+
31+
public class PanacheQuarkusComponentTestCallbacks implements QuarkusComponentTestCallbacks {
32+
33+
private static final DotName PANACHE_ENTITY = DotName.createSimple("io.quarkus.hibernate.orm.panache.PanacheEntity");
34+
private static final DotName PANACHE_ENTITY_BASE = DotName
35+
.createSimple("io.quarkus.hibernate.orm.panache.PanacheEntityBase");
36+
37+
@Override
38+
public void beforeBuild(BeforeBuildContext buildContext) {
39+
Class<?> testClass = buildContext.getTestClass();
40+
MockPanacheEntities panacheMocks = testClass.getAnnotation(MockPanacheEntities.class);
41+
if (panacheMocks == null || panacheMocks.value().length == 0) {
42+
return;
43+
}
44+
List<ClassInfo> mockedEntities = new ArrayList<>();
45+
for (Class<?> mockClass : panacheMocks.value()) {
46+
ClassInfo maybeMockedEntity = buildContext.getComputingBeanArchiveIndex().getClassByName(mockClass);
47+
DotName superName = maybeMockedEntity.superName();
48+
if (maybeMockedEntity.isAbstract()
49+
|| superName == null
50+
|| (!superName.equals(PANACHE_ENTITY)
51+
&& !superName.equals(PANACHE_ENTITY_BASE))) {
52+
continue;
53+
}
54+
mockedEntities.add(maybeMockedEntity);
55+
}
56+
57+
Map<DotName, List<MethodInfo>> entityUserMethods = new HashMap<>();
58+
for (ClassInfo entity : mockedEntities) {
59+
for (MethodInfo method : entity.methods()) {
60+
if (!method.isSynthetic()
61+
&& Modifier.isStatic(method.flags())
62+
&& Modifier.isPublic(method.flags())) {
63+
List<MethodInfo> userMethods = entityUserMethods.get(entity.name());
64+
if (userMethods == null) {
65+
userMethods = new ArrayList<>();
66+
entityUserMethods.put(entity.name(), userMethods);
67+
}
68+
userMethods.add(method);
69+
}
70+
}
71+
}
72+
73+
Map<DotName, List<MethodInfo>> entityGeneratedMethods = new HashMap<>();
74+
ClassInfo panacheEntityBaseClassInfo = buildContext.getComputingBeanArchiveIndex()
75+
.getClassByName(DotName.createSimple("io.quarkus.hibernate.orm.panache.PanacheEntityBase"));
76+
for (ClassInfo entity : mockedEntities) {
77+
for (MethodInfo method : panacheEntityBaseClassInfo.methods()) {
78+
if (!userMethodExists(entityUserMethods.get(entity.name()), method)) {
79+
AnnotationInstance bridge = method.annotation(PanacheConstants.DOTNAME_GENERATE_BRIDGE);
80+
if (bridge != null) {
81+
// TODO bridge.value("targetReturnTypeErased") and bridge.value("callSuperMethod")
82+
List<MethodInfo> generated = entityGeneratedMethods.get(entity.name());
83+
if (generated == null) {
84+
generated = new ArrayList<>();
85+
entityGeneratedMethods.put(entity.name(), generated);
86+
}
87+
generated.add(method);
88+
}
89+
}
90+
}
91+
}
92+
93+
for (ClassInfo entity : mockedEntities) {
94+
String entityClassName = entity.name().toString();
95+
ClassTransformer transformer = new ClassTransformer(entity.name().toString());
96+
List<MethodInfo> userMethods = entityUserMethods.get(entity.name());
97+
if (userMethods != null) {
98+
for (MethodInfo userMethod : userMethods) {
99+
transformer.removeMethod(MethodDescriptor.of(userMethod));
100+
addMethod(entityClassName, transformer, userMethod);
101+
}
102+
}
103+
List<MethodInfo> generatedMethods = entityGeneratedMethods.get(entity.name());
104+
if (generatedMethods != null) {
105+
for (MethodInfo generatedMethod : generatedMethods) {
106+
addMethod(entityClassName, transformer, generatedMethod);
107+
}
108+
}
109+
buildContext.addBytecodeTransformer(
110+
new BytecodeTransformer(entityClassName, new BiFunction<String, ClassVisitor, ClassVisitor>() {
111+
@Override
112+
public ClassVisitor apply(String className, ClassVisitor originalVisitor) {
113+
return transformer.applyTo(originalVisitor);
114+
}
115+
}));
116+
}
117+
}
118+
119+
private void addMethod(String entityClass, ClassTransformer transformer, MethodInfo method) {
120+
MethodCreator transform = transformer.addMethod(MethodDescriptor.of(method));
121+
transform.setModifiers(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC);
122+
// if (!PanacheMock.IsMockEnabled)
123+
// throw new RuntimeException("Panache mock not enabled");
124+
// if (!PanacheMock.isMocked(TestClass.class))
125+
// throw new RuntimeException("FooEntity not mocked");
126+
// try {
127+
// return (int)PanacheMock.mockMethod(TestClass.class, "foo", new Class<?>[] {int.class}, new Object[] {arg});
128+
// } catch (PanacheMock.InvokeRealMethodException e) {
129+
// throw new RuntimeException("Unstubbed method called", e);
130+
// }
131+
ResultHandle isMockEnabled = transform
132+
.readStaticField(FieldDescriptor.of(PanacheMock.class, "IsMockEnabled", boolean.class));
133+
BytecodeCreator mockNotEnabled = transform.ifTrue(isMockEnabled).falseBranch();
134+
mockNotEnabled.throwException(RuntimeException.class, "Panache mock not enabled");
135+
ResultHandle isMocked = transform.invokeStaticMethod(
136+
MethodDescriptor.ofMethod(PanacheMock.class, "isMocked", boolean.class, Class.class),
137+
transform.loadClass(entityClass));
138+
BytecodeCreator notMocked = transform.ifTrue(isMocked).falseBranch();
139+
notMocked.throwException(RuntimeException.class, entityClass + " not mocked");
140+
ResultHandle entityClazz = transform.loadClass(entityClass);
141+
ResultHandle methodName = transform.load(method.name());
142+
ResultHandle paramTypes = transform.newArray(Class.class, method.parametersCount());
143+
for (int i = 0; i < method.parametersCount(); i++) {
144+
transform.writeArrayValue(paramTypes, i, transform.loadClass(method.parameterType(i).name().toString()));
145+
}
146+
ResultHandle args = transform.newArray(Object.class, method.parametersCount());
147+
for (int i = 0; i < method.parametersCount(); i++) {
148+
transform.writeArrayValue(args, i, transform.getMethodParam(i));
149+
}
150+
TryBlock tryInvoke = transform.tryBlock();
151+
ResultHandle ret = tryInvoke.invokeStaticMethod(MethodDescriptor.ofMethod(PanacheMock.class, "mockMethod",
152+
Object.class, Class.class, String.class, Class[].class, Object[].class), entityClazz, methodName,
153+
paramTypes,
154+
args);
155+
tryInvoke.returnValue(ret);
156+
CatchBlockCreator catched = tryInvoke.addCatch(PanacheMock.InvokeRealMethodException.class);
157+
catched.throwException(RuntimeException.class,
158+
"Unstubbed method called: " + method.toString(), catched.getCaughtException());
159+
}
160+
161+
@Override
162+
public void afterStart(AfterStartContext afterStartContext) {
163+
Class<?> testClass = afterStartContext.getTestClass();
164+
MockPanacheEntities panacheMocks = testClass.getAnnotation(MockPanacheEntities.class);
165+
if (panacheMocks == null || panacheMocks.value().length == 0) {
166+
return;
167+
}
168+
PanacheMock.mock(panacheMocks.value());
169+
}
170+
171+
@Override
172+
public void afterStop(AfterStopContext afterStopContext) {
173+
PanacheMock.reset();
174+
}
175+
176+
private boolean userMethodExists(List<MethodInfo> userMethods, MethodInfo method) {
177+
if (userMethods == null || userMethods.isEmpty()) {
178+
return false;
179+
}
180+
String descriptor = method.descriptor();
181+
for (MethodInfo userMethod : userMethods) {
182+
if (userMethod.descriptor().equals(descriptor)) {
183+
return true;
184+
}
185+
}
186+
return false;
187+
}
188+
189+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
io.quarkus.panache.mock.impl.PanacheQuarkusComponentTestCallbacks
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package io.quarkus.panache.mock;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertNull;
5+
6+
import java.util.List;
7+
8+
import jakarta.inject.Inject;
9+
10+
import org.junit.jupiter.api.Test;
11+
import org.mockito.Mockito;
12+
13+
import io.quarkus.test.component.QuarkusComponentTest;
14+
15+
@QuarkusComponentTest
16+
@MockPanacheEntities(Person.class)
17+
public class EntityComponentTest {
18+
19+
@Inject
20+
MyComponent myComponent;
21+
22+
@Test
23+
public void testMock() {
24+
Mockito.when(Person.count()).thenReturn(23L);
25+
Mockito.when(Person.count("from foo")).thenReturn(13L);
26+
Mockito.when(Person.findOrdered()).thenReturn(List.of(new Person()));
27+
assertEquals(23, Person.count());
28+
assertEquals(13, Person.count("from foo"));
29+
assertEquals(23, myComponent.ping());
30+
// user method
31+
List<Person> list = Person.findOrdered();
32+
assertEquals(1, list.size());
33+
assertNull(list.get(0).name);
34+
// default values
35+
assertEquals(0, Person.deleteAll());
36+
assertNull(Person.findById("1"));
37+
}
38+
39+
}

0 commit comments

Comments
 (0)