Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JUnit 5 extension #887

Merged
merged 41 commits into from
Nov 2, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
a057302
Add JUnit Jupiter extension without implementation and simple test ad…
britter Sep 28, 2018
7936f90
Add first working implementation
britter Sep 28, 2018
fff8dd0
Adapt TestcontainersRestatBetweenTestsIT from spock extension
britter Sep 28, 2018
fbfac67
Adapt PostgresContainerIT from spock extension
britter Sep 28, 2018
92e2026
Adapt ComposeContainerIT from spock extension
britter Sep 28, 2018
5304cda
Adapt SharedComposeContainerIT from spock extension
britter Sep 28, 2018
09719a9
Adapt TestcontainersSharedContainerIT, thereby rewriting the extenstion
britter Sep 28, 2018
6b7bad6
Add JavaDoc
britter Sep 28, 2018
728a50b
Refactor common code into helper methods
britter Sep 28, 2018
3a939bf
Better documentation
britter Oct 1, 2018
18a16e2
Implement shared containers based on test instance post processor
britter Oct 1, 2018
8d01abe
Write shared container back to instance field
britter Oct 2, 2018
475d349
Write test for nested shared containers
britter Oct 2, 2018
88dc17a
Add implementation that works for nested restarted containers
britter Oct 4, 2018
68759c6
Document the container to ClosableResource adapter
britter Oct 4, 2018
cd143e3
Don't mutate parameter values
britter Oct 4, 2018
fdd6d0b
No need to create a local variable here
britter Oct 4, 2018
1b32a44
Better local variable names
britter Oct 4, 2018
bb1a146
WIP for inheritance use case
britter Oct 8, 2018
f3e0a5f
Fix javadoc generation
britter Oct 12, 2018
99417cf
Fix inheritance test case
britter Oct 12, 2018
e80512e
Revert shared containers as instance fields
britter Oct 22, 2018
c2c416b
Only use one annotation for declaring containers
britter Oct 22, 2018
4f39e1b
Add more documentation
britter Oct 22, 2018
f18010e
Fix typo
britter Oct 22, 2018
71307f0
Use isNotStatic method instead of negating the predicate
britter Oct 22, 2018
c7c77c5
Fix typo
kiview Oct 24, 2018
060d694
Make more specific what to annotate
kiview Oct 24, 2018
f323fd8
Add pointer to Testcontainers BOM
kiview Oct 24, 2018
271a85b
Stream can be processed in parallel
kiview Oct 24, 2018
abba31a
Remove unnecessary plugin declaration
britter Oct 24, 2018
e3008bb
Better method name
britter Oct 24, 2018
633197f
Remove unnecessary test cases
britter Oct 24, 2018
c876afa
Don't rename static fields like constants
britter Oct 24, 2018
80292b8
Remove code that was copied from #636 for the singleton use case whic…
britter Oct 24, 2018
d1c5abe
Remove since tags
britter Oct 24, 2018
e7f2839
Add documentation about missing support for parallel test execution.
britter Oct 25, 2018
a6f9c40
Shared containers have to be declared static
rnorth Oct 28, 2018
835c68e
Fix typo
britter Oct 28, 2018
1af5190
Container can be declared final
britter Oct 30, 2018
421c583
Rename tests
britter Oct 30, 2018
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions modules/junit-jupiter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# TestContainers JUnit Jupiter module

While Testcontainers is tightly coupled with the JUnit 4.x rule API, this module provides
an API that is based on the [JUnit Jupiter](https://junit.org/junit5/) extension model.

The extension supports two modes:

- containers that are restarted for every test method
- containers that are shared between all methods of a test class

## Examples

To use the Testcontainers extension annotate your test class with `@Testcontainers`.

### Restarted containers

To define a restarted container, define an instance field inside your test class and annotate it with
the `@Container` annotation.

```java
@Testcontainers
class SomeTest {

@Container
private MySQLContainer mySQLContainer = new MySQLContainer();

@Test
void someTestMethod() {
String url = mySQLContainer.getJdbcUrl();

// create a connection and run test as normal
}

@Nested
class NestedTests {

@Container
private final PostgreSQLContainer postgreSQLContainer = new PostgreSQLContainer();

void nestedTestMethod() {
// top level container is restarted for nested methods
String mySqlUrl = mySQLContainer.getJdbcUrl();

// nested containers are only available inside their nested class
String postgresUrl = postgreSQLContainer.getJdbcUrl();
}
}
}
```

### Shared containers

Shared containers are defined as static fields in a top level test class and have to be annotated with `@Container`.
Note that shared containers can't be declared inside nested test classes.
This is because nested test classes have to be defined non-static and can't therefore have static fields.

```java
@Testcontainers
class SomeTest {

@Container
private static final MySQLContainer MY_SQL_CONTAINER = new MySQLContainer();

@Test
void someTestMethod() {
String url = MY_SQL_CONTAINER.getJdbcUrl();

// create a connection and run test as normal
}
}
```

### Singleton containers

Sometimes it might be useful to define a container that is only started once for several test classes.
There is no special support for this use case provided by the Testcontainers extension.
Instead this can be implemented using the following pattern:

```java
abstract class AbstractContainerBaseTest {

static final MySQLContainer MY_SQL_CONTAINER;

static {
MY_SQL_CONTAINER = new MySQLContainer();
MY_SQL_CONTAINER.start();
}
}

class FirstTest extends AbstractContainerBaseTest {

@Test
void someTestMethod() {
String url = MY_SQL_CONTAINER.getJdbcUrl();

// create a connection and run test as normal
}
}
```

The singleton container is started only once when the base class is loaded.
The container can then be used by all inheriting test classes.
At the end of the test suite the [Ryuk container](https://github.com/testcontainers/moby-ryuk)
that is started by Testcontainers core will take care of stopping the singleton container.

## Dependency information

Replace `VERSION` with the [latest version available on Maven Central](https://search.maven.org/#search%7Cga%7C1%7Cg%3A%22org.testcontainers%22) or use the Testcontainers BOM.

### Maven

```
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>VERSION</version>
</dependency>
```

### Gradle

```
compile group: 'org.testcontainers', name: 'junit-jupiter', version: 'VERSION'
```

## Limitations

Since this module has a dependency onto JUnit Jupiter and on Testcontainers core, which
has a dependency onto JUnit 4.x, projects using this module will end up with both, JUnit Jupiter
and JUnit 4.x in the test classpath.

This extension has only be tested with sequential test execution. Using it with parallel test execution is unsupported and may have unintended side effects.
22 changes: 22 additions & 0 deletions modules/junit-jupiter/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
description = "Testcontainers :: JUnit Jupiter Extension"

dependencies {
compile project(':testcontainers')
compile 'org.junit.jupiter:junit-jupiter-api:5.3.1'

testCompile project(':mysql')
testCompile project(':postgresql')
testCompile 'com.zaxxer:HikariCP:2.6.1'
testCompile 'redis.clients:jedis:2.8.0'

testRuntime 'org.postgresql:postgresql:42.0.0'
testRuntime 'mysql:mysql-connector-java:6.0.6'
testRuntime 'org.junit.jupiter:junit-jupiter-engine:5.3.1'
}

test {
useJUnitPlatform()
testLogging {
events "passed", "skipped", "failed"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package org.testcontainers.junit.jupiter;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* The {@code @Container} annotation is used in conjunction with the {@link Testcontainers} annotation
* to mark containers that should be managed by the Testcontainers extension.
*
* @see Testcontainers
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Container {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package org.testcontainers.junit.jupiter;

import org.junit.jupiter.api.extension.ExtendWith;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* {@code @Testcontainers} is a JUnit Jupiter extension to activate automatic
* startup and stop of containers used in a test case.
*
* <p>The test containers extension finds all fields of type
* {@link org.testcontainers.lifecycle.Startable} that are annotated with
kiview marked this conversation as resolved.
Show resolved Hide resolved
* {@link Container} and calls their container lifecycle methods. Containers
* declared as static fields will be shared between test methods. They will be
* started only once before any test method is executed and stopped after the
* last test method has executed. Containers declared as instance fields will
* be started and stopped for every test method.</p>
*
* <p><strong>Note:</strong> This extension has only be tested with sequential
* test execution. Using it with parallel test execution is unsupported and
* may have unintended side effects.</p>
*
* <p>Example:</p>
*
* <pre>
kiview marked this conversation as resolved.
Show resolved Hide resolved
* &#64;Testcontainers
* class MyTestcontainersTests {
*
* // will be shared between test methods
* &#64;Container
* private static final MySQLContainer MY_SQL_CONTAINER = new MySQLContainer();
*
* // will be started before and stopped after each test method
* &#64;Container
* private PostgreSQLContainer postgresqlContainer = new PostgreSQLContainer()
* .withDatabaseName("foo")
* .withUsername("foo")
* .withPassword("secret");
*
* &#64;Test
* void test() {
* assertTrue(MY_SQL_CONTAINER.isRunning());
* assertTrue(postgresqlContainer.isRunning());
* }
* }
* </pre>
*
* @see Container
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(TestcontainersExtension.class)
public @interface Testcontainers {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package org.testcontainers.junit.jupiter;

import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionConfigurationException;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ExtensionContext.Namespace;
import org.junit.jupiter.api.extension.ExtensionContext.Store.CloseableResource;
import org.junit.jupiter.api.extension.TestInstancePostProcessor;
import org.junit.platform.commons.support.AnnotationSupport;
import org.junit.platform.commons.util.Preconditions;
import org.junit.platform.commons.util.ReflectionUtils;
import org.testcontainers.lifecycle.Startable;

import java.lang.reflect.Field;
import java.util.LinkedHashSet;
import java.util.Optional;
import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Stream;

class TestcontainersExtension implements TestInstancePostProcessor, BeforeEachCallback {

private static final Namespace NAMESPACE = Namespace.create(TestcontainersExtension.class);

private static final String TEST_INSTANCE = "testInstance";

@Override
public void postProcessTestInstance(final Object testInstance, final ExtensionContext context) {
ExtensionContext.Store store = context.getStore(NAMESPACE);
store.put(TEST_INSTANCE, testInstance);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This works for TEST_INSTANCE as constant key in case of parallel execution?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be a singleton, right? Meaning a store.get(TEST_INSTANCE) should always return the same instance?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@marcphilipp / @sbrannen What's your stance on this in case of parallel execution?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ExtensionContext passed to TestInstancePostProcessors is the one of the test class. Thus, unless I'm missing something, this should not work with parallel test execution.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@marcphilipp okay... Do you have a suggestion on how to implement this? The testInstance is stored to access outer instances in case of @Nested test cases.

I adopted this code from the MockitoExtension. So their code is broken too. We probably should notify them.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about

store.put(testInstance.getClass().getCanonicalName(), testInstance);

and

String innerClassName = getClass().getCanonicalName();
String outerClassName = innerClassName.substring(0, innerClassName.lastIndexOf("."));
ctx.getStore(NAMESPACE).remove(outerClassName)

?

Not a working example, just as an idea for discussion.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see how this helps. In case of parallel execution the class names will be the same. So it's the same problem as with the constant, isn't it?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only if parallel execution happens inside the nested class.
I was assuming, parallel execution will be on outer class level.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to wait for junit-team/junit5#1618

But I also want to dig into the junit code to be sure this does really not work in parallel mode.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can also merge and add a disclaimer, that parallel execution isn't supported ATM. Better than leaving this PR blocked for a longer time.


findSharedContainers(testInstance)
.forEach(adapter -> store.getOrComputeIfAbsent(adapter.key, k -> adapter.start()));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason to use field instead of getter?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The StoreAdapter class is a private class inside of TestcontainersExtension. So all fields are visible from TestcontainersExtension and there is no gain from an information hiding perspective if we add getters.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem I see is that it is inconsistent with the rest of our code base because we always use getters :)

}

@Override
public void beforeEach(final ExtensionContext context) {
collectParentTestInstances(context)
.parallelStream()
.flatMap(this::findRestartContainers)
.forEach(adapter -> context.getStore(NAMESPACE)
.getOrComputeIfAbsent(adapter.key, k -> adapter.start()));
}

private Set<Object> collectParentTestInstances(final ExtensionContext context) {
Set<Object> testInstances = new LinkedHashSet<>();
Optional<ExtensionContext> current = Optional.of(context);
while(current.isPresent()) {
ExtensionContext ctx = current.get();
Object testInstance = ctx.getStore(NAMESPACE).remove(TEST_INSTANCE);
if (testInstance != null) {
testInstances.add(testInstance);
}
current = ctx.getParent();
}
return testInstances;
}

private Stream<StoreAdapter> findSharedContainers(Object testInstance) {
return ReflectionUtils.findFields(
testInstance.getClass(),
isSharedContainer(),
ReflectionUtils.HierarchyTraversalMode.TOP_DOWN)
.stream()
.map(f -> getContainerInstance(testInstance, f));
}

private Predicate<Field> isSharedContainer() {
return isContainer().and(ReflectionUtils::isStatic);
}

private Stream<StoreAdapter> findRestartContainers(Object testInstance) {
return ReflectionUtils.findFields(
testInstance.getClass(),
isRestartContainer(),
ReflectionUtils.HierarchyTraversalMode.TOP_DOWN)
.stream()
.map(f -> getContainerInstance(testInstance, f));
}

private Predicate<Field> isRestartContainer() {
return isContainer().and(ReflectionUtils::isNotStatic);
}

private static Predicate<Field> isContainer() {
return field -> Startable.class.isAssignableFrom(field.getType()) && AnnotationSupport.isAnnotated(field, Container.class);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would return any field annotated with @Container and throw and error if it is not Startable, otherwise it will simply ignore it

}

private static StoreAdapter getContainerInstance(final Object testInstance, final Field field) {
try {
field.setAccessible(true);
kiview marked this conversation as resolved.
Show resolved Hide resolved
Startable containerInstance = Preconditions.notNull((Startable) field.get(testInstance), "Container " + field.getName() + " needs to be initialized");
return new StoreAdapter(field.getDeclaringClass(), field.getName(), containerInstance);
} catch (IllegalAccessException e) {
throw new ExtensionConfigurationException("Can not access container defined in field " + field.getName());
}
}

/**
* An adapter for {@link Startable} that implement {@link CloseableResource}
* thereby letting the JUnit automatically stop containers once the current
* {@link ExtensionContext} is closed.
*/
private static class StoreAdapter implements CloseableResource {
kiview marked this conversation as resolved.
Show resolved Hide resolved

private String key;

private Startable container;

private StoreAdapter(Class<?> declaringClass, String fieldName, Startable container) {
this.key = declaringClass.getName() + "." + fieldName;
this.container = container;
}

private StoreAdapter start() {
container.start();
return this;
}

@Override
public void close() {
container.stop();
}
}
}
Loading