이번에 테스트 환경을 보다가 찝찝한 지점이 있었다.
운영은 PostgreSQL을 쓰고 있는데, 테스트는 H2의 PostgreSQL mode로 돌고 있었다.
처음에는 PostgreSQL mode면 어느 정도 비슷하지 않나?라고 생각할 수 있다.
그런데 Repository 테스트나 JPA 매핑 테스트에서는 이 차이가 생각보다 크다.
H2, PostgreSQL 예약어를 둘다 피해야 하고, 같은 환경이 아니므로 테스트 시 예외가 생길 수도 있는 것이다.
H2 PostgreSQL mode는 PostgreSQL을 완전히 재현하지 않는다.
PostgreSQL처럼 보이게 일부 문법을 맞춰주는 것에 가깝다.
그래서 다음 같은 부분에서 차이가 생길 수 있다.
@SQLRestriction 기반 소프트 삭제 필터@MapsId 공유 PK 매핑예를 들어 이런 native query가 있다고 하자.
SELECT COUNT(*)
FROM users
WHERE user_id = ?
H2에서는 통과했는데 PostgreSQL에서는 식별자, 타입, 문법 차이 때문에 실패할 수 있다.
이러면 테스트는 통과인데 운영에서 깨지는 상황이 생긴다.
그래서 테스트 DB도 운영과 같은 PostgreSQL 16으로 맞추기로 했다.
이번에는 Testcontainers의 JDBC URL 방식을 썼다.
spring:
datasource:
url: jdbc:tc:postgresql:16:///testdb
username: test
password: test
test:
database:
replace: none
jdbc:tc:postgresql:16:///testdb는 Testcontainers가 제공하는 특수한 JDBC URL이다.
애플리케이션이 이 datasource로 연결하려고 하면 Testcontainers가 PostgreSQL 컨테이너를 띄워준다.
이 방식의 장점은 설정이 가볍다는 것이다.
dependencies {
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.testcontainers:postgresql")
}
컨테이너를 직접 생성하는 테스트 설정 클래스를 만들 필요가 없다.
지금처럼 PostgreSQL 하나만 필요한 상황에서는 가장 단순하다.
놓치기 쉬운 부분이 있다.
application-test.yml에 Testcontainers datasource를 잡아도, 테스트가 test profile로 실행되지 않으면 해당 설정을 읽지 않는다.
그래서 Repository 테스트나 통합 테스트에 profile을 명시했다.
@DataJpaTest
@ActiveProfiles("test")
class UserRepositoryTest {
// ...
}
통합 테스트도 마찬가지다.
@SpringBootTest
@ActiveProfiles("test")
class HealthControllerTests {
// ...
}
그리고 @DataJpaTest 같은 slice test에서는 Spring Boot가 datasource를 임베디드 DB로 바꾸려고 할 수 있다.
그래서 다음 설정도 같이 둔다.
spring:
test:
database:
replace: none
이 설정이 없으면 열심히 Testcontainers datasource를 잡아놓고도 테스트에서는 다른 DB를 볼 수 있다.
Spring Boot에서는 @ServiceConnection으로 Testcontainers 연결 정보를 자동 등록할 수도 있다.
@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfig {
@Bean
@ServiceConnection
fun postgresContainer(): PostgreSQLContainer<*> =
PostgreSQLContainer("postgres:16")
}
이 방식은 컨테이너를 코드로 직접 다룬다.
그래서 다음 상황에서는 JDBC URL보다 더 낫다.
즉 이번 선택 기준은 이렇게 잡았다.
| 상황 | 선택 |
|---|---|
| PostgreSQL 하나만 필요하다 | jdbc:tc: URL |
| 여러 컨테이너가 필요하다 | @ServiceConnection |
| Spring Boot 지원 범위를 벗어난 세부 설정이 필요하다 | @DynamicPropertySource |
@ServiceConnection을 쓰기 어려운 경우에는 @DynamicPropertySource로 직접 datasource property를 넣을 수도 있다.
@Testcontainers
@SpringBootTest
class UserIntegrationTest {
companion object {
@Container
val postgres = PostgreSQLContainer("postgres:16")
@JvmStatic
@DynamicPropertySource
fun properties(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl)
registry.add("spring.datasource.username", postgres::getUsername)
registry.add("spring.datasource.password", postgres::getPassword)
}
}
}
Testcontainers를 쓰면 분명 H2보다 느리다.
첫 실행 때는 Docker 이미지 pull이 필요할 수 있고, 컨테이너 기동 시간도 있다.
또 로컬이나 CI에 Docker가 없으면 테스트가 돌지 않는다.
하지만 DB 통합 테스트의 목적이 운영 DB와의 정합성 검증이라면 이 비용은 낼 만하다.
정리하면 이렇다.
| 항목 | 얻은 것 | 감수한 것 |
|---|---|---|
| 정합성 | 운영과 같은 PostgreSQL에서 테스트 | H2보다 무거움 |
| 속도 | JVM 내 컨테이너 공유로 비용 분산 가능 | 첫 컨테이너 기동 수 초 |
| 환경 | 로컬과 CI의 DB 조건 통일 | Docker 필수 |
| 설정 | JDBC URL은 코드 0줄 | DB 단독일 때 가장 적합 |
| 확장성 | 지금 단계에서는 충분 | Redis 등 추가 시 방식 전환 필요 |
GitHub Actions의 ubuntu-latest runner는 Docker를 사용할 수 있다.
그래서 일반적인 Testcontainers 기반 테스트는 별도의 PostgreSQL service를 workflow에 띄우지 않아도 동작한다.
물론 Docker가 필요한 테스트가 된다는 점은 명확히 해야 한다.
로컬 개발자 환경이나 CI runner가 Docker를 지원하지 않으면 테스트가 실패한다.
이번 도입의 결론은 단순하다.
jdbc:tc:postgresql:16:///testdb는 단일 DB 컨테이너 테스트에 가장 가볍다.@DataJpaTest에서는 spring.test.database.replace: none을 확인해야 한다.application-test.yml을 쓰려면 @ActiveProfiles("test")가 필요하다.@ServiceConnection이나 @DynamicPropertySource로 넘어가는 게 낫다.테스트 속도만 보면 H2가 더 좋다.
하지만 운영에서 깨질 가능성을 줄이는 테스트라면, 조금 느려도 실제 DB에서 검증하는 쪽이 더 낫다고 판단해서 도입하게 되었다.
https://java.testcontainers.org/modules/databases/jdbc/
https://java.testcontainers.org/modules/databases/postgres/
https://docs.spring.io/spring-boot/reference/testing/testcontainers.html
https://docs.spring.io/spring-boot/api/java/org/springframework/boot/test/autoconfigure/jdbc/AutoConfigureTestDatabase.Replace.html