diff --git a/core/build.gradle b/core/build.gradle index 4dad6c61131..98e2cd9a3da 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -113,6 +113,7 @@ dependencies { testImplementation 'redis.clients:jedis:5.1.5' testImplementation 'com.rabbitmq:amqp-client:5.22.0' testImplementation 'org.mongodb:mongo-java-driver:3.12.14' + testImplementation project(':junit-vintage') testImplementation ('org.mockito:mockito-core:4.11.0') { exclude(module: 'hamcrest-core') diff --git a/core/src/test/java/org/testcontainers/containers/NetworkTest.java b/core/src/test/java/org/testcontainers/containers/NetworkTest.java index 099bef20ed7..0262dad140d 100644 --- a/core/src/test/java/org/testcontainers/containers/NetworkTest.java +++ b/core/src/test/java/org/testcontainers/containers/NetworkTest.java @@ -6,6 +6,9 @@ import org.junit.runner.RunWith; import org.testcontainers.DockerClientFactory; import org.testcontainers.TestImages; +import org.testcontainers.junit.vintage.Container; +import org.testcontainers.junit.vintage.TemporaryNetwork; +import org.testcontainers.junit.vintage.Testcontainers; import static org.assertj.core.api.Assertions.assertThat; @@ -15,15 +18,18 @@ public class NetworkTest { public static class WithRules { @Rule - public Network network = Network.newNetwork(); + public TemporaryNetwork network = new TemporaryNetwork(Network.newNetwork()); @Rule + public Testcontainers containers = new Testcontainers(this); + + @Container public GenericContainer foo = new GenericContainer<>(TestImages.TINY_IMAGE) .withNetwork(network) .withNetworkAliases("foo") .withCommand("/bin/sh", "-c", "while true ; do printf 'HTTP/1.1 200 OK\\n\\nyay' | nc -l -p 8080; done"); - @Rule + @Container public GenericContainer bar = new GenericContainer<>(TestImages.TINY_IMAGE) .withNetwork(network) .withCommand("top"); diff --git a/core/src/test/java/org/testcontainers/junit/ComposeContainerPortViaEnvTest.java b/core/src/test/java/org/testcontainers/junit/ComposeContainerPortViaEnvTest.java index b27f4b080da..52116991e19 100644 --- a/core/src/test/java/org/testcontainers/junit/ComposeContainerPortViaEnvTest.java +++ b/core/src/test/java/org/testcontainers/junit/ComposeContainerPortViaEnvTest.java @@ -2,12 +2,17 @@ import org.junit.Rule; import org.testcontainers.containers.ComposeContainer; +import org.testcontainers.junit.vintage.Container; +import org.testcontainers.junit.vintage.Testcontainers; import java.io.File; public class ComposeContainerPortViaEnvTest extends BaseComposeTest { @Rule + public final Testcontainers containers = new Testcontainers(this); + + @Container public ComposeContainer environment = new ComposeContainer( new File("src/test/resources/v2-compose-test-port-via-env.yml") ) diff --git a/docs/examples/junit4/redis/build.gradle b/docs/examples/junit4/redis/build.gradle index 2a0bfb73e7c..f4cd9f95875 100644 --- a/docs/examples/junit4/redis/build.gradle +++ b/docs/examples/junit4/redis/build.gradle @@ -5,5 +5,6 @@ dependencies { testImplementation "junit:junit:4.13.2" testImplementation project(":testcontainers") + testImplementation project(":junit-vintage") testImplementation 'org.assertj:assertj-core:3.26.3' } diff --git a/docs/examples/junit4/redis/src/test/java/quickstart/RedisBackedCacheIntTest.java b/docs/examples/junit4/redis/src/test/java/quickstart/RedisBackedCacheIntTest.java index e4c4424315b..e61d0a82af3 100644 --- a/docs/examples/junit4/redis/src/test/java/quickstart/RedisBackedCacheIntTest.java +++ b/docs/examples/junit4/redis/src/test/java/quickstart/RedisBackedCacheIntTest.java @@ -4,6 +4,8 @@ import org.junit.Rule; import org.junit.Test; import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.vintage.Container; +import org.testcontainers.junit.vintage.Testcontainers; import org.testcontainers.utility.DockerImageName; import static org.assertj.core.api.Assertions.assertThat; @@ -14,13 +16,19 @@ public class RedisBackedCacheIntTest { // rule { @Rule + public Testcontainers containers = new Testcontainers(this); + + // } + + // container { + @Container public GenericContainer redis = new GenericContainer(DockerImageName.parse("redis:6-alpine")) .withExposedPorts(6379); // } @Before - public void setUp() { + public void createCache() { String address = redis.getHost(); Integer port = redis.getFirstMappedPort(); @@ -29,7 +37,7 @@ public void setUp() { } @Test - public void testSimplePutAndGet() { + public void simplePutAndGet() { underTest.put("test", "example"); String retrieved = underTest.get("test"); diff --git a/docs/examples/junit4/redis/src/test/java/quickstart/RedisBackedCacheIntTestStep0.java b/docs/examples/junit4/redis/src/test/java/quickstart/RedisBackedCacheIntTestStep0.java index e3f52403c48..ab042aa3f3c 100644 --- a/docs/examples/junit4/redis/src/test/java/quickstart/RedisBackedCacheIntTestStep0.java +++ b/docs/examples/junit4/redis/src/test/java/quickstart/RedisBackedCacheIntTestStep0.java @@ -12,13 +12,13 @@ public class RedisBackedCacheIntTestStep0 { private RedisBackedCache underTest; @Before - public void setUp() { + public void createCache() { // Assume that we have Redis running locally? underTest = new RedisBackedCache("localhost", 6379); } @Test - public void testSimplePutAndGet() { + public void simplePutAndGet() { underTest.put("test", "example"); String retrieved = underTest.get("test"); diff --git a/docs/quickstart/junit_4_quickstart.md b/docs/quickstart/junit_4_quickstart.md index 57946c1fb74..b0977781c23 100644 --- a/docs/quickstart/junit_4_quickstart.md +++ b/docs/quickstart/junit_4_quickstart.md @@ -1,10 +1,13 @@ # JUnit 4 Quickstart -It's easy to add Testcontainers to your project - let's walk through a quick example to see how. +This example shows the way you could use Testcontainers with JUnit 4. + +!!! note + JUnit 4 is in [maintenance mode since 2025-05-31](https://github.com/junit-team/junit4), so we recommend using JUnit 5 or newer versions instead. Let's imagine we have a simple program that has a dependency on Redis, and we want to add some tests for it. In our imaginary program, there is a `RedisBackedCache` class which stores data in Redis. - + You can see an example test that could have been written for it (without using Testcontainers): @@ -15,7 +18,7 @@ Notice that the existing test has a problem - it's relying on a local installati This may work if we were sure that every developer and CI machine had Redis installed, but would fail otherwise. We might also have problems if we attempted to run tests in parallel, such as state bleeding between tests, or port clashes. -Let's start from here, and see how to improve the test with Testcontainers: +Let's start from here, and see how to improve the test with Testcontainers: ## 1. Add Testcontainers as a test-scoped dependency @@ -23,7 +26,8 @@ First, add Testcontainers as a dependency as follows: === "Gradle" ```groovy - testImplementation "org.testcontainers:testcontainers:{{latest_version}}" + testImplementation("org.testcontainers:testcontainers:{{latest_version}}") + testImplementation("org.testcontainers:junit-vintage:{{latest_version}}") ``` === "Maven" ```xml @@ -33,18 +37,26 @@ First, add Testcontainers as a dependency as follows: {{latest_version}} test + + org.testcontainers + junit-vintage + {{latest_version}} + test + ``` ## 2. Get Testcontainers to run a Redis container during our tests -Simply add the following to the body of our test class: +Add the following to the body of our test class: [JUnit 4 Rule](../examples/junit4/redis/src/test/java/quickstart/RedisBackedCacheIntTest.java) inside_block:rule The `@Rule` annotation tells JUnit to notify this field about various events in the test lifecycle. -In this case, our rule object is a Testcontainers `GenericContainer`, configured to use a specific Redis image from Docker Hub, and configured to expose a port. +Wrap the containers with `new TestContainersRule(...)` so the containers start and stop, according to the test lifecycle. +In this case, our rule object is not `static`, so the container will start and stop with every test. +The test configures `GenericContainer` to use a specific Redis image from Docker Hub, and to expose a port. If we run our test as-is, then regardless of the actual test outcome, we'll see logs showing us that Testcontainers: @@ -66,9 +78,9 @@ We can do this in our test `setUp` method, to set up our component under test: !!! tip - Notice that we also ask Testcontainers for the container's actual address with `redis.getHost();`, + Notice that we also ask Testcontainers for the container's actual address with `redis.getHost();`, rather than hard-coding `localhost`. `localhost` may work in some environments but not others - for example it may - not work on your current or future CI environment. As such, **avoid hard-coding** the address, and use + not work on your current or future CI environment. As such, **avoid hard-coding** the address, and use `getHost()` instead. ## 4. Run the tests! @@ -80,4 +92,3 @@ Let's look at our complete test class to see how little we had to add to get up [RedisBackedCacheIntTest](../examples/junit4/redis/src/test/java/quickstart/RedisBackedCacheIntTest.java) block:RedisBackedCacheIntTest - diff --git a/modules/junit-vintage/build.gradle b/modules/junit-vintage/build.gradle new file mode 100644 index 00000000000..a67bd6b3305 --- /dev/null +++ b/modules/junit-vintage/build.gradle @@ -0,0 +1,11 @@ +description = "Testcontainers :: JUnit4 Rule" + +dependencies { + api project(":testcontainers") + api 'junit:junit:4.13.2' + implementation platform('org.junit:junit-bom:5.10.3') + implementation 'org.junit.jupiter:junit-jupiter-api' + + testImplementation 'com.datastax.oss:java-driver-core:4.17.0' + testImplementation 'org.assertj:assertj-core:3.26.3' +} diff --git a/modules/junit-vintage/src/main/java/org/testcontainers/junit/vintage/Container.java b/modules/junit-vintage/src/main/java/org/testcontainers/junit/vintage/Container.java new file mode 100644 index 00000000000..97bb96b238f --- /dev/null +++ b/modules/junit-vintage/src/main/java/org/testcontainers/junit/vintage/Container.java @@ -0,0 +1,15 @@ +package org.testcontainers.junit.vintage; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * The {@code @Container} annotation marks containers that should be managed by the + * {@code Testcontainers} rule. + */ +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Container { +} diff --git a/modules/junit-vintage/src/main/java/org/testcontainers/junit/vintage/FailureDetectingExternalResource.java b/modules/junit-vintage/src/main/java/org/testcontainers/junit/vintage/FailureDetectingExternalResource.java new file mode 100644 index 00000000000..ef4e5c65d82 --- /dev/null +++ b/modules/junit-vintage/src/main/java/org/testcontainers/junit/vintage/FailureDetectingExternalResource.java @@ -0,0 +1,96 @@ +package org.testcontainers.junit.vintage; + +import org.junit.rules.TestRule; +import org.junit.runner.Description; +import org.junit.runners.model.MultipleFailureException; +import org.junit.runners.model.Statement; +import org.testcontainers.lifecycle.TestDescription; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * {@link TestRule} which is called before and after each test, and also is notified on success/failure. + * + * This mimics the behaviour of TestWatcher to some degree, but failures occurring in {@code starting()} + * prevent the test from being run. + */ +class FailureDetectingExternalResource implements TestRule { + + @Override + public final Statement apply(Statement base, Description description) { + return new Statement() { + @Override + public void evaluate() throws Throwable { + List errors = new ArrayList(); + Optional failure = Optional.empty(); + + try { + starting(description); + base.evaluate(); + notifySucceeded(description, errors); + } catch (org.junit.internal.AssumptionViolatedException e) { + failure = Optional.of(e); + } catch (Throwable e) { + failure = Optional.of(e); + errors.add(e); + notifyFailed(e, description); + } finally { + notifyFinished(failure, description, errors); + } + + MultipleFailureException.assertEmpty(errors); + } + }; + } + + protected void starting(Description description) throws Throwable {} + + protected void succeeded(Description description) throws Throwable {} + + protected void failed(Throwable e, Description description) throws Throwable {} + + protected void finished(Description description) throws Throwable {} + + private void notifySucceeded(Description description, List errors) { + try { + succeeded(description); + } catch (Throwable e) { + errors.add(e); + } + } + + private void notifyFailed(Throwable failure, Description description) { + try { + failed(failure, description); + } catch (Throwable e) { + failure.addSuppressed(e); + } + } + + private void notifyFinished(Optional failure, Description description, List errors) { + try { + finished(description); + } catch (Throwable e) { + failure.ifPresent(f -> f.addSuppressed(e)); // ifPresentOrElse() requires Java 9 + if (!failure.isPresent()) { + errors.add(e); + } + } + } + + protected static final TestDescription toTestDescription(Description description) { + return new TestDescription() { + @Override + public String getTestId() { + return description.getDisplayName(); + } + + @Override + public String getFilesystemFriendlyName() { + return description.getClassName() + "-" + description.getMethodName(); + } + }; + } +} diff --git a/modules/junit-vintage/src/main/java/org/testcontainers/junit/vintage/TemporaryNetwork.java b/modules/junit-vintage/src/main/java/org/testcontainers/junit/vintage/TemporaryNetwork.java new file mode 100644 index 00000000000..0b43b0e4421 --- /dev/null +++ b/modules/junit-vintage/src/main/java/org/testcontainers/junit/vintage/TemporaryNetwork.java @@ -0,0 +1,66 @@ +package org.testcontainers.junit.vintage; + +import org.junit.rules.ExternalResource; +import org.testcontainers.containers.Network; + +/** + * Integrates {@link Network} with the JUnit4 lifecycle. + */ +public final class TemporaryNetwork extends ExternalResource implements Network { + + private final Network network; + + private volatile State state = State.BEFORE_RULE; + + /** + * Creates an instance. + * + *

The passed-in network will be closed when the current test completes. + * + * @param network Network that the rule will delegate to. + */ + public TemporaryNetwork(Network network) { + this.network = network; + } + + @Override + public String getId() { + if (state == State.AFTER_RULE) { + throw new IllegalStateException("Cannot reference the network after the test completes"); + } + return network.getId(); + } + + @Override + public void close() { + switch (state) { + case BEFORE_RULE: + throw new IllegalStateException("Cannot close the network before the test starts"); + case INSIDE_RULE: + break; + case AFTER_RULE: + throw new IllegalStateException("Cannot reference the network after the test completes"); + } + network.close(); + } + + @Override + protected void before() throws Throwable { + state = State.AFTER_RULE; // Just in case an exception is thrown below. + network.getId(); // This has the side-effect of creating the network. + + state = State.INSIDE_RULE; + } + + @Override + protected void after() { + state = State.AFTER_RULE; + network.close(); + } + + private enum State { + BEFORE_RULE, + INSIDE_RULE, + AFTER_RULE, + } +} diff --git a/modules/junit-vintage/src/main/java/org/testcontainers/junit/vintage/Testcontainers.java b/modules/junit-vintage/src/main/java/org/testcontainers/junit/vintage/Testcontainers.java new file mode 100644 index 00000000000..0f43e5b1ade --- /dev/null +++ b/modules/junit-vintage/src/main/java/org/testcontainers/junit/vintage/Testcontainers.java @@ -0,0 +1,171 @@ +package org.testcontainers.junit.vintage; + +import org.junit.platform.commons.support.AnnotationSupport; +import org.junit.platform.commons.support.HierarchyTraversalMode; +import org.junit.platform.commons.support.ModifierSupport; +import org.junit.platform.commons.support.ReflectionSupport; +import org.junit.runner.Description; +import org.junit.runners.model.MultipleFailureException; +import org.testcontainers.lifecycle.Startable; +import org.testcontainers.lifecycle.TestDescription; +import org.testcontainers.lifecycle.TestLifecycleAware; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.ListIterator; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +/** + * Integrates Testcontainers with the JUnit4 lifecycle. + */ +public final class Testcontainers extends FailureDetectingExternalResource { + + private final Object testInstance; + + private List startedContainers = Collections.emptyList(); + + private List lifecycleAwareContainers = Collections.emptyList(); + + /** + * Constructs an instance for use by {@code @Rule}. + * + * @param testInstance instance of the current test. + */ + public Testcontainers(Object testInstance) { + this.testInstance = Objects.requireNonNull(testInstance); + } + + /** + * Constructs an instance for use by {@code @ClassRule}. + */ + public Testcontainers() { + testInstance = null; + } + + @Override + protected void starting(Description description) { + if (description.isTest()) { + if (testInstance == null) { + throw new RuntimeException("Testcontainers used as a @Rule without being provided a test instance"); + } + } else if (testInstance != null) { + throw new RuntimeException("Testcontainers used as a @ClassRule but was provided a test instance"); + } + + List containers = findContainers(description); + startedContainers = new ArrayList<>(containers.size()); + containers.forEach(startable -> { + startable.start(); + startedContainers.add(startable); + }); + + lifecycleAwareContainers = + startedContainers + .stream() + .filter(startable -> startable instanceof TestLifecycleAware) + .map(TestLifecycleAware.class::cast) + .collect(Collectors.toList()); + if (!lifecycleAwareContainers.isEmpty()) { + TestDescription testDescription = toTestDescription(description); + lifecycleAwareContainers.forEach(container -> container.beforeTest(testDescription)); + } + } + + @Override + protected void succeeded(Description description) { + if (!lifecycleAwareContainers.isEmpty()) { + TestDescription testDescription = toTestDescription(description); + forEachReversed( + lifecycleAwareContainers, + container -> container.afterTest(testDescription, Optional.empty()) + ); + } + } + + @Override + protected void failed(Throwable e, Description description) { + if (!lifecycleAwareContainers.isEmpty()) { + TestDescription testDescription = toTestDescription(description); + Optional exception = Optional.of(e); + forEachReversed(lifecycleAwareContainers, container -> container.afterTest(testDescription, exception)); + } + } + + @Override + protected void finished(Description description) throws Exception { + List errors = new ArrayList(); + + forEachReversed( + startedContainers, + startable -> { + try { + startable.stop(); + } catch (Throwable e) { + errors.add(e); + } + } + ); + + MultipleFailureException.assertEmpty(errors); + } + + private List findContainers(Description description) { + if (description.getTestClass() == null) { + return Collections.emptyList(); + } + Predicate isTargetedContainerField = isContainerField(); + if (testInstance == null) { + isTargetedContainerField = isTargetedContainerField.and(ModifierSupport::isStatic); + } else { + isTargetedContainerField = isTargetedContainerField.and(ModifierSupport::isNotStatic); + } + + return ReflectionSupport + .streamFields(description.getTestClass(), isTargetedContainerField, HierarchyTraversalMode.TOP_DOWN) + .map(this::getContainerInstance) + .collect(Collectors.toList()); + } + + private static Predicate isContainerField() { + return field -> { + boolean isAnnotatedWithContainer = AnnotationSupport.isAnnotated(field, Container.class); + if (isAnnotatedWithContainer) { + boolean isStartable = Startable.class.isAssignableFrom(field.getType()); + + if (!isStartable) { + throw new RuntimeException( + String.format("The @Container field '%s' does not implement Startable", field.getName()) + ); + } + return true; + } + return false; + }; + } + + private Startable getContainerInstance(Field field) { + try { + field.setAccessible(true); + Startable containerInstance = (Startable) field.get(testInstance); + if (containerInstance == null) { + throw new RuntimeException("Container " + field.getName() + " needs to be initialized"); + } + return containerInstance; + } catch (IllegalAccessException e) { + throw new RuntimeException("Cannot access container defined in field " + field.getName()); + } + } + + private static void forEachReversed(List list, Consumer callback) { + ListIterator iterator = list.listIterator(list.size()); + while (iterator.hasPrevious()) { + callback.accept(iterator.previous()); + } + } +} diff --git a/modules/junit-vintage/src/test/java/org/testcontainers/junit/vintage/TestLifecycleAwareContainerMock.java b/modules/junit-vintage/src/test/java/org/testcontainers/junit/vintage/TestLifecycleAwareContainerMock.java new file mode 100644 index 00000000000..30dc0283c96 --- /dev/null +++ b/modules/junit-vintage/src/test/java/org/testcontainers/junit/vintage/TestLifecycleAwareContainerMock.java @@ -0,0 +1,60 @@ +package org.testcontainers.junit.vintage; + +import org.testcontainers.lifecycle.Startable; +import org.testcontainers.lifecycle.TestDescription; +import org.testcontainers.lifecycle.TestLifecycleAware; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +public class TestLifecycleAwareContainerMock implements Startable, TestLifecycleAware { + + static final String START = "start"; + + static final String BEFORE_TEST = "beforeTest"; + + static final String AFTER_TEST = "afterTest"; + + static final String STOP = "stop"; + + private final List lifecycleMethodCalls = new ArrayList<>(); + + private final List lifecycleFilesystemFriendlyNames = new ArrayList<>(); + + private Throwable capturedThrowable; + + @Override + public void beforeTest(TestDescription description) { + lifecycleMethodCalls.add(BEFORE_TEST); + lifecycleFilesystemFriendlyNames.add(description.getFilesystemFriendlyName()); + } + + @Override + public void afterTest(TestDescription description, Optional throwable) { + lifecycleMethodCalls.add(AFTER_TEST); + throwable.ifPresent(capturedThrowable -> this.capturedThrowable = capturedThrowable); + } + + List getLifecycleMethodCalls() { + return lifecycleMethodCalls; + } + + Throwable getCapturedThrowable() { + return capturedThrowable; + } + + public List getLifecycleFilesystemFriendlyNames() { + return lifecycleFilesystemFriendlyNames; + } + + @Override + public void start() { + lifecycleMethodCalls.add(START); + } + + @Override + public void stop() { + lifecycleMethodCalls.add(STOP); + } +} diff --git a/modules/junit-vintage/src/test/java/org/testcontainers/junit/vintage/TestcontainersTest.java b/modules/junit-vintage/src/test/java/org/testcontainers/junit/vintage/TestcontainersTest.java new file mode 100644 index 00000000000..0d4c49c8058 --- /dev/null +++ b/modules/junit-vintage/src/test/java/org/testcontainers/junit/vintage/TestcontainersTest.java @@ -0,0 +1,206 @@ +package org.testcontainers.junit.vintage; + +import org.junit.After; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.RuleChain; +import org.junit.rules.TestRule; +import org.junit.rules.TestWatcher; +import org.junit.runner.Description; +import org.junit.runner.JUnitCore; +import org.junit.runner.Result; +import org.junit.runner.notification.Failure; +import org.junit.runners.model.MultipleFailureException; +import org.junit.runners.model.Statement; + +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +public class TestcontainersTest { + + private static final Statement PASSING_STATEMENT = new Statement() { + @Override + public void evaluate() throws Throwable {} + }; + + private static final Statement FAILING_STATEMENT = new Statement() { + @Override + public void evaluate() throws Throwable { + throw new TestException(); + } + }; + + @After + public void resetIntegrationTest() { + IntegrationTest.reset(); + } + + @Test + public void statementDelegateDoesNotThrow() throws Throwable { + // Arrange + FakeTest fakeTest = new FakeTest(); + Testcontainers containers = new Testcontainers(fakeTest); + Statement statement = containers.apply(PASSING_STATEMENT, FakeTest.TEST_DESCRIPTION); + assertThat(fakeTest.testContainer.getLifecycleMethodCalls()).isEmpty(); + + // Act - evaluate statement + statement.evaluate(); + + // Assert + assertThat(fakeTest.testContainer.getLifecycleMethodCalls()) + .containsExactly( + TestLifecycleAwareContainerMock.START, + TestLifecycleAwareContainerMock.BEFORE_TEST, + TestLifecycleAwareContainerMock.AFTER_TEST, + TestLifecycleAwareContainerMock.STOP + ); + assertThat(fakeTest.testContainer.getCapturedThrowable()).isNull(); + assertThat(fakeTest.testContainer.getLifecycleFilesystemFriendlyNames()).isEqualTo(FakeTest.FRIENDLY_NAMES); + } + + @Test + public void statementDelegateThrows() { + // Arrange + FakeTest fakeTest = new FakeTest(); + Testcontainers containers = new Testcontainers(fakeTest); + Statement statement = containers.apply(FAILING_STATEMENT, FakeTest.TEST_DESCRIPTION); + assertThat(fakeTest.testContainer.getLifecycleMethodCalls()).isEmpty(); + + // Act - evaluate statement + assertThatExceptionOfType(TestException.class).isThrownBy(statement::evaluate); + + // Assert + assertThat(fakeTest.testContainer.getLifecycleMethodCalls()) + .containsExactly( + TestLifecycleAwareContainerMock.START, + TestLifecycleAwareContainerMock.BEFORE_TEST, + TestLifecycleAwareContainerMock.AFTER_TEST, + TestLifecycleAwareContainerMock.STOP + ); + assertThat(fakeTest.testContainer.getCapturedThrowable()).isNotNull(); + assertThat(fakeTest.testContainer.getLifecycleFilesystemFriendlyNames()).isEqualTo(FakeTest.FRIENDLY_NAMES); + } + + @Test + public void integrationTestsPass() throws Exception { + IntegrationTest.enabled = true; + + Result result = JUnitCore.runClasses(IntegrationTest.class); + + verifyNoFailures(result); + assertThat(IntegrationTest.testsStarted) + .withFailMessage("No tests in IntegrationTests were run") + .isGreaterThan(0); + } + + /** Test class used for tests that directly call the Testcontainers rule. */ + private static class FakeTest { + + static final Description TEST_DESCRIPTION = Description.createTestDescription(FakeTest.class, "boom"); + + static final List FRIENDLY_NAMES = Collections.singletonList( + FailureDetectingExternalResource.toTestDescription(TEST_DESCRIPTION).getFilesystemFriendlyName() + ); + + @Container + private final TestLifecycleAwareContainerMock testContainer = new TestLifecycleAwareContainerMock(); + } + + /** Integration tests for verifying behavior around container discovery. */ + public static class IntegrationTest { + + static final List FRIENDLY_NAMES = Collections.singletonList( + FailureDetectingExternalResource + .toTestDescription(Description.createTestDescription(IntegrationTest.class, "containerStarted")) + .getFilesystemFriendlyName() + ); + + static int testsStarted = 0; + + static boolean enabled = false; + + /** Ensures that the tests in this class are not run directly by gradle. */ + final TestRule skipWhenDisabled = new TestRule() { + @Override + public final Statement apply(Statement base, Description description) { + return enabled ? base : PASSING_STATEMENT; + } + }; + + /** The class under test; this isn't annotated by @Rule because it is run by "testRuleChain". */ + final Testcontainers containers = new Testcontainers(this); + + final TestRule verifyPreAndPostConditions = new TestWatcher() { + @Override + protected void starting(Description description) { + forEachTestContainer(container -> { + assertThat(container.getLifecycleMethodCalls()).isEmpty(); + }); + } + + @Override + protected void finished(Description description) { + forEachTestContainer(container -> { + assertThat(container.getLifecycleMethodCalls()) + .containsExactly( + TestLifecycleAwareContainerMock.START, + TestLifecycleAwareContainerMock.BEFORE_TEST, + TestLifecycleAwareContainerMock.AFTER_TEST, + TestLifecycleAwareContainerMock.STOP + ); + }); + } + }; + + @Rule + public final RuleChain testRuleChain = RuleChain + .outerRule(skipWhenDisabled) + .around(verifyPreAndPostConditions) + .around(containers); + + static void reset() { + enabled = false; + testsStarted = 0; + } + + @Container + private final TestLifecycleAwareContainerMock testContainer1 = new TestLifecycleAwareContainerMock(); + + @Container + private final TestLifecycleAwareContainerMock testContainer2 = new TestLifecycleAwareContainerMock(); + + @Test + public void containerStarted() { + testsStarted++; + + forEachTestContainer(container -> { + assertThat(container.getLifecycleMethodCalls()) + .containsExactly( + TestLifecycleAwareContainerMock.START, + TestLifecycleAwareContainerMock.BEFORE_TEST + ); + }); + } + + private void forEachTestContainer(Consumer callback) { + callback.accept(testContainer1); + callback.accept(testContainer2); + } + } + + private static void verifyNoFailures(Result result) throws Exception { + List exceptions = result + .getFailures() + .stream() + .map(Failure::getException) + .collect(Collectors.toList()); + MultipleFailureException.assertEmpty(exceptions); + } + + static class TestException extends RuntimeException {} +}