mirror of
https://github.com/docker/docs.git
synced 2026-10-25 06:09:46 +03:00
## Description Follow-up to #25338 (the guides revamp). That PR collapsed most multi-page guide series into single pages but left ~50 guides wrapped in a `<name>/index.md` directory structure alongside guides that are already flat `<name>.md` files — an inconsistent mix. This PR finishes the flattening: - Flattens 50 folder-wrapped guides to `content/guides/<slug>.md` via `git mv` (history preserved). These keep the same URL, since Hugo generates the same permalink for a leaf-bundle `dir/index.md` and a flat `dir.md`. - Moves the last 4 guides' local per-guide image bundles (`genai-leveraging-rag`, `genai-video-bot`, `genai-claude-code-mcp`, `java`) into the shared `content/guides/images/` folder with slug-prefixed filenames, matching the convention already used by most other guides (`databases-1.webp`, `kafka-1.webp`, etc.), and rewrites the in-body image references. - Normalizes `genai-claude-code-mcp`'s non-standard filename (`claude-code-mcp-guide.md`) and capitalized `Images/` folder to match convention. - Promotes `content/guides/frameworks/laravel/index.md` to a top-level `content/guides/laravel.md`, since it was the only guide under `frameworks/` and every other language/framework guide is flat. - Adds `aliases` entries for the two URLs that actually changed (`/guides/frameworks/laravel/` and `/guides/genai-claude-code-mcp/claude-code-mcp-guide/`); every other rename preserves its existing URL. Companion PR establishing this as the documented convention going forward: #25503 ## Validation - `hugo --minify` build via `docker compose run` completes with zero warnings/errors (3174 pages, 1524 aliases — unchanged from before this PR). - Verified both changed-URL guides render at their new path and the old path 301s via the Hugo alias redirect. - Verified every image reference in the 4 migrated guides resolves to an existing file in `content/guides/images/`. - Multi-agent docs review (link-checker, cross-reference-checker, guide-conventions) found zero issues. ## Related issues or tickets Discussed in the team's docs Slack channel following #25338. ## Reviews - [ ] Technical review
470 lines
14 KiB
Markdown
470 lines
14 KiB
Markdown
---
|
|
title: Testcontainers container lifecycle management using JUnit 5
|
|
linkTitle: Container lifecycle (Java)
|
|
description: Learn how to manage Testcontainers container lifecycle using JUnit 5 callbacks, extension annotations, and the singleton containers pattern.
|
|
keywords: testcontainers, java, testing, junit, lifecycle, singleton containers, postgresql
|
|
summary: |
|
|
Learn different approaches to manage container lifecycle with Testcontainers
|
|
using JUnit 5 lifecycle callbacks, extension annotations, and the singleton
|
|
containers pattern.
|
|
aliases:
|
|
- /guides/testcontainers-java-lifecycle/create-project/
|
|
- /guides/testcontainers-java-lifecycle/extension-annotations/
|
|
- /guides/testcontainers-java-lifecycle/lifecycle-callbacks/
|
|
- /guides/testcontainers-java-lifecycle/singleton-containers/
|
|
params:
|
|
tags: [testing]
|
|
time: 20 minutes
|
|
---
|
|
|
|
|
|
<!-- Source: https://github.com/testcontainers/tc-guide-testcontainers-lifecycle -->
|
|
|
|
In this guide, you will learn how to:
|
|
|
|
- Start and stop containers using JUnit 5 lifecycle callbacks
|
|
- Manage containers using JUnit 5 extension annotations (`@Testcontainers` and `@Container`)
|
|
- Share containers across multiple test classes using the singleton containers pattern
|
|
- Avoid a common misconfiguration when combining extension annotations with singleton containers
|
|
|
|
## Prerequisites
|
|
|
|
- Java 17+
|
|
- Your preferred IDE
|
|
- A Docker environment supported by Testcontainers
|
|
|
|
> [!NOTE]
|
|
> If you're new to Testcontainers, visit the
|
|
> [Testcontainers overview](https://testcontainers.com/getting-started/) to learn more about
|
|
> Testcontainers and the benefits of using it.
|
|
|
|
## Create the project and business logic
|
|
|
|
### Set up the project
|
|
|
|
Create a Java project with Maven and add the required dependencies:
|
|
|
|
```xml
|
|
<dependencies>
|
|
<dependency>
|
|
<groupId>org.postgresql</groupId>
|
|
<artifactId>postgresql</artifactId>
|
|
<version>42.7.3</version>
|
|
</dependency>
|
|
<dependency>
|
|
<groupId>ch.qos.logback</groupId>
|
|
<artifactId>logback-classic</artifactId>
|
|
<version>1.5.6</version>
|
|
</dependency>
|
|
<dependency>
|
|
<groupId>org.junit.jupiter</groupId>
|
|
<artifactId>junit-jupiter</artifactId>
|
|
<version>5.10.2</version>
|
|
<scope>test</scope>
|
|
</dependency>
|
|
<dependency>
|
|
<groupId>org.testcontainers</groupId>
|
|
<artifactId>testcontainers-junit-jupiter</artifactId>
|
|
<version>2.0.4</version>
|
|
<scope>test</scope>
|
|
</dependency>
|
|
<dependency>
|
|
<groupId>org.testcontainers</groupId>
|
|
<artifactId>testcontainers-postgresql</artifactId>
|
|
<version>2.0.4</version>
|
|
<scope>test</scope>
|
|
</dependency>
|
|
</dependencies>
|
|
```
|
|
|
|
### Create the business logic
|
|
|
|
Create a `Customer` record:
|
|
|
|
```java
|
|
package com.testcontainers.demo;
|
|
|
|
public record Customer(Long id, String name) {}
|
|
```
|
|
|
|
Create a `CustomerService` class with methods to create, retrieve, and delete
|
|
customers:
|
|
|
|
```java
|
|
package com.testcontainers.demo;
|
|
|
|
import java.sql.Connection;
|
|
import java.sql.DriverManager;
|
|
import java.sql.PreparedStatement;
|
|
import java.sql.ResultSet;
|
|
import java.sql.SQLException;
|
|
import java.util.ArrayList;
|
|
import java.util.List;
|
|
import java.util.Optional;
|
|
|
|
public class CustomerService {
|
|
|
|
private final String url;
|
|
private final String username;
|
|
private final String password;
|
|
|
|
public CustomerService(String url, String username, String password) {
|
|
this.url = url;
|
|
this.username = username;
|
|
this.password = password;
|
|
createCustomersTableIfNotExists();
|
|
}
|
|
|
|
public void createCustomer(Customer customer) {
|
|
try (Connection conn = this.getConnection()) {
|
|
PreparedStatement pstmt = conn.prepareStatement(
|
|
"insert into customers(id,name) values(?,?)"
|
|
);
|
|
pstmt.setLong(1, customer.id());
|
|
pstmt.setString(2, customer.name());
|
|
pstmt.execute();
|
|
} catch (SQLException e) {
|
|
throw new RuntimeException(e);
|
|
}
|
|
}
|
|
|
|
public List<Customer> getAllCustomers() {
|
|
List<Customer> customers = new ArrayList<>();
|
|
try (Connection conn = this.getConnection()) {
|
|
PreparedStatement pstmt = conn.prepareStatement(
|
|
"select id,name from customers"
|
|
);
|
|
ResultSet rs = pstmt.executeQuery();
|
|
while (rs.next()) {
|
|
long id = rs.getLong("id");
|
|
String name = rs.getString("name");
|
|
customers.add(new Customer(id, name));
|
|
}
|
|
} catch (SQLException e) {
|
|
throw new RuntimeException(e);
|
|
}
|
|
return customers;
|
|
}
|
|
|
|
public Optional<Customer> getCustomer(Long customerId) {
|
|
try (Connection conn = this.getConnection()) {
|
|
PreparedStatement pstmt = conn.prepareStatement(
|
|
"select id,name from customers where id = ?"
|
|
);
|
|
pstmt.setLong(1, customerId);
|
|
ResultSet rs = pstmt.executeQuery();
|
|
if (rs.next()) {
|
|
long id = rs.getLong("id");
|
|
String name = rs.getString("name");
|
|
return Optional.of(new Customer(id, name));
|
|
}
|
|
} catch (SQLException e) {
|
|
throw new RuntimeException(e);
|
|
}
|
|
return Optional.empty();
|
|
}
|
|
|
|
public void deleteAllCustomers() {
|
|
try (Connection conn = this.getConnection()) {
|
|
PreparedStatement pstmt = conn.prepareStatement("delete from customers");
|
|
pstmt.execute();
|
|
} catch (SQLException e) {
|
|
throw new RuntimeException(e);
|
|
}
|
|
}
|
|
|
|
private void createCustomersTableIfNotExists() {
|
|
try (Connection conn = this.getConnection()) {
|
|
PreparedStatement pstmt = conn.prepareStatement(
|
|
"""
|
|
create table if not exists customers (
|
|
id bigint not null,
|
|
name varchar not null,
|
|
primary key (id)
|
|
)
|
|
"""
|
|
);
|
|
pstmt.execute();
|
|
} catch (SQLException e) {
|
|
throw new RuntimeException(e);
|
|
}
|
|
}
|
|
|
|
private Connection getConnection() {
|
|
try {
|
|
return DriverManager.getConnection(url, username, password);
|
|
} catch (Exception e) {
|
|
throw new RuntimeException(e);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## JUnit 5 lifecycle callbacks
|
|
|
|
When testing with Testcontainers, you want to start the required containers
|
|
before executing any tests and remove them afterwards. You can use JUnit 5
|
|
`@BeforeAll` and `@AfterAll` lifecycle callback methods for this:
|
|
|
|
```java
|
|
package com.testcontainers.demo;
|
|
|
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
|
import java.util.List;
|
|
import java.util.Optional;
|
|
import org.junit.jupiter.api.AfterAll;
|
|
import org.junit.jupiter.api.BeforeAll;
|
|
import org.junit.jupiter.api.BeforeEach;
|
|
import org.junit.jupiter.api.Test;
|
|
import org.testcontainers.postgresql.PostgreSQLContainer;
|
|
|
|
class CustomerServiceWithLifeCycleCallbacksTest {
|
|
|
|
static PostgreSQLContainer postgres = new PostgreSQLContainer(
|
|
"postgres:16-alpine"
|
|
);
|
|
|
|
CustomerService customerService;
|
|
|
|
@BeforeAll
|
|
static void startContainers() {
|
|
postgres.start();
|
|
}
|
|
|
|
@AfterAll
|
|
static void stopContainers() {
|
|
postgres.stop();
|
|
}
|
|
|
|
@BeforeEach
|
|
void setUp() {
|
|
customerService =
|
|
new CustomerService(
|
|
postgres.getJdbcUrl(),
|
|
postgres.getUsername(),
|
|
postgres.getPassword()
|
|
);
|
|
customerService.deleteAllCustomers();
|
|
}
|
|
|
|
@Test
|
|
void shouldCreateCustomer() {
|
|
customerService.createCustomer(new Customer(1L, "George"));
|
|
|
|
Optional<Customer> customer = customerService.getCustomer(1L);
|
|
assertTrue(customer.isPresent());
|
|
assertEquals(1L, customer.get().id());
|
|
assertEquals("George", customer.get().name());
|
|
}
|
|
|
|
@Test
|
|
void shouldGetCustomers() {
|
|
customerService.createCustomer(new Customer(1L, "George"));
|
|
customerService.createCustomer(new Customer(2L, "John"));
|
|
|
|
List<Customer> customers = customerService.getAllCustomers();
|
|
assertEquals(2, customers.size());
|
|
}
|
|
}
|
|
```
|
|
|
|
Here's what the code does:
|
|
|
|
- `PostgreSQLContainer` is declared as a **static field**. The container starts
|
|
before all tests and stops after all tests in this class.
|
|
- `@BeforeAll` starts the container, `@AfterAll` stops it.
|
|
- `@BeforeEach` initializes `CustomerService` with the container's JDBC
|
|
parameters and deletes all rows to give each test a clean database.
|
|
|
|
Key observations:
|
|
|
|
- Because the container is a **static field**, it's shared across all test
|
|
methods in the class. You can declare it as a non-static field and use
|
|
`@BeforeEach`/`@AfterEach` to start a new container per test, but this
|
|
isn't recommended as it's resource-intensive.
|
|
- Even without explicitly stopping the container in `@AfterAll`, Testcontainers
|
|
uses the [Ryuk container](https://github.com/testcontainers/moby-ryuk) to
|
|
clean up containers automatically when the JVM exits.
|
|
|
|
## JUnit 5 extension annotations
|
|
|
|
The Testcontainers library provides a JUnit 5 extension that simplifies
|
|
starting and stopping containers using annotations. To use it, add the
|
|
`org.testcontainers:testcontainers-junit-jupiter` test dependency.
|
|
|
|
```java
|
|
package com.testcontainers.demo;
|
|
|
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
|
import java.util.List;
|
|
import java.util.Optional;
|
|
import org.junit.jupiter.api.BeforeEach;
|
|
import org.junit.jupiter.api.Test;
|
|
import org.testcontainers.postgresql.PostgreSQLContainer;
|
|
import org.testcontainers.junit.jupiter.Container;
|
|
import org.testcontainers.junit.jupiter.Testcontainers;
|
|
|
|
@Testcontainers
|
|
class CustomerServiceWithJUnit5ExtensionTest {
|
|
|
|
@Container
|
|
static PostgreSQLContainer postgres = new PostgreSQLContainer(
|
|
"postgres:16-alpine"
|
|
);
|
|
|
|
CustomerService customerService;
|
|
|
|
@BeforeEach
|
|
void setUp() {
|
|
customerService =
|
|
new CustomerService(
|
|
postgres.getJdbcUrl(),
|
|
postgres.getUsername(),
|
|
postgres.getPassword()
|
|
);
|
|
customerService.deleteAllCustomers();
|
|
}
|
|
|
|
@Test
|
|
void shouldCreateCustomer() {
|
|
customerService.createCustomer(new Customer(1L, "George"));
|
|
|
|
Optional<Customer> customer = customerService.getCustomer(1L);
|
|
assertTrue(customer.isPresent());
|
|
assertEquals(1L, customer.get().id());
|
|
assertEquals("George", customer.get().name());
|
|
}
|
|
|
|
@Test
|
|
void shouldGetCustomers() {
|
|
customerService.createCustomer(new Customer(1L, "George"));
|
|
customerService.createCustomer(new Customer(2L, "John"));
|
|
|
|
List<Customer> customers = customerService.getAllCustomers();
|
|
assertEquals(2, customers.size());
|
|
}
|
|
}
|
|
```
|
|
|
|
Instead of manually starting and stopping the container in `@BeforeAll` and
|
|
`@AfterAll`, the `@Testcontainers` annotation on the class and the
|
|
`@Container` annotation on the field handle it automatically:
|
|
|
|
- The extension finds all `@Container`-annotated fields.
|
|
- **Static fields** start once before all tests and stop after all tests.
|
|
- **Instance fields** start before each test and stop after each test (not
|
|
recommended — it's resource-intensive).
|
|
|
|
## Singleton containers pattern
|
|
|
|
As the number of test classes grows, starting containers for each class adds
|
|
up. The singleton containers pattern starts all required containers once in a
|
|
common base class and reuses them across all integration tests.
|
|
|
|
### Define the base class
|
|
|
|
Create an abstract base class that starts the containers in a static
|
|
initializer:
|
|
|
|
```java
|
|
package com.testcontainers.demo;
|
|
|
|
import org.testcontainers.postgresql.PostgreSQLContainer;
|
|
import org.testcontainers.kafka.ConfluentKafkaContainer;
|
|
|
|
public abstract class AbstractIntegrationTest {
|
|
|
|
static PostgreSQLContainer postgres = new PostgreSQLContainer(
|
|
"postgres:16-alpine");
|
|
static ConfluentKafkaContainer kafka = new ConfluentKafkaContainer(
|
|
"confluentinc/cp-kafka:7.8.0");
|
|
|
|
static {
|
|
postgres.start();
|
|
kafka.start();
|
|
}
|
|
}
|
|
```
|
|
|
|
The containers start once when the class loads and Testcontainers uses the
|
|
[Ryuk container](https://github.com/testcontainers/moby-ryuk) to remove them
|
|
after the JVM exits.
|
|
|
|
> [!TIP]
|
|
> Instead of starting containers sequentially, start them in parallel using
|
|
> `Startables.deepStart(postgres, kafka).join();`
|
|
|
|
### Extend the base class
|
|
|
|
Each test class inherits from the base class and reuses the same containers:
|
|
|
|
```java
|
|
class ProductControllerTest extends AbstractIntegrationTest {
|
|
|
|
ProductRepository productRepository;
|
|
|
|
@BeforeEach
|
|
void setUp() {
|
|
productRepository = new ProductRepository(...);
|
|
productRepository.deleteAll();
|
|
}
|
|
|
|
@Test
|
|
void shouldGetAllProducts() {
|
|
// test logic using the shared postgres container
|
|
}
|
|
}
|
|
```
|
|
|
|
### Avoid a common misconfiguration
|
|
|
|
A common mistake is combining singleton containers with the `@Testcontainers`
|
|
and `@Container` annotations:
|
|
|
|
```java
|
|
// DON'T DO THIS — containers will stop after each test class
|
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
|
@Testcontainers
|
|
public abstract class AbstractIntegrationTest {
|
|
|
|
@Container
|
|
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
|
|
DockerImageName.parse("postgres:16-alpine"));
|
|
|
|
@DynamicPropertySource
|
|
static void configureProperties(DynamicPropertyRegistry registry) {
|
|
registry.add("spring.datasource.url", postgres::getJdbcUrl);
|
|
registry.add("spring.datasource.username", postgres::getUsername);
|
|
registry.add("spring.datasource.password", postgres::getPassword);
|
|
}
|
|
}
|
|
```
|
|
|
|
The `@Testcontainers` extension stops containers at the end of **each test
|
|
class**. Subsequent test classes reuse the cached Spring context, but the
|
|
containers are already stopped — causing connection failures.
|
|
|
|
Instead, use a static initializer or `@BeforeAll` to start the containers,
|
|
without the `@Testcontainers` and `@Container` annotations.
|
|
|
|
### Summary
|
|
|
|
- Use **JUnit 5 lifecycle callbacks** (`@BeforeAll`/`@AfterAll`) for
|
|
explicit control over container startup and shutdown.
|
|
- Use **extension annotations** (`@Testcontainers`/`@Container`) for less
|
|
boilerplate in single test classes.
|
|
- Use the **singleton containers pattern** (static initializer in a base class)
|
|
to share containers across multiple test classes.
|
|
- Don't mix singleton containers with `@Testcontainers`/`@Container`
|
|
annotations.
|
|
|
|
### Further reading
|
|
|
|
- [Testcontainers JUnit 5 quickstart](https://java.testcontainers.org/quickstart/junit_5_quickstart/)
|
|
- [Testcontainers singleton containers pattern](https://java.testcontainers.org/test_framework_integration/manual_lifecycle_control/#singleton-containers)
|
|
- [Testing a Spring Boot REST API with Testcontainers](/guides/testcontainers-java-spring-boot-rest-api/)
|