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.

Co-authored-by: Stuart Douglas [email protected]
  • Loading branch information
mkouba committed Jun 2, 2023
1 parent 1e2a69e commit 887708b
Show file tree
Hide file tree
Showing 54 changed files with 2,559 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 @@ -2955,6 +2955,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 @@ -566,6 +566,7 @@ private DiscoveryResult discoverTestClasses() {
}
}
}

Set<DotName> allTestAnnotations = collectTestAnnotations(index);
Set<DotName> allTestClasses = new HashSet<>();
Map<DotName, DotName> enclosingClasses = new HashMap<>();
Expand Down Expand Up @@ -597,7 +598,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)
|| quarkusTestClasses.contains(name)) {
continue;
}
var enclosing = enclosingClasses.get(testClass);
Expand Down
132 changes: 132 additions & 0 deletions docs/src/main/asciidoc/getting-started-testing.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -1507,3 +1507,135 @@ 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

IMPORTANT: This feature is experimental and the API may change in the future.

In Quarkus, the component model is built on top CDI.
Therefore, Quarkus provides the `QuarkusComponentTestExtension`, a JUnit extension to ease the testing of components and mocking of their dependencies.
This extension is available in the `quarkus-junit5-component` dependency.

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>
@ConfigProperty(name = "bar")
boolean bar; <3>
public String ping() {
return bar ? charlie.ping() : "nok";
}
}
----
<1> `Foo` is an `@ApplicationScoped` CDI bean.
<2> `Foo` depends on `Charlie` which declares a method `ping()`.
<3> `Foo` depends on the config property `bar`.

Then a component test could look like:

[source, java]
----
import static org.junit.jupiter.api.Assertions.assertEquals;
import jakarta.inject.Inject;
import io.quarkus.test.component.TestConfigProperty;
import io.quarkus.test.component.TestMock;
import io.quarkus.test.component.QuarkusComponentTest;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
@QuarkusComponentTest <1>
@TestConfigProperty(key = "bar", value = "true") <2>
public class FooTest {
@Inject
Foo foo; <3>
@ConfigureMock
Charlie charlieMock; <4>
@Test
public void testPing() {
Mockito.when(charlieMock.ping()).thenReturn("OK"); <5>
assertEquals("OK", foo.ping());
}
}
----
<1> The `QuarkusComponentTest` annotation registers the JUnit extension.
<2> Set a configuration property for the test.
<3> The test injects the component under the test. The types of all fields annotated with `@Inject` are considered the component types under test. You can also specify additional component classes via `@QuarkusComponentTest#value()`.
<4> The test also injects `Charlie`, a dependency for which a synthetic `@Singleton` bean is registered automatically. The injected reference is an "unconfigured" Mockito mock.
<5> We can leverage the Mockito API in a test method to configure the behavior.

If you need the full control over the `QuarkusComponentTestExtension` configuration then you can use the `@RegisterExtension` annotation and configure the extension programatically.
The test above could be rewritten like:

[source, java]
----
import static org.junit.jupiter.api.Assertions.assertEquals;
import jakarta.inject.Inject;
import io.quarkus.test.component.TestMock;
import io.quarkus.test.component.QuarkusComponentTestExtension;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
public class FooTest {
@RegisterExtension <1>
static final QuarkusComponentTestExtension extension = new QuarkusComponentTestExtension().configProperty("bar","true");
@Inject
Foo foo;
@ConfigureMock
Charlie charlieMock;
@Test
public void testPing() {
Mockito.when(charlieMock.ping()).thenReturn("OK");
assertEquals("OK", foo.ping());
}
}
----
<1> The `QuarkusComponentTestExtension` is configured in a static field.

=== Lifecycle

So what exactly does the `QuarkusComponentTest` do?
It starts the CDI container and registers a dedicated xref:config-reference.adoc[configuration object] 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` and `@ConfigureMock` 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.

NOTE: By default, a new test instance is created for each test method. Therefore, a new CDI container is started for each test method. However, if the test class is annotated with `@org.junit.jupiter.api.TestInstance` and the test instance lifecycle is set to `org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS` then the CDI container will be shared across all test method executions of a given test class.

=== 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 `QuarkusComponentTestExtension#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 `QuarkusComponentTestExtension#configProperty(String, String)` method or the `@TestConfigProperty` annotation.
If you only need to use the default values for missing config properties, then the `QuarkusComponentTestExtension#useDefaultConfigProperties()` or `@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 887708b

Please sign in to comment.