Skip to content

Commit

Permalink
Introduce QuarkusComponentTest
Browse files Browse the repository at this point in the history
- a JUnit extension to ease the testing of components and mocking of their dependencies

== Lifecycle
- the CDI container is started and a dedicated SmallRyeConfig is registered during the before all test phase
- the container is stopped and the config is released during the after all test phase
- the fields annotated with jakarta.inject.Inject are injected after a test instance is created and unset before a test instance is destroyed
- the dependent beans injected into fields annotated with jakarta.inject.Inject are correctly destroyed before a test instance is destroyed
- the CDI request context is activated and terminated per each test method

== Auto Mocking Unsatisfied Dependencies
- the test does not fail if a component injects an unsatisfied dependency
- instead, a synthetic bean is registered automatically for each combination of required type and qualifiers of an injection point that resolves to an unsatisfied dependency
- the bean has the Singleton scope so it's shared across all injection points with the same required type and qualifiers
- the injected reference is an unconfigured Mockito mock. You can inject the mock in your test and leverage the Mockito API to configure the behavior

== Custom Mocks For Unsatisfied Dependencies

Sometimes you need the full control over the bean attributes and maybe even configure the default mock behavior. You can use the mock configurator API via the QuarkusComponentTest#mock(Class) method.

== Configuration

A dedicated SmallRyeConfig is registered during the before all test phase. Moreover, it's possible to set the configuration properties via the configProperty(String, String) method. If you only need to use the default values for missing config properties, then the useDefaultConfigProperties() might come in useful.
  • Loading branch information
mkouba committed May 26, 2023
1 parent 4108a2c commit b4dc722
Show file tree
Hide file tree
Showing 45 changed files with 2,068 additions and 263 deletions.
5 changes: 5 additions & 0 deletions bom/application/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2953,6 +2953,11 @@
<artifactId>quarkus-test-security-webauthn</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5-component</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5-internal</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import org.jboss.jandex.AnnotationTarget;
import org.jboss.jandex.ClassInfo;
import org.jboss.jandex.DotName;
import org.jboss.jandex.FieldInfo;
import org.jboss.jandex.Index;
import org.jboss.jandex.Indexer;
import org.jboss.logging.Logger;
Expand Down Expand Up @@ -81,6 +82,7 @@ public class JunitTestRunner {
public static final DotName QUARKUS_TEST = DotName.createSimple("io.quarkus.test.junit.QuarkusTest");
public static final DotName QUARKUS_MAIN_TEST = DotName.createSimple("io.quarkus.test.junit.main.QuarkusMainTest");
public static final DotName QUARKUS_INTEGRATION_TEST = DotName.createSimple("io.quarkus.test.junit.QuarkusIntegrationTest");
public static final DotName QUARKUS_COMPONENT_TEST = DotName.createSimple("io.quarkus.test.component.QuarkusComponentTest");
public static final DotName TEST_PROFILE = DotName.createSimple("io.quarkus.test.junit.TestProfile");
public static final DotName TEST = DotName.createSimple(Test.class.getName());
public static final DotName REPEATED_TEST = DotName.createSimple(RepeatedTest.class.getName());
Expand Down Expand Up @@ -570,6 +572,15 @@ private DiscoveryResult discoverTestClasses() {
}
}
}
Set<String> componentTestClasses = new HashSet<>();
for (ClassInfo testClass : index.getKnownUsers(QUARKUS_COMPONENT_TEST)) {
for (FieldInfo field : testClass.fields()) {
if (field.type().name().equals(QUARKUS_COMPONENT_TEST)) {
componentTestClasses.add(testClass.name().toString());
}
}
}

Set<DotName> allTestAnnotations = collectTestAnnotations(index);
Set<DotName> allTestClasses = new HashSet<>();
Map<DotName, DotName> enclosingClasses = new HashMap<>();
Expand Down Expand Up @@ -601,7 +612,8 @@ private DiscoveryResult discoverTestClasses() {
Set<String> unitTestClasses = new HashSet<>();
for (DotName testClass : allTestClasses) {
String name = testClass.toString();
if (integrationTestClasses.contains(name) || quarkusTestClasses.contains(name)) {
if (integrationTestClasses.contains(name) || componentTestClasses.contains(name)
|| quarkusTestClasses.contains(name)) {
continue;
}
var enclosing = enclosingClasses.get(testClass);
Expand Down
90 changes: 90 additions & 0 deletions docs/src/main/asciidoc/getting-started-testing.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -1506,3 +1506,93 @@ For `@QuarkusIntegrationTest` tests that result in launcher the application as a
This can be used by `QuarkusTestResourceLifecycleManager` that need to launch additional containers that the application will communicate with.
====

== Testing Components

In Quarkus, the component model is built on top CDI.
Therefore, Quarkus provides the `QuarkusComponentTest`, a JUnit extension to ease the testing of components and mocking of their dependencies.

Let's have a component `Foo`:

[source, java]
----
package org.acme;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped <1>
public class Foo {
@Inject
Charlie charlie; <2>
public String ping() {
return charlie.ping();
}
}
----
<1> `Foo` is an `@ApplicationScoped` CDI bean.
<2> `Foo` depends on `Charlie` which declares a method `ping()`.

Then a component test could look like:

[source, java]
----
import static org.junit.jupiter.api.Assertions.assertEquals;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.mockito.Mockito;
import org.acme.Charlie;
import org.acme.Foo;
public class FooTest {
@RegisterExtension <1>
static final QuarkusComponentTest test = new QuarkusComponentTest(Foo.class);
@Inject
Foo foo; <2>
// Inject a mock that is created automatically...
@Inject
Charlie charlie; <3>
@Test
public void testPing() {
Mockito.when(charlie.ping()).thenReturn("OK"); <4>
assertEquals("OK", foo());
}
}
----
<1> The `QuarkusComponentTest` extension is configured in a static field.
<2> The test injects the component under the test.
<3> The test also injects `Charlie`, a dependency for which a synthetic `@Singleton` bean is registered automatically. The injected reference is an "unconfigured" Mockito mock.
<4> We can leverage the Mockito API in a test method to configure the behavior.

=== Lifecycle

So what exactly does the `QuarkusComponentTest` do?
It starts the CDI container and registers a dedicated `SmallRyeConfig` during the `before all` test phase.
The container is stopped and the config is released during the `after all` test phase.
The fields annotated with `@Inject` are injected after a test instance is created and unset before a test instance is destroyed.
Finally, the CDI request context is activated and terminated per each test method.

=== Auto Mocking Unsatisfied Dependencies

Unlike in regular CDI environments the test does not fail if a component injects an unsatisfied dependency.
Instead, a synthetic bean is registered automatically for each combination of required type and qualifiers of an injection point that resolves to an unsatisfied dependency.
The bean has the `@Singleton` scope so it's shared across all injection points with the same required type and qualifiers.
The injected reference is an _unconfigured_ Mockito mock.
You can inject the mock in your test and leverage the Mockito API to configure the behavior.

=== Custom Mocks For Unsatisfied Dependencies

Sometimes you need the full control over the bean attributes and maybe even configure the default mock behavior.
You can use the mock configurator API via the `QuarkusComponentTest#mock()` method.

=== Configuration

A dedicated `SmallRyeConfig` is registered during the `before all` test phase.
Moreover, it's possible to set the configuration properties via the `QuarkusComponentTest#configProperty(String, String)` method.
If you only need to use the default values for missing config properties, then the `QuarkusComponentTest#useDefaultConfigProperties()` might come in useful.
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
package io.quarkus.arc.processor;

import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Queue;
import java.util.Set;
import java.util.function.Function;

Expand Down Expand Up @@ -196,4 +201,162 @@ public static Collection<AnnotationInstance> onlyRuntimeVisible(Collection<Annot
return result;
}

public static org.jboss.jandex.AnnotationInstance jandexAnnotation(Annotation annotation) {
Class<? extends Annotation> annotationType = annotationType(annotation);

DotName name = DotName.createSimple(annotationType.getName());
@SuppressWarnings("unchecked")
org.jboss.jandex.AnnotationValue[] jandexAnnotationValues = jandexAnnotationValues(
(Class<Annotation>) annotationType, annotation);

return org.jboss.jandex.AnnotationInstance.create(name, null, jandexAnnotationValues);
}

@SuppressWarnings("unchecked")
private static Class<? extends Annotation> annotationType(Annotation annotation) {
Class<? extends Annotation> annotationType = null;

Queue<Class<?>> candidates = new ArrayDeque<>();
candidates.add(annotation.getClass());
while (!candidates.isEmpty()) {
Class<?> candidate = candidates.remove();

if (candidate.isAnnotation()) {
annotationType = (Class<? extends Annotation>) candidate;
break;
}

Collections.addAll(candidates, candidate.getInterfaces());
}

if (annotationType == null) {
throw new IllegalArgumentException("Not an annotation: " + annotation);
}

return annotationType;
}

private static <A extends Annotation> org.jboss.jandex.AnnotationValue[] jandexAnnotationValues(
Class<A> annotationType, A annotationInstance) {
List<org.jboss.jandex.AnnotationValue> result = new ArrayList<>();
for (Method member : annotationType.getDeclaredMethods()) {
try {
// annotation types do not necessarily have to be public (if the annotation type
// and the build compatible extension class reside in the same package)
if (!member.canAccess(annotationInstance)) {
member.setAccessible(true);
}
result.add(jandexAnnotationValue(member.getName(), member.invoke(annotationInstance)));
} catch (ReflectiveOperationException e) {
throw new RuntimeException(e);
}
}
return result.toArray(new org.jboss.jandex.AnnotationValue[0]);
}

private static org.jboss.jandex.AnnotationValue jandexAnnotationValue(String name, Object value) {
if (value instanceof Boolean) {
return org.jboss.jandex.AnnotationValue.createBooleanValue(name, (Boolean) value);
} else if (value instanceof Byte) {
return org.jboss.jandex.AnnotationValue.createByteValue(name, (Byte) value);
} else if (value instanceof Short) {
return org.jboss.jandex.AnnotationValue.createShortValue(name, (Short) value);
} else if (value instanceof Integer) {
return org.jboss.jandex.AnnotationValue.createIntegerValue(name, (Integer) value);
} else if (value instanceof Long) {
return org.jboss.jandex.AnnotationValue.createLongValue(name, (Long) value);
} else if (value instanceof Float) {
return org.jboss.jandex.AnnotationValue.createFloatValue(name, (Float) value);
} else if (value instanceof Double) {
return org.jboss.jandex.AnnotationValue.createDoubleValue(name, (Double) value);
} else if (value instanceof Character) {
return org.jboss.jandex.AnnotationValue.createCharacterValue(name, (Character) value);
} else if (value instanceof String) {
return org.jboss.jandex.AnnotationValue.createStringValue(name, (String) value);
} else if (value instanceof Enum) {
return org.jboss.jandex.AnnotationValue.createEnumValue(name,
DotName.createSimple(((Enum<?>) value).getDeclaringClass().getName()), ((Enum<?>) value).name());
} else if (value instanceof Class) {
return org.jboss.jandex.AnnotationValue.createClassValue(name, Types.jandexType((Class<?>) value));
} else if (value.getClass().isAnnotation()) {
Class<? extends Annotation> annotationType = annotationType((Annotation) value);
@SuppressWarnings("unchecked")
org.jboss.jandex.AnnotationValue[] jandexAnnotationValues = jandexAnnotationValues(
(Class<Annotation>) annotationType, (Annotation) value);
org.jboss.jandex.AnnotationInstance jandexAnnotation = org.jboss.jandex.AnnotationInstance.create(
DotName.createSimple(annotationType.getName()), null, jandexAnnotationValues);
return org.jboss.jandex.AnnotationValue.createNestedAnnotationValue(name, jandexAnnotation);
} else if (value.getClass().isArray()) {
org.jboss.jandex.AnnotationValue[] jandexAnnotationValues = Arrays.stream(boxArray(value))
.map(it -> jandexAnnotationValue(name, it))
.toArray(org.jboss.jandex.AnnotationValue[]::new);
return org.jboss.jandex.AnnotationValue.createArrayValue(name, jandexAnnotationValues);
} else {
throw new IllegalArgumentException("Unknown annotation attribute value: " + value);
}
}

private static Object[] boxArray(Object value) {
if (value instanceof boolean[]) {
boolean[] primitiveArray = (boolean[]) value;
Object[] boxedArray = new Boolean[primitiveArray.length];
for (int i = 0; i < primitiveArray.length; i++) {
boxedArray[i] = primitiveArray[i];
}
return boxedArray;
} else if (value instanceof byte[]) {
byte[] primitiveArray = (byte[]) value;
Object[] boxedArray = new Byte[primitiveArray.length];
for (int i = 0; i < primitiveArray.length; i++) {
boxedArray[i] = primitiveArray[i];
}
return boxedArray;
} else if (value instanceof short[]) {
short[] primitiveArray = (short[]) value;
Object[] boxedArray = new Short[primitiveArray.length];
for (int i = 0; i < primitiveArray.length; i++) {
boxedArray[i] = primitiveArray[i];
}
return boxedArray;
} else if (value instanceof int[]) {
int[] primitiveArray = (int[]) value;
Object[] boxedArray = new Integer[primitiveArray.length];
for (int i = 0; i < primitiveArray.length; i++) {
boxedArray[i] = primitiveArray[i];
}
return boxedArray;
} else if (value instanceof long[]) {
long[] primitiveArray = (long[]) value;
Object[] boxedArray = new Long[primitiveArray.length];
for (int i = 0; i < primitiveArray.length; i++) {
boxedArray[i] = primitiveArray[i];
}
return boxedArray;
} else if (value instanceof float[]) {
float[] primitiveArray = (float[]) value;
Object[] boxedArray = new Float[primitiveArray.length];
for (int i = 0; i < primitiveArray.length; i++) {
boxedArray[i] = primitiveArray[i];
}
return boxedArray;
} else if (value instanceof double[]) {
double[] primitiveArray = (double[]) value;
Object[] boxedArray = new Double[primitiveArray.length];
for (int i = 0; i < primitiveArray.length; i++) {
boxedArray[i] = primitiveArray[i];
}
return boxedArray;
} else if (value instanceof char[]) {
char[] primitiveArray = (char[]) value;
Object[] boxedArray = new Character[primitiveArray.length];
for (int i = 0; i < primitiveArray.length; i++) {
boxedArray[i] = primitiveArray[i];
}
return boxedArray;
} else if (value instanceof Object[]) {
return (Object[]) value;
} else {
throw new IllegalArgumentException("Not an array: " + value);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ public void done() {

BeanInfo.Builder builder = new BeanInfo.Builder()
.implClazz(implClass)
.identifier(identifier)
.providerType(providerType)
.beanDeployment(beanDeployment)
.scope(scope)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

import io.quarkus.arc.BeanCreator;
import io.quarkus.arc.BeanDestroyer;
import io.quarkus.arc.InjectableBean;
import io.quarkus.arc.InjectableReferenceProvider;
import io.quarkus.arc.SyntheticCreationalContext;
import io.quarkus.arc.processor.InjectionPointInfo.TypeAndQualifiers;
Expand All @@ -36,6 +37,7 @@
public abstract class BeanConfiguratorBase<THIS extends BeanConfiguratorBase<THIS, T>, T> extends ConfiguratorBase<THIS>
implements Consumer<AnnotationInstance> {

protected String identifier;
protected final DotName implClazz;
protected final Set<Type> types;
protected final Set<AnnotationInstance> qualifiers;
Expand Down Expand Up @@ -311,6 +313,21 @@ public THIS destroyer(Consumer<MethodCreator> methodCreatorConsumer) {
return cast(this);
}

/**
* The identifier becomes part of the {@link BeanInfo#getIdentifier()} and {@link InjectableBean#getIdentifier()}.
* <p>
* An identifier can be used to register multiple synthetic beans with the same set of types and qualifiers.
*
* @param identifier
* @return self
* @see #defaultBean()
* @see #alternative(boolean)
*/
public THIS identifier(String identifier) {
this.identifier = identifier;
return cast(this);
}

@SuppressWarnings("unchecked")
protected static <T> T cast(Object obj) {
return (T) obj;
Expand Down
Loading

0 comments on commit b4dc722

Please sign in to comment.