Skip to content

Commit

Permalink
Merge pull request #20 from jamezp/refactor-deux
Browse files Browse the repository at this point in the history
Use the @TestContainer annotation for resource injection. Add a new R…
  • Loading branch information
jasondlee authored Jul 4, 2024
2 parents 7180ced + 9aaae6d commit 0f10dcb
Show file tree
Hide file tree
Showing 17 changed files with 704 additions and 241 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*
* Copyright The Arquillian Authors
* SPDX-License-Identifier: Apache-2.0
*/

package org.jboss.arquillian.testcontainers;

import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.InaccessibleObjectException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.jboss.arquillian.core.api.Instance;
import org.jboss.arquillian.core.api.annotation.Inject;
import org.jboss.arquillian.test.spi.TestEnricher;
import org.jboss.arquillian.testcontainers.api.DockerRequired;
import org.jboss.arquillian.testcontainers.api.Testcontainer;
import org.testcontainers.containers.GenericContainer;

/**
* A test enricher for injecting a {@link GenericContainer} into fields annotated with {@link Testcontainer @Testcontainer}.
*
* @author <a href="mailto:[email protected]">James R. Perkins</a>
*/
@SuppressWarnings({ "unchecked" })
public class ContainerInjectionTestEnricher implements TestEnricher {
@Inject
private Instance<TestcontainerRegistry> instances;

@Override
public void enrich(final Object testCase) {
if (!isAnnotatedWith(testCase.getClass(), DockerRequired.class)) {
return;
}
for (Field field : getFieldsWithAnnotation(testCase.getClass())) {
Object value;
try {
final List<Annotation> qualifiers = Stream.of(field.getAnnotations())
.filter(a -> !(a instanceof Testcontainer))
.collect(Collectors.toList());
final Testcontainer testcontainer = field.getAnnotation(Testcontainer.class);

// If the field is the default GenericContainer, validate the field is a GenericContainer
if (testcontainer.type() == GenericContainer.class) {
if (!(GenericContainer.class.isAssignableFrom(field.getType()))) {
throw new IllegalArgumentException(
String.format("Field %s is not assignable to %s", field, testcontainer.type()
.getName()));
}
} else {
// An explicit type was defined, make sure we can assign the type to the field
if (!(field.getType().isAssignableFrom(testcontainer.type()))) {
throw new IllegalArgumentException(
String.format("Field %s is not assignable to %s", field, testcontainer.type()
.getName()));
}
}

value = instances.get()
.lookupOrCreate((Class<GenericContainer<?>>) field.getType(), testcontainer, qualifiers);
} catch (Exception e) {
throw new RuntimeException("Could not lookup value for field " + field, e);
}
try {
// Field marked as accessible during lookup to fail early if it cannot be made accessible. See the
// getFieldsWithAnnotation() method.
field.set(testCase, value);
} catch (Exception e) {
throw new RuntimeException("Could not set value on field " + field + " using " + value, e);
}
}
}

@Override
public Object[] resolve(final Method method) {
return new Object[method.getParameterTypes().length];
}

private static List<Field> getFieldsWithAnnotation(final Class<?> source) {
final List<Field> foundFields = new ArrayList<>();
Class<?> nextSource = source;
while (nextSource != Object.class) {
for (Field field : nextSource.getDeclaredFields()) {
if (field.isAnnotationPresent(Testcontainer.class)) {
if (!field.trySetAccessible()) {
throw new InaccessibleObjectException(String.format("Could not make field %s accessible", field));
}
foundFields.add(field);
}
}
nextSource = nextSource.getSuperclass();
}
return List.copyOf(foundFields);
}

private static boolean isAnnotatedWith(final Class<?> clazz, final Class<? extends Annotation> annotation) {
if (clazz == null) {
return false;
}
if (clazz.isAnnotationPresent(annotation)) {
return true;
}
for (Class<?> intf : clazz.getInterfaces()) {
if (isAnnotatedWith(intf, annotation)) {
return true;
}
}
return isAnnotatedWith(clazz.getSuperclass(), annotation);
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
package org.jboss.arquillian.testcontainers;

import org.jboss.arquillian.core.spi.LoadableExtension;
import org.jboss.arquillian.test.spi.enricher.resource.ResourceProvider;
import org.jboss.arquillian.test.spi.TestEnricher;

class TestContainersExtension implements LoadableExtension {
@Override
public void register(ExtensionBuilder builder) {
builder
.observer(TestContainersObserver.class)
.service(ResourceProvider.class, TestContainerProvider.class);
.service(TestEnricher.class, ContainerInjectionTestEnricher.class);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,96 +6,81 @@

import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList;
import java.util.List;

import org.jboss.arquillian.container.spi.ContainerRegistry;
import org.jboss.arquillian.core.api.Instance;
import org.jboss.arquillian.core.api.InstanceProducer;
import org.jboss.arquillian.core.api.annotation.Inject;
import org.jboss.arquillian.core.api.annotation.Observes;
import org.jboss.arquillian.core.spi.ServiceLoader;
import org.jboss.arquillian.test.spi.TestClass;
import org.jboss.arquillian.test.spi.annotation.ClassScoped;
import org.jboss.arquillian.test.spi.event.enrichment.AfterEnrichment;
import org.jboss.arquillian.test.spi.event.suite.AfterClass;
import org.jboss.arquillian.test.spi.event.suite.BeforeClass;
import org.jboss.arquillian.testcontainers.api.TestContainer;
import org.jboss.arquillian.testcontainers.api.TestContainerInstances;
import org.jboss.arquillian.testcontainers.api.DockerRequired;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.containers.GenericContainer;

@SuppressWarnings("unused")
class TestContainersObserver {
@Inject
@ClassScoped
private InstanceProducer<TestContainerInstances> containersWrapper;
private InstanceProducer<TestcontainerRegistry> containerRegistry;

private ContainerRegistry registry;
@Inject
private Instance<ContainerRegistry> registry;

public void createContainer(@Observes(precedence = 500) BeforeClass beforeClass) {
TestClass javaClass = beforeClass.getTestClass();
TestContainer tcAnno = javaClass.getAnnotation(TestContainer.class);
if (tcAnno != null) {
checkForDocker(tcAnno.failIfNoDocker(), isDockerAvailable());
List<GenericContainer<?>> containers = new ArrayList<>();
for (Class<? extends GenericContainer<?>> clazz : tcAnno.value()) {
try {
final GenericContainer<?> container = clazz.getConstructor().newInstance();
containers.add(container);
} catch (Exception e) { // Clean up
throw new RuntimeException(e);
}
/**
* This first checks if the {@link DockerRequired} annotation is present on the test class failing if necessary. It
* then creates the {@link TestcontainerRegistry} and stores it in a {@link ClassScoped} instance.
*
* @param beforeClass the before class event
*
* @throws Throwable if an error occurs
*/
public void createContainer(@Observes(precedence = 500) BeforeClass beforeClass) throws Throwable {
final TestClass javaClass = beforeClass.getTestClass();
final DockerRequired dockerRequired = javaClass.getAnnotation(DockerRequired.class);
if (dockerRequired != null) {
if (!isDockerAvailable()) {
throw createException(dockerRequired.value());
}
TestContainerInstances instances = new TestContainerInstances(containers);
containersWrapper.set(instances);
instances.beforeStart(registry);
for (GenericContainer<?> container : instances.all()) {
container.start();
}
instances.afterStart(registry);
}
final TestcontainerRegistry instances = new TestcontainerRegistry();
containerRegistry.set(instances);
}

public void registerInstance(@Observes ContainerRegistry registry, ServiceLoader serviceLoader) {
this.registry = registry;
}

/**
* Stops all containers, even ones not managed via Arquillian, after the test is complete
*
* @param afterClass the after class event
*/
public void stopContainer(@Observes AfterClass afterClass) {
TestContainerInstances instances = containersWrapper.get();
if (instances != null) {
instances.beforeStop(registry);
for (GenericContainer<?> container : instances.all()) {
container.stop();
TestcontainerRegistry registry = containerRegistry.get();
if (registry != null) {
for (TestcontainerDescription container : registry) {
container.instance.stop();
}
}
}

private void checkForDocker(boolean failIfNoDocker, boolean isDockerAvailable) {
final String detailMessage = "No Docker environment is available.";
if (!isDockerAvailable) {
if (failIfNoDocker) {
throw new AssertionError(detailMessage);
} else {
// First attempt to throw a JUnit 5 assumption
throwAssumption("org.opentest4j.TestAbortedException", detailMessage);
// Not found, attempt to throw a JUnit exception
throwAssumption("org.junit.AssumptionViolatedException", detailMessage);
// No supported test platform found. Throw an AssertionError.
throw new AssertionError(
"Failed to find a support test platform and no Docker environment is available.");
/**
* Starts all containers after enrichment is done. This happens after the {@link ContainerInjectionTestEnricher} is
* invoked.
*
* @param event the after enrichment event
*/
public void startContainer(@Observes(precedence = 500) final AfterEnrichment event) {
TestcontainerRegistry registry = containerRegistry.get();
if (registry != null) {
// Look for the servers to start on fields only
for (TestcontainerDescription description : registry) {
if (description.testcontainer.value()) {
description.instance.start();
}
}
}
}

private void throwAssumption(final String type, final String detailMessage) {
try {
Class<?> clazz = Class.forName(type);
Constructor<?> ctor = clazz.getConstructor(String.class);
throw (RuntimeException) ctor.newInstance(detailMessage);
} catch (ClassNotFoundException | NoSuchMethodException | InstantiationException | IllegalAccessException
| InvocationTargetException ignore) {
}
}

@SuppressWarnings({ "resource", "BooleanMethodIsAlwaysInverted" })
private boolean isDockerAvailable() {
try {
Expand All @@ -105,4 +90,23 @@ private boolean isDockerAvailable() {
return false;
}
}

private static Throwable createException(final Class<? extends Throwable> value) {
// First try the String.class constructor
try {
final Constructor<? extends Throwable> constructor = value.getConstructor(String.class);
return constructor.newInstance("No Docker environment is available.");
} catch (NoSuchMethodException ignore) {
try {
final Constructor<? extends Throwable> constructor = value.getConstructor();
return constructor.newInstance();
} catch (NoSuchMethodException unused) {
throw new AssertionError(String.format("No String or no-arg constructor found for %s", value));
} catch (InvocationTargetException | InstantiationException | IllegalAccessException e) {
throw new AssertionError(String.format("Failed to create exception for type %s", value), e);
}
} catch (InvocationTargetException | InstantiationException | IllegalAccessException e) {
throw new AssertionError(String.format("Failed to create exception for type %s", value), e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Copyright The Arquillian Authors
* SPDX-License-Identifier: Apache-2.0
*/

package org.jboss.arquillian.testcontainers;

import org.jboss.arquillian.testcontainers.api.Testcontainer;
import org.testcontainers.containers.GenericContainer;

/**
* A holder for information about the Testcontainer being injected into a field.
*
* @author <a href="mailto:[email protected]">James R. Perkins</a>
*/
class TestcontainerDescription {

/**
* The annotation that was on the field
*/
final Testcontainer testcontainer;
/**
* The instance of the container created
*/
final GenericContainer<?> instance;

TestcontainerDescription(final Testcontainer testcontainer, final GenericContainer<?> instance) {
this.testcontainer = testcontainer;
this.instance = instance;
}
}
Loading

0 comments on commit 0f10dcb

Please sign in to comment.