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 테스트하기

더 알아보기 (Learn more)