JUnit 5로 Testcontainers 컨테이너 수명주기 관리하기
JUnit 5로 Testcontainers 컨테이너 수명주기 관리하기
이 가이드에서는 JUnit 5 수명주기 콜백, 확장 어노테이션, 싱글턴 컨테이너 패턴을 사용해 Testcontainers로 컨테이너 수명주기를 관리하는 여러 방법을 배워요.
출처: 문서
본문
이 가이드를 통해 다음 내용을 배울 수 있어요.
- JUnit 5 수명주기 콜백으로 컨테이너 시작/중지하기
- JUnit 5 확장 어노테이션(
@Testcontainers,@Container)으로 컨테이너 관리하기 - 싱글턴 컨테이너 패턴으로 여러 테스트 클래스가 컨테이너를 공유하기
- 확장 어노테이션과 싱글턴 컨테이너를 조합할 때 흔한 잘못된 설정 피하기
사전 준비 (Prerequisites)
- Java 17 이상
- 선호하는 IDE
- Testcontainers가 지원하는 Docker 환경
참고: Testcontainers가 처음이라면 Testcontainers 개요를 방문해 알아보는 걸 권장해요.
프로젝트와 비즈니스 로직 만들기
Maven으로 Java 프로젝트를 만들고 필요한 의존성을 추가해요.
<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>
Customer 레코드를 만들어요.
package com.testcontainers.demo;
public record Customer(Long id, String name) {}
고객을 생성, 조회, 삭제하는 메서드를 가진 CustomerService 클래스를 만들어요.
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 수명주기 콜백
Testcontainers로 테스트할 때는 테스트를 실행하기 전에 필요한 컨테이너를 시작하고, 끝난 뒤 제거하고 싶을 거예요. 이를 위해 JUnit 5의 @BeforeAll과 @AfterAll 수명주기 콜백 메서드를 사용할 수 있어요.
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());
}
}
코드가 하는 일을 살펴보면,
PostgreSQLContainer가static필드로 선언돼요.- 컨테이너는 이 클래스의 모든 테스트 전에 시작되고 모든 테스트 후에 중지돼요.
@BeforeAll이 컨테이너를 시작하고,@AfterAll이 중지해요.@BeforeEach는 컨테이너의 JDBC 매개변수로CustomerService를 초기화하고, 모든 행을 삭제해서 각 테스트가 깨끗한 데이터베이스를 갖게 해요.
주요 관찰 포인트:
- 컨테이너가
static필드이므로 클래스의 모든 테스트 메서드가 공유해요. - 비정적(non-static) 필드로 선언하고
@BeforeEach/@AfterEach를 사용해 테스트마다 새 컨테이너를 시작할 수도 있지만, 리소스를 많이 쓰므로 권장하지 않아요. @AfterAll에서 컨테이너를 명시적으로 중지하지 않아도, Testcontainers는 JVM이 종료될 때 Ryuk 컨테이너를 사용해 컨테이너를 자동으로 정리해줘요.
JUnit 5 확장 어노테이션
Testcontainers 라이브러리는 오직 어노테이션만으로 컨테이너 시작과 중지를 단순화하는 JUnit 5 확장을 제공해요. 이를 사용하려면 org.testcontainers:testcontainers-junit-jupiter 테스트 의존성을 추가해요.
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());
}
}
@BeforeAll과 @AfterAll에서 컨테이너를 수동으로 시작/중지하는 대신, 클래스의 @Testcontainers 어노테이션과 필드의 @Container 어노테이션이 자동으로 처리해요.
- 확장은
@Container로 주석이 달린 모든 필드를 찾아요. static필드는 모든 테스트 전에 한 번 시작되고 모든 테스트 후에 중지돼요.- 인스턴스 필드는 각 테스트 전에 시작되고 각 테스트 후에 중지돼요(권장하지 않아요 — 리소스를 많이 써요).
싱글턴 컨테이너 패턴
테스트 클래스 수가 늘어나면 클래스마다 컨테이너를 시작하는 게 누적돼요. 싱글턴 컨테이너 패턴은 필요한 모든 컨테이너를 공통 베이스 클래스에서 한 번 시작하고 모든 통합 테스트에서 재사용해요.
베이스 클래스 정의하기
정적 초기화 블록에서 컨테이너를 시작하는 추상 베이스 클래스를 만들어요.
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();
}
}
컨테이너는 클래스가 로드될 때 한 번 시작되며, Testcontainers는 JVM이 종료된 후 Ryuk 컨테이너로 이를 제거해요.
팁: 컨테이너를 순차적으로 시작하는 대신
Startables.deepStart(postgres, kafka).join();으로 병렬로 시작할 수 있어요.
베이스 클래스 확장하기
각 테스트 클래스는 베이스 클래스를 상속받아 같은 컨테이너를 재사용해요.
class ProductControllerTest extends AbstractIntegrationTest {
ProductRepository productRepository;
@BeforeEach
void setUp() {
productRepository = new ProductRepository(...);
productRepository.deleteAll();
}
@Test
void shouldGetAllProducts() {
// 공유되는 postgres 컨테이너를 사용하는 테스트 로직
}
}
흔한 잘못된 설정 피하기
흔한 실수는 싱글턴 컨테이너와 @Testcontainers, @Container 어노테이션을 조합하는 거예요.
// 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);
}
}
@Testcontainers 확장은 각 테스트 클래스가 끝날 때 컨테이너를 중지해요. 이후의 테스트 클래스는 캐시된 Spring 컨텍스트를 재사용하지만, 컨테이너는 이미 중지돼서 연결 실패가 발생해요.
대신 static 초기화 블록이나 @BeforeAll을 사용해 컨테이너를 시작하고, @Testcontainers와 @Container 어노테이션은 사용하지 마세요.
요약 (Summary)
- JUnit 5 수명주기 콜백(
@BeforeAll/@AfterAll)을 사용하면 컨테이너 시작/종료를 명시적으로 제어할 수 있어요. - 확장 어노테이션(
@Testcontainers/@Container)을 사용하면 단일 테스트 클래스에서 상용구를 줄일 수 있어요. - 싱글턴 컨테이너 패턴(베이스 클래스의 static 초기화 블록)을 사용하면 여러 테스트 클래스가 컨테이너를 공유할 수 있어요.
- 싱글턴 컨테이너와
@Testcontainers/@Container어노테이션은 혼합하지 마세요.
더 읽어보기 (Further reading)
- Testcontainers JUnit 5 빠른 시작
- Testcontainers 싱글턴 컨테이너 패턴
- Testcontainers로 Spring Boot REST API 테스트하기