Testcontainers

라모스·2024년 3월 5일
post-thumbnail

일반적인 테스트의 한계?

보통 일반적인 테스트는 다음과 같은 방식으로 진행할 것이다.

  • Local: 로컬에 설치 및 환경 구축 후 테스트에 사용
    • 테스트 이후 데이터 정리 문제
  • In-Memory: In-Memory DB를 활용하여 테스트 구동 시 사용 (ex. H2 Database, ...)
    • 호환성 문제
    • 데이터 정합성 문제
  • Embedded Library: Library를 사용하여 테스트 구동 시 사용 (ex. Embedded MongoDB, ...)
    • 지원하지 않는 것들이 많음

위 세 가지 방식은 테스트에서 중요한 멱등성이 깨지고 데이터를 계속 clean 해줘야 하는 문제가 있다.

그렇다고 Mocking을 한다면 DB와의 통신을 건너 뛰고 순수한 로직만을 테스트할 순 있지만 실제 환경에서 DB와 연동했을 때 문제가 발생할 가능성이 높다.

Testcontainers?

Testcontainers는 Docker 컨테이너에 래핑된 실제 서비스를 사용하여 로컬 개발 및 테스트 종속성을 부트스트랩하기 위한 쉽고 가벼운 API를 제공하는 라이브러리입니다. Testcontainers를 사용하면 모의 서비스나 메모리 내 서비스 없이 프로덕션에서 사용하는 것과 동일한 서비스에 의존하는 테스트를 작성할 수 있습니다.
(공식 reference 번역)

Testcontainers를 사용하면, 도커 컨테이너를 Java 코드로 특정 도커 이미지를 실행하고 끌 수 있다. Test 환경에 도입하면 In-Memory DB로 테스트 하는 것이 아닌 실제 운영 DB와 동일한 환경을 외부 인스턴스에 띄우지 않고 로컬 도커 컨테이너에서 테스트할 때마다 DB 컨테이너를 띄우고 테스트 종료 시 컨테이너를 내리는 작업을 자동화 할 수 있다.

장단점

장점

  • 인메모리 DB를 사용하지 않고 실제 운영 DB와 동일한 환경에서 테스트할 수 있다.
  • CI/CD 환경에서 테스트할 때 별도의 외부 인스턴스를 띄우지 않고 도커 컨테이너를 손쉽게 띄우고 내릴 수 있다.
  • 별도의 도커 컨테이너 관리를 하지 않아도 테스트마다 독립적으로 사용할 수 있다
  • 특정 DB 뿐만 아니라 Nginx, Elasticsearch, 커스텀한 도커 이미지에도 적용할 수 있다.

단점

  • 테스트 코드 작성 비용이 증가한다.
  • 테스트 코드 실행 속도가 느리다. (테스트 실행 시 도커를 띄우고 내린다.)
  • 작업 속도가 저하된다.

Testcontainers Lifecycle

  • restarted
    • test method 수행이 될 때마다 Container가 새로 시작하는 방식이다.
    • method마다 Container가 수행되기 때문에 멱등성을 보장한 테스트가 가능하다.
    • method가 많을수록 Container의 start, stop의 반복이 많아 테스트 수행 시간이 오래 걸릴 수 있다.
  • shared
    • test class scope 안에서 method 가 수행될 때 Container가 한 번만 동작해 공유한다.
    • method마다 수행되지 않고 공유하기 때문에 데이터 관리가 어느 정도 필요하다.
    • method마다 수행되지 않기 때문에 테스트 수행 시간을 줄일 수 있다.

설정

먼저 build.gradle.kts에 의존성을 추가한다.

// 중략
dependencies {
	// testcontainers
	testImplementation ("org.springframework.boot:spring-boot-testcontainers")
	testImplementation ("org.testcontainers:mysql:1.19.6")
	testImplementation ("org.testcontainers:junit-jupiter")
}

테스트 모듈에 TestContainer를 위한 설정을 다음과 같이 작성한다.
공식 문서의 내용과는 다르게, Spring과 integration 하기 위해 직접 생명 주기를 제어하는 방식으로 처리한다.

이렇게 처리하면 테스트 클래스 생성 마다 abstract class 매번 상속받거나 매번 container를 선언할 필요 없이 test의 생명 주기와 일치시킬 수 있다.

package me.ramos.guide.config

import jakarta.annotation.PreDestroy
import org.springframework.boot.test.context.TestConfiguration
import org.testcontainers.containers.MySQLContainer
import org.testcontainers.junit.jupiter.Container

@TestConfiguration("TestMySQLContainer")
class TestMySQLContainer {

    @PreDestroy
    fun stop() {
        container.stop()
    }

    companion object {
        @Container
        @JvmStatic
        val container = MySQLContainer<Nothing>("mysql:8.0.19")
            .apply {
                withDatabaseName("test")
                withUsername("root")
                withPassword("root")
            }
            .apply {
                start()
            }
    }
}

DataSource를 설정하는 부분은 다음과 같다.

package me.ramos.guide.config

import com.zaxxer.hikari.HikariDataSource
import org.springframework.boot.jdbc.DataSourceBuilder
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.DependsOn

@TestConfiguration
class DataSourceConfig {

    @Bean
    // 컨테이너가 생성된 이후 빈이 생성되도록 DependsOn을 적용한다.
    @DependsOn("TestMySQLContainer")
    fun dataSource(): HikariDataSource {
        return DataSourceBuilder.create()
            .type(HikariDataSource::class.java)
            .url(TestMySQLContainer.container.jdbcUrl)
            .username(TestMySQLContainer.container.username)
            .password(TestMySQLContainer.container.password)
            .build()
    }
}
// test/resources/application.yaml
spring:
  datasource:
    url: jdbc:tc:mysql:8.0.19//guide
    driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
  jpa:
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        format_sql: true
        show_sql: true

DataJpaTest에 적용

package me.ramos.guide.repository

import me.ramos.guide.config.DataSourceConfig
import me.ramos.guide.config.TestConfig
import me.ramos.guide.config.TestMySQLContainer
import me.ramos.guide.domain.Team
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager
import org.springframework.context.annotation.Import
import org.springframework.data.repository.findByIdOrNull

@DataJpaTest
@Import(TestConfig::class, TestMySQLContainer::class, DataSourceConfig::class)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
internal class TeamRepositoryTest @Autowired constructor(
    val entityManager: TestEntityManager,
    val teamRepository: TeamRepository
) {

    @Test
    fun findById() {
        val team = Team("Real Madrid C.F", "La Liga")
        entityManager.persist(team)
        entityManager.flush()

        val found = teamRepository.findByIdOrNull(team.id)

        assertThat(found).isEqualTo(team)
        assertThat(found?.name).isEqualTo(team.name)
        assertThat(found?.league).isEqualTo(team.league)
    }
}
  • @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE): H2 데이터베이스 대신 Testcontainer를 적용하도록 변경

참고로, 테스트를 실행 시 반드시 로컬에 Docker engine이 실행중이어야 한다.

profile
블로그 이전 → https://ramos-log.tistory.com/

0개의 댓글