Spring Boot 3에서 Testcontainers를 이용한 통합 테스트
1. 개요
H2에서 실행하는 통합 테스트는 로컬 머신에서는 “통과”로 나오지만 PostgreSQL을 실행하는 프로덕션에서는 여전히 오류가 발생합니다. 이유는 두 데이터베이스 시스템의 SQL 문법, 데이터 타입, 트랜잭션 동작이 다르기 때문입니다. Testcontainers는 테스트 실행 시 Docker 컨테이너 내에서 실제 데이터베이스를 시작하여 이 문제를 해결합니다.
이 글에서는 Spring Boot 3.x 애플리케이션을 위해 Testcontainers를 구성하고 @ServiceConnection을 사용하여 컨테이너를 Spring에 연결합니다. 그 다음 여러 테스트 클래스 간에 하나의 컨테이너를 공유하는 방법과 흔한 오류들을 다룹니다.
2. 준비
2.1. Dependency
머신에서 Docker가 실행 중이거나 호환되는 Docker daemon이 필요합니다. 그 후 pom.xml에 다음 dependency를 추가합니다:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Spring Boot parent가 Testcontainers 버전을 관리하므로 <version>을 선언할 필요가 없습니다. spring-boot-testcontainers artifact는 Spring Boot 3.1부터 제공되며 @ServiceConnection annotation을 제공합니다.
2.2. 테스트 대상 코드
간단한 Order entity를 사용합니다:
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String customerEmail;
private BigDecimal total;
@Enumerated(EnumType.STRING)
private OrderStatus status;
protected Order() {
}
public Order(String customerEmail, BigDecimal total, OrderStatus status) {
this.customerEmail = customerEmail;
this.total = total;
this.status = status;
}
public Long getId() { return id; }
public String getCustomerEmail() { return customerEmail; }
public BigDecimal getTotal() { return total; }
public OrderStatus getStatus() { return status; }
}
public enum OrderStatus { NEW, PAID, CANCELLED }
Repository는 PostgreSQL의 ILIKE를 사용하는 native query를 가집니다:
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query(value = "select * from orders where customer_email ilike :pattern",
nativeQuery = true)
List<Order> searchByEmail(@Param("pattern") String pattern);
List<Order> findByStatus(OrderStatus status);
}
Native query는 “H2에서는 작동하지만 Postgres에서 깨지는” 타입의 코드입니다. 그 반대도 마찬가지입니다. 이것이 실제 데이터베이스에서 테스트해야 하는 부분입니다.
3. @ServiceConnection으로 repository 테스트
3.1. 컨테이너 선언
@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
private OrderRepository orderRepository;
@Test
void givenOrders_whenSearchByEmailIgnoringCase_thenReturnMatches() {
orderRepository.save(new Order("An.Nguyen@example.com", new BigDecimal("150000"), OrderStatus.NEW));
orderRepository.save(new Order("binh@example.com", new BigDecimal("90000"), OrderStatus.PAID));
List<Order> result = orderRepository.searchByEmail("%an.nguyen%");
assertThat(result)
.extracting(Order::getCustomerEmail)
.containsExactly("An.Nguyen@example.com");
}
}
각 컴포넌트는 다음과 같이 작동합니다:
@Testcontainers- JUnit 5 extension을 활성화합니다. 이 extension은@Container가 있는 필드를 찾고 그들의 생명주기를 관리합니다.static필드의@Container- 컨테이너는 클래스의 모든 테스트 전에 한 번 시작되고 마지막 테스트 후에 중지됩니다. 필드가static이 아니면 각 테스트 메서드마다 컨테이너가 다시 생성됩니다.@ServiceConnection- Spring Boot는 컨테이너에서 호스트, 무작위 포트, 사용자명 및 암호를 읽고JdbcConnectionDetailsbean을 생성합니다. 따라서spring.datasource.url을 수동으로 구성할 필요가 없습니다.@AutoConfigureTestDatabase(replace = NONE)- 기본적으로@DataJpaTest는 DataSource를 내장 데이터베이스로 바꿀 수 있습니다. Spring Boot 3.4부터 framework는 DataSource가@ServiceConnection에서 온 것을 자동으로 인식합니다. 그럼에도 불구하고 명시적 선언은 3.x의 모든 버전에서 코드가 올바르게 실행되도록 합니다.ddl-auto=create-drop- 내장되지 않은 데이터베이스의 경우 Spring Boot는 기본적으로 스키마를 자동으로 생성하지 않습니다. 실제 프로젝트에서는 Flyway 또는 Liquibase를 사용하여 테스트가 프로덕션의 migration과 올바르게 실행되도록 해야 합니다.
테스트를 실행할 때 로그는 다음과 같습니다:
Creating container for image: postgres:16-alpine
Container postgres:16-alpine is starting: 3f9c1a...
Container postgres:16-alpine started in PT2.1S
HikariPool-1 - Added connection org.postgresql.jdbc.PgConnection@6a1b...
첫 실행은 Docker가 이미지를 pull해야 하므로 더 느립니다. 이후 실행은 일반적으로 몇 초만 걸립니다.
3.2. 이전 방식: @DynamicPropertySource
Spring Boot 3.1 이전이거나 아직 @ServiceConnection이 지원되지 않는 컨테이너의 경우 속성을 수동으로 설정합니다:
@DynamicPropertySource
static void datasourceProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
이 방법은 여전히 작동합니다. 그러나 PostgreSQL, MySQL, Redis, Kafka, MongoDB 및 기타 많은 인기 있는 서비스의 경우 @ServiceConnection이 더 간결하고 속성 이름 오류 가능성이 적습니다.
4. @SpringBootTest로 전체 애플리케이션 테스트
Repository 테스트는 JPA 계층만 로드합니다. HTTP → service → database 흐름을 확인하려면 전체 context를 로드해야 합니다. 다음 controller가 있다고 가정합니다:
@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderRepository orderRepository;
public OrderController(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public record CreateOrderRequest(String customerEmail, BigDecimal total) {}
@PostMapping
public ResponseEntity<Order> create(@RequestBody CreateOrderRequest request) {
Order saved = orderRepository.save(
new Order(request.customerEmail(), request.total(), OrderStatus.NEW));
return ResponseEntity.status(HttpStatus.CREATED).body(saved);
}
@GetMapping("/{id}")
public ResponseEntity<Order> get(@PathVariable Long id) {
return ResponseEntity.of(orderRepository.findById(id));
}
}
4.1. @TestConfiguration으로 컨테이너 공유
각 테스트 클래스에서 컨테이너를 선언하는 대신 bean으로 설정합니다:
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfig {
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>("postgres:16-alpine");
}
}
여기서는 JUnit이 아닌 Spring에서 컨테이너를 관리합니다. Spring은 DataSource를 생성하기 전에 컨테이너를 시작하고 context가 닫힐 때 중지합니다. Spring TestContext는 동일한 구성을 가진 테스트 클래스 간에 context를 캐시하므로 이러한 클래스는 하나의 컨테이너를 공유합니다.
4.2. 테스트 작성
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = "spring.jpa.hibernate.ddl-auto=create-drop")
@Import(TestcontainersConfig.class)
class OrderControllerIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private OrderRepository orderRepository;
@BeforeEach
void cleanDatabase() {
orderRepository.deleteAll();
}
@Test
void whenCreateOrder_thenItIsPersistedAndReadable() {
var request = new OrderController.CreateOrderRequest(
"chi@example.com", new BigDecimal("250000"));
ResponseEntity<Order> created =
restTemplate.postForEntity("/orders", request, Order.class);
assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED);
Long id = created.getBody().getId();
Order fetched = restTemplate.getForObject("/orders/" + id, Order.class);
assertThat(fetched.getCustomerEmail()).isEqualTo("chi@example.com");
assertThat(fetched.getTotal()).isEqualByComparingTo("250000");
assertThat(fetched.getStatus()).isEqualTo(OrderStatus.NEW);
assertThat(orderRepository.findByStatus(OrderStatus.NEW)).hasSize(1);
}
}
몇 가지 주의할 점:
RANDOM_PORT는 실제 서버를 시작합니다.TestRestTemplate은 자동으로 해당 포트를 가리킵니다.- 이 테스트는
@Transactional이 없습니다. HTTP 요청이 테스트와 다른 스레드에서 실행되기 때문입니다. 데이터는 Postgres에 실제로 커밋되므로@BeforeEach에서 정리해야 합니다. isEqualByComparingTo는 값으로BigDecimal을 비교합니다. 이 방법은 다양한 소수 자릿수로 인한250000과250000.00의 차이를 방지합니다.Order클래스는 Jackson으로 deserialize되어야 합니다. deserialize 오류가 발생하면 기본 생성자와 getter를 확인하거나 response를 위한 별도의 record DTO를 사용하세요.
4.3. Postgres 설치 없이 로컬에서 애플리케이션 실행
TestcontainersConfig는 거의 알려지지 않은 용도가 하나 더 있습니다: Postgres를 설치하지 않고 로컬에서 애플리케이션을 실행하는 것입니다. src/test/java에 다음 클래스를 만듭니다:
public class TestOrderApplication {
public static void main(String[] args) {
SpringApplication.from(OrderApplication::main)
.with(TestcontainersConfig.class)
.run(args);
}
}
그 후 다음 명령을 실행합니다:
./mvnw spring-boot:test-run
애플리케이션은 동일한 Postgres 컨테이너와 함께 시작됩니다. 애플리케이션을 종료하면 컨테이너가 중지됩니다.
5. 흔한 오류
5.1. Static @Container를 여러 클래스에서 context를 공유하며 사용
이것이 가장 답답한 오류입니다. @Container static과 @ServiceConnection이 있는 abstract base class가 있고 여러 테스트 클래스가 이를 상속한다고 가정합니다:
OrderControllerIT -> 컨테이너가 포트 55001에서 시작되고 context가 캐시됨
CustomerControllerIT -> JUnit은 이전 컨테이너를 중지하고 포트 55017에서 새 컨테이너를 시작
-> Spring은 캐시된 context를 재사용하며 여전히 55001을 가리킴
-> Connection refused / HikariPool timeout
JUnit은 각 클래스 후에 컨테이너를 중지하지만 Spring은 여전히 캐시된 context를 유지합니다. 두 가지 방법으로 수정할 수 있습니다:
- 섹션 4.1과 같이 bean 컨테이너를 사용합니다. 그러면 컨테이너 생명주기가 context에 연결됩니다. 이것이 권장되는 방법입니다.
- Singleton 컨테이너를 사용합니다:
@Container와@Testcontainers를 제거하고 static 블록에서 수동으로 시작합니다. Ryuk이 JVM 종료 시 컨테이너를 정리합니다.
public abstract class AbstractIntegrationTest {
@ServiceConnection
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
static {
POSTGRES.start();
}
}
5.2. latest 태그 사용
주의: 항상 이미지 버전을 고정하고 (
postgres:16-alpine), 가장 좋은 것은 프로덕션 버전과 일치하는 것입니다.latest태그는 새 이미지가 릴리스될 때 테스트 동작이 예기치 않게 변경될 수 있습니다.
5.3. 테스트 간 데이터 누수
@DataJpaTest는 기본적으로 각 테스트 후에 rollback하지만 @SpringBootTest와 RANDOM_PORT는 그렇지 않습니다. 컨테이너를 공유할 때 이전 클래스의 데이터가 여전히 데이터베이스에 있습니다. @BeforeEach에서 데이터를 정리하거나 @Sql을 사용하여 TRUNCATE 스크립트를 실행합니다. 테스트 실행 순서에 의존하는 테스트를 작성하지 마세요.
5.4. CI 환경에서 Docker가 없는 경우
Docker가 실행 중이 아니면 테스트는 오류를 보고합니다:
java.lang.IllegalStateException: Could not find a valid Docker environment.
CI runner에 Docker가 있는지 확인해야 합니다. Docker가 없을 때 fail하는 대신 테스트를 skip하려면 @Testcontainers(disabledWithoutDocker = true)를 사용합니다. 이 옵션을 활성화하기 전에 신중히 고려하세요. skip된 테스트는 조용히 무시되기 쉽기 때문입니다.
5.5. 로컬에서 reuse로 속도 높이기
로컬에서 테스트 실행 간에 컨테이너를 계속 실행 중으로 유지하려면 ~/.testcontainers.properties 파일에서 reuse를 활성화합니다:
testcontainers.reuse.enable=true
그 후 컨테이너를 생성할 때 .withReuse(true)를 호출합니다. CI에서 이 옵션을 활성화하지 마세요. 컨테이너가 자동으로 정리되지 않기 때문입니다.
6. Testcontainers를 사용하지 말아야 할 때
Testcontainers는 테스트를 더 신뢰할 수 있게 하지만 각 테스트가 더 느리고 Docker가 필요합니다. 사용하면 안 되는 경우도 있습니다:
- 순수 로직 - 계산, 유효성 검사 또는 매핑: mock이나 실제 객체를 사용한 단위 테스트로 충분합니다. 단위 테스트는 수백 배 빠릅니다.
- 각 테스트 클래스가 다른 context 구성을 가지고 있는 경우: 각 구성이 새 context를 생성합니다. 컨테이너가 context에 연결되어 있으면 여러 컨테이너가 병렬로 실행됩니다. 테스트를 몇 가지 공통 구성으로 통합하세요.
- HTTP를 통한 타사 서비스: 일반적으로 실제 서비스를 컨테이너에서 실행하려고 하는 대신 WireMock을 사용하는 것이 좋습니다.
합리적인 비율은: 대부분의 경우 단위 테스트이고, Testcontainers를 사용한 통합 테스트는 repository, native query, migration, message broker 등 인프라에 영향을 미치는 부분에 집중해야 합니다.
7. 결론
이 글에서는 Testcontainers를 사용하여 PostgreSQL에서 통합 테스트를 실행했습니다. @ServiceConnection은 수동 datasource 구성을 완전히 제거할 수 있게 합니다. @TestConfiguration에서 컨테이너를 bean으로 설정하면 여러 테스트 클래스 간에 하나의 컨테이너를 공유할 수 있을 뿐만 아니라 spring-boot:test-run을 사용하여 로컬에서 애플리케이션을 실행할 수 있습니다.
기억해야 할 사항:
- 프로덕션과 일치하도록 이미지 버전을 고정합니다.
- static
@Container을 Spring의 context cache와 섞지 마세요. - 테스트가 트랜잭션 내에서 실행되지 않을 때 데이터를 정리합니다.
- 실제로 데이터베이스가 필요한 부분에서만 Testcontainers를 사용합니다.