Skip to content

Commit d34a1d1

Browse files
committed
Introduce SUITE, CLASS, and MANUAL lifecycle modes for @TestContainer annotation, replacing the previous boolean value.
SUITE-scoped containers are started once and shared across test classes, CLASS-scoped containers follow the existing per-class behavior, MANUAL-scoped containers are injected but never started/stopped by the framework. Add TestcontainerLifecycle enum for container lifecycle scoping Suite-scoped containers use @SuiteScoped registry created in BeforeSuite, while class-scoped containers use a fresh @ClassScoped registry per test class. TestcontainerRegistryView routes lookups to the correct registry based on lifecycle value. Resolves: #126 Signed-off-by: Radoslav Husar <rhusar@redhat.com> Signed-off-by: Radoslav Husar <radosoft@gmail.com>
1 parent 75f03a2 commit d34a1d1

9 files changed

Lines changed: 302 additions & 27 deletions

File tree

README.adoc

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,33 @@ public class TypeSpecifiedInjectionTest {
8989
}
9090
----
9191

92-
By default, this extension will manage the lifecycle of each Testcontainer that is injected into the test. If you'd
93-
prefer to manage the lifecycle yourself, use the `value=true` attribute in the `@Testcontainer` annotation. For example
94-
use `@Testcontainer(false)`.
92+
=== Container Lifecycle
93+
94+
By default, this extension manages the lifecycle of each Testcontainer per test class. The `@Testcontainer` annotation
95+
accepts a `TestcontainerLifecycle` value to control when containers are started and stopped:
96+
97+
* `TestcontainerLifecycle.CLASS` (default) -- the container is started before each test class and stopped after it
98+
completes.
99+
* `TestcontainerLifecycle.SUITE` -- the container is started once on first encounter and stopped when the test suite
100+
ends. The same container instance is shared across all test classes that request the same type with suite lifecycle.
101+
This is useful for expensive containers that do not need to be restarted between test classes.
102+
* `TestcontainerLifecycle.MANUAL` -- the container is created and injected but never started or stopped by the
103+
framework. The user is responsible for managing the container lifecycle.
104+
105+
[source,java]
106+
----
107+
// Suite-scoped container shared across test classes
108+
@Testcontainer(TestcontainerLifecycle.SUITE)
109+
private KeycloakContainer keycloak;
110+
111+
// Class-scoped container (default behavior)
112+
@Testcontainer
113+
private SimpleTestContainer container;
114+
115+
// Manually managed container
116+
@Testcontainer(TestcontainerLifecycle.MANUAL)
117+
private SimpleTestContainer manualContainer;
118+
----
95119

96120
== Helpers
97121

src/main/java/org/arquillian/testcontainers/ContainerInjectionTestEnricher.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
@SuppressWarnings({ "unchecked" })
3030
public class ContainerInjectionTestEnricher implements TestEnricher {
3131
@Inject
32-
private Instance<TestcontainerRegistry> instances;
32+
private Instance<TestcontainerRegistryView> registries;
3333

3434
@Override
3535
public void enrich(final Object testCase) {
@@ -60,7 +60,7 @@ public void enrich(final Object testCase) {
6060
}
6161
}
6262

63-
value = instances.get()
63+
value = registries.get()
6464
.lookupOrCreate((Class<GenericContainer<?>>) field.getType(), testcontainer, qualifiers);
6565
} catch (Exception e) {
6666
throw new RuntimeException("Could not lookup value for field " + field, e);

src/main/java/org/arquillian/testcontainers/TestContainersObserver.java

Lines changed: 68 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import java.lang.reflect.Constructor;
88
import java.lang.reflect.InvocationTargetException;
99

10+
import org.arquillian.testcontainers.api.TestcontainerLifecycle;
1011
import org.arquillian.testcontainers.api.TestcontainersRequired;
1112
import org.jboss.arquillian.container.spi.ContainerRegistry;
1213
import org.jboss.arquillian.core.api.Instance;
@@ -15,26 +16,46 @@
1516
import org.jboss.arquillian.core.api.annotation.Observes;
1617
import org.jboss.arquillian.test.spi.TestClass;
1718
import org.jboss.arquillian.test.spi.annotation.ClassScoped;
19+
import org.jboss.arquillian.test.spi.annotation.SuiteScoped;
1820
import org.jboss.arquillian.test.spi.event.enrichment.AfterEnrichment;
1921
import org.jboss.arquillian.test.spi.event.suite.AfterClass;
22+
import org.jboss.arquillian.test.spi.event.suite.AfterSuite;
2023
import org.jboss.arquillian.test.spi.event.suite.BeforeClass;
24+
import org.jboss.arquillian.test.spi.event.suite.BeforeSuite;
2125
import org.testcontainers.DockerClientFactory;
2226

2327
@SuppressWarnings("unused")
2428
class TestContainersObserver {
2529

2630
private static final String NO_DOCKER_MSG = "No Docker/podman environment is available.";
2731

32+
@Inject
33+
@SuiteScoped
34+
private InstanceProducer<TestcontainerRegistry> suiteContainerRegistry;
35+
2836
@Inject
2937
@ClassScoped
30-
private InstanceProducer<TestcontainerRegistry> containerRegistry;
38+
private InstanceProducer<TestcontainerRegistry> classContainerRegistry;
39+
40+
@Inject
41+
@ClassScoped
42+
private InstanceProducer<TestcontainerRegistryView> containerRegistries;
3143

3244
@Inject
3345
private Instance<ContainerRegistry> registry;
3446

47+
/**
48+
* Creates the suite-scoped {@link TestcontainerRegistry} once before the suite starts.
49+
*
50+
* @param beforeSuite the before suite event
51+
*/
52+
public void createSuiteRegistry(@Observes BeforeSuite beforeSuite) {
53+
suiteContainerRegistry.set(new TestcontainerRegistry());
54+
}
55+
3556
/**
3657
* This first checks if the {@link TestcontainersRequired} annotation is present on the test class failing if necessary. It
37-
* then creates the {@link TestcontainerRegistry} and stores it in a {@link ClassScoped} instance.
58+
* then creates the class-scoped {@link TestcontainerRegistry} and the combined {@link TestcontainerRegistryView}.
3859
*
3960
* @param beforeClass the before class event
4061
*
@@ -56,38 +77,68 @@ public void createContainer(@Observes(precedence = 500) BeforeClass beforeClass)
5677
throw createException(throwable);
5778
}
5879
}
59-
final TestcontainerRegistry instances = new TestcontainerRegistry();
60-
containerRegistry.set(instances);
80+
81+
// Read suite registry before setting class registry — both InstanceProducers share the same generic type
82+
// (TestcontainerRegistry), and .get() resolves hierarchically (most-specific scope wins), so a subsequent
83+
// .get() on the suite producer would return the class-scoped value instead.
84+
final TestcontainerRegistry suiteRegistry = suiteContainerRegistry.get();
85+
final TestcontainerRegistry classRegistry = new TestcontainerRegistry();
86+
classContainerRegistry.set(classRegistry);
87+
88+
containerRegistries.set(new TestcontainerRegistryView(suiteRegistry, classRegistry));
6189
}
6290

6391
/**
64-
* Stops all containers, even ones not managed via Arquillian, after the test is complete
92+
* Stops class-scoped containers after the test class is complete. Suite-scoped containers are not stopped here.
6593
*
6694
* @param afterClass the after class event
6795
*/
6896
public void stopContainer(@Observes AfterClass afterClass) {
69-
TestcontainerRegistry registry = containerRegistry.get();
70-
if (registry != null) {
71-
for (TestcontainerDescription container : registry) {
72-
container.instance.stop();
97+
TestcontainerRegistryView registries = containerRegistries.get();
98+
if (registries != null) {
99+
for (TestcontainerDescription container : registries.classRegistry()) {
100+
if (container.testcontainer.value() == TestcontainerLifecycle.CLASS) {
101+
container.instance.stop();
102+
}
73103
}
74104
}
75105
}
76106

77107
/**
78108
* Starts all containers after enrichment is done. This happens after the {@link ContainerInjectionTestEnricher} is
79-
* invoked.
109+
* invoked. Suite-scoped containers are only started if they are not already running.
80110
*
81111
* @param event the after enrichment event
82112
*/
83113
public void startContainer(@Observes(precedence = 500) final AfterEnrichment event) {
84-
TestcontainerRegistry registry = containerRegistry.get();
85-
if (registry != null) {
86-
// Look for the servers to start on fields only
87-
for (TestcontainerDescription description : registry) {
88-
if (description.testcontainer.value()) {
89-
description.instance.start();
90-
}
114+
TestcontainerRegistryView registries = containerRegistries.get();
115+
if (registries == null) {
116+
return;
117+
}
118+
119+
for (TestcontainerDescription description : registries.classRegistry()) {
120+
if (description.testcontainer.value() == TestcontainerLifecycle.CLASS) {
121+
description.instance.start();
122+
}
123+
}
124+
125+
for (TestcontainerDescription description : registries.suiteRegistry()) {
126+
if (!description.instance.isRunning()) {
127+
description.instance.start();
128+
}
129+
}
130+
}
131+
132+
/**
133+
* Stops all suite-scoped containers when the test suite ends.
134+
*
135+
* @param afterSuite the after suite event
136+
*/
137+
public void stopSuiteContainers(@Observes AfterSuite afterSuite) {
138+
TestcontainerRegistry suiteRegistry = suiteContainerRegistry.get();
139+
if (suiteRegistry != null) {
140+
for (TestcontainerDescription container : suiteRegistry) {
141+
container.instance.stop();
91142
}
92143
}
93144
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* Copyright The Arquillian Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
package org.arquillian.testcontainers;
6+
7+
import java.lang.annotation.Annotation;
8+
import java.util.List;
9+
10+
import org.arquillian.testcontainers.api.Testcontainer;
11+
import org.arquillian.testcontainers.api.TestcontainerLifecycle;
12+
import org.testcontainers.containers.GenericContainer;
13+
14+
/**
15+
* A unified view over both the suite-scoped and class-scoped {@link TestcontainerRegistry} instances, routing
16+
* container lookup and creation to the appropriate registry based on the container's configured
17+
* {@link TestcontainerLifecycle lifecycle}.
18+
* <p>
19+
* This class exists because Arquillian's {@link org.jboss.arquillian.core.api.Instance Instance&lt;T&gt;} injection
20+
* in {@link org.jboss.arquillian.test.spi.TestEnricher TestEnricher} services cannot carry scope annotations
21+
* ({@code @SuiteScoped}/{@code @ClassScoped}). Injecting {@code Instance<TestcontainerRegistry>} directly would
22+
* always resolve to the most-specific (class) scope, making it impossible to reach the suite registry. This distinct
23+
* type is produced as {@code @ClassScoped} and provides a single injection point that holds references to both
24+
* registries.
25+
*
26+
* @author Radoslav Husar
27+
*/
28+
class TestcontainerRegistryView {
29+
30+
private final TestcontainerRegistry suiteRegistry;
31+
private final TestcontainerRegistry classRegistry;
32+
33+
TestcontainerRegistryView(final TestcontainerRegistry suiteRegistry, final TestcontainerRegistry classRegistry) {
34+
this.suiteRegistry = suiteRegistry;
35+
this.classRegistry = classRegistry;
36+
}
37+
38+
GenericContainer<?> lookupOrCreate(final Class<GenericContainer<?>> type, final Testcontainer testcontainer,
39+
final List<Annotation> qualifiers) {
40+
if (testcontainer.value() == TestcontainerLifecycle.SUITE) {
41+
return suiteRegistry.lookupOrCreate(type, testcontainer, qualifiers);
42+
}
43+
return classRegistry.lookupOrCreate(type, testcontainer, qualifiers);
44+
}
45+
46+
TestcontainerRegistry suiteRegistry() {
47+
return suiteRegistry;
48+
}
49+
50+
TestcontainerRegistry classRegistry() {
51+
return classRegistry;
52+
}
53+
}

src/main/java/org/arquillian/testcontainers/api/Testcontainer.java

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,19 @@
4848
public @interface Testcontainer {
4949

5050
/**
51-
* Indicates whether Arquillian should manage the starting of the Testcontainer. With a value of {@code false},
52-
* Arquillian will not start the server. It will still attempt to stop the server.
51+
* Defines the lifecycle scope for this Testcontainer.
52+
* <ul>
53+
* <li>{@link TestcontainerLifecycle#SUITE SUITE} - the container is started once on first encounter and stopped
54+
* when the test suite ends; the same instance is shared across test classes</li>
55+
* <li>{@link TestcontainerLifecycle#CLASS CLASS} - the container is started before each test class and stopped
56+
* after it completes (default)</li>
57+
* <li>{@link TestcontainerLifecycle#MANUAL MANUAL} - the container is created and injected but never started or
58+
* stopped by the extension</li>
59+
* </ul>
5360
*
54-
* @return {@code true} to have Arquillian manage the lifecycle of the Testcontainer
61+
* @return the lifecycle scope for this Testcontainer
5562
*/
56-
boolean value() default true;
63+
TestcontainerLifecycle value() default TestcontainerLifecycle.CLASS;
5764

5865
/**
5966
* The type used to create the value for the field. The type must have a no-arg constructor.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* Copyright The Arquillian Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
package org.arquillian.testcontainers.api;
6+
7+
/**
8+
* Defines the lifecycle scope for a container managed by the Arquillian Testcontainers extension.
9+
*
10+
* @author Radoslav Husar
11+
*/
12+
public enum TestcontainerLifecycle {
13+
14+
/**
15+
* The container is started once on first encounter and stopped when the test suite ends. The same container
16+
* instance is shared across all test classes that request the same container type with suite lifecycle.
17+
*/
18+
SUITE,
19+
20+
/**
21+
* The container is started before each test class and stopped after the test class completes. This is the default
22+
* behavior.
23+
*/
24+
CLASS,
25+
26+
/**
27+
* The container is created and injected but never started or stopped by the framework. The user is responsible for
28+
* managing the container lifecycle.
29+
*/
30+
MANUAL,
31+
}

src/test/java/org/arquillian/testcontainers/ManualContainerTest.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
package org.arquillian.testcontainers;
77

88
import org.arquillian.testcontainers.api.Testcontainer;
9+
import org.arquillian.testcontainers.api.TestcontainerLifecycle;
910
import org.arquillian.testcontainers.api.TestcontainersRequired;
1011
import org.arquillian.testcontainers.common.SimpleTestContainer;
1112
import org.jboss.arquillian.container.test.api.Deployment;
@@ -32,7 +33,7 @@
3233
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
3334
public class ManualContainerTest {
3435

35-
@Testcontainer(false)
36+
@Testcontainer(TestcontainerLifecycle.MANUAL)
3637
private static SimpleTestContainer container;
3738

3839
@Deployment
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* Copyright The Arquillian Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package org.arquillian.testcontainers;
7+
8+
import org.arquillian.testcontainers.api.Testcontainer;
9+
import org.arquillian.testcontainers.api.TestcontainerLifecycle;
10+
import org.arquillian.testcontainers.api.TestcontainersRequired;
11+
import org.arquillian.testcontainers.common.SimpleTestContainer;
12+
import org.arquillian.testcontainers.common.WildFlyContainer;
13+
import org.jboss.arquillian.container.test.api.Deployment;
14+
import org.jboss.arquillian.container.test.api.RunAsClient;
15+
import org.jboss.arquillian.junit5.ArquillianExtension;
16+
import org.jboss.shrinkwrap.api.ShrinkWrap;
17+
import org.jboss.shrinkwrap.api.asset.EmptyAsset;
18+
import org.jboss.shrinkwrap.api.spec.JavaArchive;
19+
import org.junit.jupiter.api.Assertions;
20+
import org.junit.jupiter.api.Test;
21+
import org.junit.jupiter.api.extension.ExtendWith;
22+
import org.opentest4j.TestAbortedException;
23+
24+
/**
25+
* Tests that suite-scoped and class-scoped containers coexist correctly in a single test class.
26+
*
27+
* @author Radoslav Husar
28+
*/
29+
@ExtendWith(ArquillianExtension.class)
30+
@RunAsClient
31+
@TestcontainersRequired(TestAbortedException.class)
32+
public class MixedLifecycleTest {
33+
34+
@Testcontainer(TestcontainerLifecycle.SUITE)
35+
private SimpleTestContainer suiteContainer;
36+
37+
@Testcontainer(TestcontainerLifecycle.CLASS)
38+
private WildFlyContainer classContainer;
39+
40+
@Deployment
41+
public static JavaArchive createDeployment() {
42+
return ShrinkWrap.create(JavaArchive.class)
43+
.addAsManifestResource(EmptyAsset.INSTANCE, "beans.xml");
44+
}
45+
46+
@Test
47+
public void suiteContainerIsRunning() {
48+
Assertions.assertNotNull(suiteContainer, "Expected the suite container to be injected.");
49+
Assertions.assertTrue(suiteContainer.isRunning(), "Expected the suite container to be running");
50+
}
51+
52+
@Test
53+
public void classContainerIsRunning() {
54+
Assertions.assertNotNull(classContainer, "Expected the class container to be injected.");
55+
Assertions.assertTrue(classContainer.isRunning(), "Expected the class container to be running");
56+
}
57+
58+
@Test
59+
public void containersAreDifferentInstances() {
60+
Assertions.assertNotSame(suiteContainer, classContainer,
61+
"Suite and class containers should be different instances");
62+
}
63+
}

0 commit comments

Comments
 (0)