Spring Testcontainers 통합 테스트

Testcontainers란?

Testcontainers는 테스트 실행 시 Docker 컨테이너로 실제 인프라를 띄워주는 라이브러리다. H2 같은 인메모리 DB 대신 실제 MySQL, PostgreSQL, Redis, Kafka를 컨테이너로 구동해서 테스트한다. “로컬에서 되는데 서버에서 안 돼요” 문제를 근본적으로 해결한다.

Spring Boot 3.1부터 Testcontainers를 1급 시민(First-class citizen)으로 지원한다. @ServiceConnection 어노테이션 하나로 컨테이너와 Spring 프로퍼티를 자동 연결하고, @TestConfiguration으로 로컬 개발 환경까지 재사용할 수 있다.

의존성 설정

// build.gradle.kts
dependencies {
    // Testcontainers BOM
    testImplementation(platform("org.testcontainers:testcontainers-bom:1.20.4"))
    testImplementation("org.testcontainers:junit-jupiter")
    testImplementation("org.testcontainers:mysql")
    testImplementation("org.testcontainers:postgresql")

    // Spring Boot Testcontainers 통합
    testImplementation("org.springframework.boot:spring-boot-testcontainers")
    testImplementation("org.springframework.boot:spring-boot-starter-test")

    // Redis, Kafka 등 추가 모듈
    testImplementation("org.testcontainers:kafka")
    testImplementation("com.redis:testcontainers-redis:2.2.2")
}

@ServiceConnection: 자동 프로퍼티 바인딩

Spring Boot 3.1 이전에는 @DynamicPropertySource로 컨테이너의 호스트/포트를 수동 매핑했다. @ServiceConnection은 이를 자동화한다.

Before: @DynamicPropertySource (수동)

@Testcontainers
@SpringBootTest
class OrderRepositoryTest {

    companion object {
        @Container
        val mysql = MySQLContainer("mysql:8.0")
            .withDatabaseName("testdb")

        @JvmStatic
        @DynamicPropertySource
        fun properties(registry: DynamicPropertyRegistry) {
            // 수동으로 매핑해야 함
            registry.add("spring.datasource.url") { mysql.jdbcUrl }
            registry.add("spring.datasource.username") { mysql.username }
            registry.add("spring.datasource.password") { mysql.password }
        }
    }
}

After: @ServiceConnection (자동)

@Testcontainers
@SpringBootTest
class OrderRepositoryTest {

    companion object {
        @Container
        @ServiceConnection  // URL, username, password 자동 바인딩!
        val mysql = MySQLContainer("mysql:8.0")
            .withDatabaseName("testdb")
            .withReuse(true)  // 컨테이너 재사용으로 속도 향상
    }

    @Autowired
    lateinit var orderRepository: OrderRepository

    @Test
    fun `주문 저장 후 조회`() {
        val order = Order(product = "MacBook", amount = 2_500_000)
        orderRepository.save(order)

        val found = orderRepository.findById(order.id!!)
        assertThat(found).isPresent
        assertThat(found.get().product).isEqualTo("MacBook")
    }
}

@ServiceConnection이 지원하는 컨테이너 타입:

컨테이너 자동 설정되는 프로퍼티
MySQLContainer spring.datasource.url/username/password
PostgreSQLContainer spring.datasource.url/username/password
RedisContainer spring.data.redis.host/port
KafkaContainer spring.kafka.bootstrap-servers
MongoDBContainer spring.data.mongodb.uri
RabbitMQContainer spring.rabbitmq.host/port/username/password

@TestConfiguration으로 로컬 개발 환경 구성

테스트용 컨테이너를 로컬 개발 시에도 재사용할 수 있다. spring-boot-devtools와 결합하면 별도 Docker Compose 없이 개발 환경을 구성할 수 있다.

// src/test/java 아래에 위치
@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfig {

    @Bean
    @ServiceConnection
    fun mysqlContainer(): MySQLContainer<*> {
        return MySQLContainer("mysql:8.0")
            .withDatabaseName("devdb")
            .withReuse(true)  // 앱 재시작해도 컨테이너 유지
    }

    @Bean
    @ServiceConnection
    fun redisContainer(): GenericContainer<*> {
        return GenericContainer("redis:7-alpine")
            .withExposedPorts(6379)
            .withReuse(true)
    }

    @Bean
    @ServiceConnection
    fun kafkaContainer(): KafkaContainer {
        return KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0"))
            .withReuse(true)
    }
}

로컬 개발 시 실행

// TestApplication.kt — 로컬 개발용 엔트리포인트
fun main(args: Array<String>) {
    fromApplication<Application>()
        .with(TestcontainersConfig::class.java)  // 테스트 설정 포함
        .run(*args)
}

// 실행: ./gradlew bootTestRun
// 또는 IDE에서 TestApplication.kt 직접 실행

멀티 컨테이너 통합 테스트

실전에서는 DB + Redis + Kafka를 동시에 사용하는 경우가 많다. 여러 컨테이너를 조합한 통합 테스트 구성이다.

@Testcontainers
@SpringBootTest
@ActiveProfiles("test")
class OrderIntegrationTest {

    companion object {
        @Container
        @ServiceConnection
        val postgres = PostgreSQLContainer("postgres:16-alpine")
            .withDatabaseName("orderdb")
            .withInitScript("schema.sql")  // 초기 스키마 로드

        @Container
        @ServiceConnection
        val redis = GenericContainer("redis:7-alpine")
            .withExposedPorts(6379)

        @Container
        @ServiceConnection
        val kafka = KafkaContainer(
            DockerImageName.parse("confluentinc/cp-kafka:7.6.0")
        )
    }

    @Autowired lateinit var orderService: OrderService
    @Autowired lateinit var redisTemplate: StringRedisTemplate
    @Autowired lateinit var kafkaTemplate: KafkaTemplate<String, String>

    @Test
    fun `주문 생성 시 캐시 저장 + 이벤트 발행`() {
        // Given
        val request = CreateOrderRequest("MacBook", 1, 2_500_000)

        // When
        val order = orderService.createOrder(request)

        // Then - DB 저장 확인
        val saved = orderService.getOrder(order.id)
        assertThat(saved.product).isEqualTo("MacBook")

        // Then - Redis 캐시 확인
        val cached = redisTemplate.opsForValue()
            .get("order:${order.id}")
        assertThat(cached).isNotNull()

        // Then - Kafka 이벤트 발행 확인
        await().atMost(5, TimeUnit.SECONDS).untilAsserted {
            // Consumer에서 이벤트 수신 확인
            verify(orderEventListener).onOrderCreated(any())
        }
    }
}

컨테이너 재사용: 테스트 속도 최적화

Testcontainers의 가장 큰 단점은 컨테이너 시작 시간이다. MySQL은 약 10초, Kafka는 15초 이상 걸린다. withReuse(true)싱글톤 패턴으로 이를 최적화한다.

전략 1: withReuse(true)

// ~/.testcontainers.properties 파일에 추가 (필수!)
testcontainers.reuse.enable=true

// 컨테이너에 withReuse(true) 설정
val mysql = MySQLContainer("mysql:8.0")
    .withReuse(true)  // 테스트 종료 후에도 컨테이너 유지
    // 다음 테스트 실행 시 기존 컨테이너 재사용

전략 2: 싱글톤 컨테이너 패턴

// 모든 테스트가 공유하는 추상 클래스
abstract class IntegrationTestBase {

    companion object {
        // static 블록에서 한 번만 시작
        val postgres: PostgreSQLContainer<*> = PostgreSQLContainer("postgres:16-alpine")
            .withDatabaseName("testdb")
            .apply { start() }  // 즉시 시작

        val redis: GenericContainer<*> = GenericContainer("redis:7-alpine")
            .withExposedPorts(6379)
            .apply { start() }

        @JvmStatic
        @DynamicPropertySource
        fun configureProperties(registry: DynamicPropertyRegistry) {
            registry.add("spring.datasource.url") { postgres.jdbcUrl }
            registry.add("spring.datasource.username") { postgres.username }
            registry.add("spring.datasource.password") { postgres.password }
            registry.add("spring.data.redis.host") { redis.host }
            registry.add("spring.data.redis.port") { redis.firstMappedPort }
        }
    }
}

// 개별 테스트에서 상속
@SpringBootTest
class UserServiceTest : IntegrationTestBase() {
    // postgres, redis 컨테이너가 이미 실행 중
}

@SpringBootTest
class OrderServiceTest : IntegrationTestBase() {
    // 같은 컨테이너 재사용 → 시작 시간 0초
}

전략 3: @TestConfiguration + @Bean (Spring Boot 3.1+ 권장)

@TestConfiguration(proxyBeanMethods = false)
class SharedContainerConfig {

    @Bean
    @ServiceConnection
    @RestartScope  // DevTools 재시작 시에도 컨테이너 유지
    fun postgres(): PostgreSQLContainer<*> {
        return PostgreSQLContainer("postgres:16-alpine")
            .withReuse(true)
    }
}

// @Import로 가져다 쓰기
@SpringBootTest
@Import(SharedContainerConfig::class)
class PaymentServiceTest { ... }

테스트 데이터 격리 전략

컨테이너를 공유하면 테스트 간 데이터 오염 문제가 발생한다. 3가지 격리 전략을 비교한다.

전략 1: @Transactional 롤백 (권장)

@SpringBootTest
@Transactional  // 각 테스트 후 자동 롤백
class OrderRepositoryTest : IntegrationTestBase() {

    @Test
    fun `주문 저장`() {
        // 테스트 데이터는 테스트 끝나면 롤백됨
        orderRepository.save(Order("test", 1000))
    }
}

전략 2: @Sql로 매 테스트 초기화

@SpringBootTest
@Sql(
    statements = ["DELETE FROM orders", "DELETE FROM users"],
    executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD
)
class OrderServiceTest : IntegrationTestBase() { ... }

전략 3: TestExecutionListener로 테이블 초기화

class DatabaseCleanupListener : TestExecutionListener {

    override fun beforeTestMethod(testContext: TestContext) {
        val dataSource = testContext.applicationContext
            .getBean(DataSource::class.java)

        JdbcTemplate(dataSource).execute("""
            SET REFERENTIAL_INTEGRITY FALSE;
            TRUNCATE TABLE orders;
            TRUNCATE TABLE users;
            SET REFERENTIAL_INTEGRITY TRUE;
        """)
    }
}

@SpringBootTest
@TestExecutionListeners(
    listeners = [DatabaseCleanupListener::class],
    mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS
)
class CleanTest : IntegrationTestBase() { ... }

CI/CD 환경 설정: GitHub Actions

# .github/workflows/test.yml
name: Integration Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: 21

      # Docker가 이미 설치되어 있으므로 바로 실행 가능
      - name: Run Tests
        run: ./gradlew test
        env:
          TESTCONTAINERS_RYUK_DISABLED: true  # CI에서 Ryuk 비활성화
          DOCKER_HOST: unix:///var/run/docker.sock

커스텀 컨테이너: GenericContainer 활용

// 공식 모듈이 없는 서비스도 GenericContainer로 구성
@Container
val elasticsearch = GenericContainer("elasticsearch:8.12.0")
    .withExposedPorts(9200)
    .withEnv("discovery.type", "single-node")
    .withEnv("xpack.security.enabled", "false")
    .waitingFor(Wait.forHttp("/").forStatusCode(200))

// LocalStack으로 AWS 서비스 테스트
@Container
val localstack = GenericContainer("localstack/localstack:3.0")
    .withExposedPorts(4566)
    .withEnv("SERVICES", "s3,sqs,dynamodb")
    .waitingFor(Wait.forLogMessage(".*Ready.*", 1))

Testcontainers는 “테스트 환경과 운영 환경의 차이”라는 근본 문제를 해결한다. H2로 테스트하고 MySQL로 배포하는 시대는 끝났다. 실제 DB에서 테스트하고, 실제 메시지 브로커로 검증하라. @ServiceConnectionwithReuse(true)를 조합하면 개발 생산성을 잃지 않으면서도 테스트 신뢰도를 극적으로 높일 수 있다.

관련 글: NestJS Testing 실전 가이드 | GitHub Actions CI/CD 심화

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux