Spring Test Container로 안전하게 테스트하기

치현·2025년 6월 3일

Spring Boot

목록 보기
4/4
post-thumbnail

Test Container

Testcontainers is a Java library that supports JUnit tests, providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container.

테스트를 위해 도커 컨테이너를 띄워주는 자바 라이브러리이다.

컨테이너를 자동으로 띄우고, 종료한다. 즉 기존 사용 중인 서비스의 연결 정보를 가로채서 안전하게 테스트를 할 수 있도록 도와주는 프록시 역할을 한다.



도입한 이유

테스트는 멱등성 이 가장 중요하다. 각 테스트마다 같은 환경에서 수행되어야하고 서로 간섭이 있어서는 안된다.

멱등성을 만족시킬 수 있는 여러가지 방법이 있다.

1) 사용 중인 서비스를 복제해 분산 환경에서 테스트

2) 인메모리 데이터베이스 사용

3) 배포 환경과 동일한 docker-compose 를 사용해 환경 구성

4) 테스트 컨테이너 사용


1번의 경우, 비용 문제와 휴먼 에러가 발생할 수 있어 제외했다.

2번의 경우, 인메모리를 사용해 엘라스틱 서치 엔진을 구동시키는 것에 무리가 있었다. 그리고 인메모리 데이터베이스는 h2 DB 를 사용하게 되는데, MySQL에 Specific한 테스트가 불가능하다는 단점이 있었다.

3번의 경우, 임의로 포트를 yml 파일에 명시해야하고 서버 구성원 중 누군가 같은 포트를 사용할 여지가 있기 때문에 에러가 발생할 여지가 있었다. 그리고 컨테이너를 직접 관리해야하는 단점이 있었다.

4번의 경우, 테스트 실행 시 컨테이너가 자동으로 시작되고 완료되면 종료된다. 그리고 구성 정보를 코드 레벨에서 설정할 수 있고 컨테이너 재사용 설정도 할 수 있다. 최종적으로 테스트 컨테이너를 택했다.


Test Container 사용 설정


dependencies {
    ... 다른 의존성
    // Test Containers
+   testImplementation 'org.testcontainers:junit-jupiter'
+   testImplementation 'org.testcontainers:elasticsearch'
+   testImplementation 'org.testcontainers:mysql'
}

간단하게 의존성을 추가하면 된다.


Elastic Search Test Container 추가

1️⃣ 테스트 연결 정보 추가


@TestConfiguration // 테스트 구성 정보
@EnableElasticsearchRepositories(basePackages = "org.sopt.confeti.domain.elastic_search.infra") // 실제 운영에 사용되는 엘라스틱 서치 레포지토리 (document 정보)를 사용한다고 명시한다.
public class ElasticsearchTestConfiguration extends ElasticsearchConfiguration {

    @Bean
    @Primary
    @Override
    public ClientConfiguration clientConfiguration() {
        // 컨테이너가 실행 중인지 확인
        if (!APIBaseTest.elasticsearchContainer.isRunning()) {
            throw new IllegalStateException("Elasticsearch container is not running!");
        }

        // BaseControllerTest의 elasticsearchContainer 참조
        // 테스트 컨테이너 호스트 정보를 가져와서 연결한다.
        String httpHostAddress = APIBaseTest.elasticsearchContainer.getHttpHostAddress();

        return ClientConfiguration.builder()
                .connectedTo(httpHostAddress)
                .withConnectTimeout(30000)
                .withSocketTimeout(60000)
                .build();
    }
}

먼저 테스트에 사용될 엘라스틱 서치 컨테이너 연결의 구성 정보를 추가한다.


그리고, 운영에서 사용했던 Index 매핑 설정 파일과 동일한 파일이 test/resources/elasticsearch 폴더 내에 있어야한다.

ex) performance-settings.json
ex) search-term-settings.json


2️⃣ 테스트 컨테이너 생성


public final class SharedTestContainers {

    private static final String ELASTICSEARCH_IMAGE = "docker.elastic.co/elasticsearch/elasticsearch:9.0.1";

    private static final int STARTUP_TIMEOUT = 120;

    public static final ElasticsearchContainer ELASTICSEARCH_CONTAINER =
            new ElasticsearchContainer(ELASTICSEARCH_IMAGE)
                    .withEnv("discovery.type", "single-node") // 마스터 노드 설정
                    .withEnv("xpack.security.enabled", "false") // SSL 비활성화
                    .withEnv("ES_JAVA_OPTS", "-Xms512m -Xmx512m")
                    .withCommand("sh", "-c",
                            "elasticsearch-plugin install analysis-nori --batch && " +
                                    "/usr/local/bin/docker-entrypoint.sh eswrapper") // 한글 형태소 분석을 사용하므로 플러그인 설치
                    .withReuse(true) // 컨테이너 재사용
                    .withStartupTimeout(Duration.ofSeconds(STARTUP_TIMEOUT));

    static {
        ELASTICSEARCH_CONTAINER.start(); // 컨테이너 실행
    }
}

테스트 컨테이너를 응집성있게 관리하기 위해 따로 클래스를 작성했다.

3️⃣ 동적 구성 정보 추가


@DynamicPropertySource
public static void configureProperties(DynamicPropertyRegistry registry) {
    // elastic search
    registry.add("spring.data.elasticsearch.uris", elasticsearchContainer::getHttpHostAddress);
    registry.add("spring.elasticsearch.uris", elasticsearchContainer::getHttpHostAddress);
}

elasticsearchContainer::getHttpHostAddress 에서는 도커 컨테이너 이름과 랜덤으로 지정된 포트를 결합해 로컬에서 접근 가능한 URI가 반환된다.


4️⃣ 멱등성을 고려해 기초 데이터 세팅


@BeforeEachvoid setUp() {
    // insert search term test data to es
    createSearchTermTestIndex();
    insertSearchTermTestDataES();

    // insert performance test data to es
    createPerformanceTestIndex();
    insertPerformanceTestDataES();
}

기본적으로 Spring Rest Docs는 블랙박스 API 테스트를 하기 때문에 어떤 데이터가 어떻게 변했는지 알 수 없다. 그래서 @Rollback 기능도 동작하지 않는 것이다. (사실 애초에 API 테스트 대상 서버가 따로 띄워진다.)

위와 같이 인덱스를 삭제하고 재생성한 뒤 데이터를 매번 생성하는 것은 테스트의 특징 때문이다.



MySQL Test Container 추가

1️⃣ 테스트 연결 정보 추가


# src/test/resources/application-test.ymlspring:
  jpa:
    hibernate:
      ddl-auto: create-drop
    properties:
      hibernate:
        hbm2ddl:
          auto: create-drop
        jdbc:
          batch_size: 20
        order_inserts: true
        order_updates: true
    database-platform: org.hibernate.dialect.MySQLDialect
    show-sql: true

  datasource:
    hikari:
      maximum-pool-size: 5
      connection-timeout: 20000

# 테스트 전용 로깅 설정
logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.orm.jdbc.bind: TRACE
    org.springframework.test: DEBUG

application-test.yml 파일에 데이터베이스 연결 정보를 작성했다.


2️⃣ 테스트 컨테이너 생성


public final class SharedTestContainers {

    private static final String MYSQL_IMAGE = "mysql:8.0";

    private static final int STARTUP_TIMEOUT = 120;

    public static final MySQLContainer<?> MYSQL_CONTAINER = new MySQLContainer<>(MYSQL_IMAGE)
            .withDatabaseName("confeti_test") // 임의로 설정
            .withUsername("test") // 임의로 설정
            .withPassword("test123") // 임의로 설정
            .withEnv("MYSQL_ROOT_PASSWORD", "root_password") // 임의로 설정
            .withUrlParam("tc", "proxy")
            .withCommand("--character-set-server=utf8mb4",
                    "--collation-server=utf8mb4_unicode_ci",
                    "--sql-mode=STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION")
            .withReuse(true) // 컨테이너 재사용
            .withStartupTimeout(Duration.ofSeconds(STARTUP_TIMEOUT));

    static {
        MYSQL_CONTAINER.start();
    }
}

3️⃣ 동작 구성 정보 추가


@DynamicPropertySource
public static void configureProperties(DynamicPropertyRegistry registry) {
    // mysql
    registry.add("spring.datasource.url", mySQLContainer::getJdbcUrl);
    registry.add("spring.datasource.username", mySQLContainer::getUsername);
    registry.add("spring.datasource.password", mySQLContainer::getPassword);
    registry.add("spring.datasource.driver-class-name", () -> "com.mysql.cj.jdbc.Driver");
}

4️⃣ 멱등성을 고려해 기초 데이터 세팅


@BeforeEachvoid setUp() {
    // insert concert test data to mysql
    insertConcertTestData();

    // insert festival test data to mysql
    insertFestivalTestData();

    // insert performance test data to mysql
    insertPerformanceTestData();

    // insert user test data to mysql
    insertUserTestData();

    // generate Access Token
    generateAccessToken();
}

@AfterEachvoid resetDatabase() {
    try {
        initializeAllTableData();
    } catch (Exception e) {
        e.printStackTrace();
    }
}

/**
     * mysql의 모든 데이터를 삭제하는 함수
     */
    private void initializeAllTableData() {
        try (Connection connection = dataSource.getConnection()) {
            try (Statement statement = connection.createStatement()) {

                // 외래키 제약 해제
                statement.execute("SET FOREIGN_KEY_CHECKS = 0");

                // 모든 테이블 조회
                List<String> tableNames = getAllTableNames(connection);

                // 모든 테이블 데이터 삭제
                for (String tableName : tableNames) {
                    try {
                        statement.execute("DELETE FROM " + tableName);
                        System.out.println("테이블 데이터 삭제: " + tableName);
                    } catch (Exception e) {
                        log.warn("테이블 초기화 스킵 : {}", tableName);
                    }
                }

                // AUTO_INCREMENT 값 초기화
                for (String tableName : tableNames) {
                    try {
                        statement.execute("ALTER TABLE " + tableName + " AUTO_INCREMENT = 1");
                    } catch (Exception e) {
                        log.warn("AUTO_INCREMENT 초기화 스킵 : {}", tableName);
                    }
                }

                // 4. 외래키 제약 복원
                statement.execute("SET FOREIGN_KEY_CHECKS = 1");

                log.info("=== 모든 테이블 데이터 초기화 완료 ===");
            }
        } catch (Exception e) {
            log.error("테이블 데이터 초기화 실패");
        }
    }

    // 모든 테이블 이름 정보를 가져오는 메서드
    private List<String> getAllTableNames(Connection connection) throws Exception {
        List<String> tableNames = new ArrayList<>();
        ResultSet tables = connection.getMetaData().getTables(
                connection.getCatalog(), null, "%", new String[]{"TABLE"}
        );

        while (tables.next()) {
            String tableName = tables.getString("TABLE_NAME");
            tableNames.add(tableName);
        }

        return tableNames;
    }

어떤 데이터가 바뀌었는지 모르기 때문에 데이터를 전부 제거 후 다시 생성한다.


Redis Test Container 추가

1️⃣ 테스트 컨테이너 생성


public final class SharedTestContainers {

    private static final String REDIS_IMAGE = "redis:latest";

    public static final int REDIS_PORT = 6379;
    private static final int STARTUP_TIMEOUT = 120;

    public static final GenericContainer<?> REDIS_CONTAINER = new GenericContainer<>(REDIS_IMAGE)
            .withExposedPorts(REDIS_PORT)
            .waitingFor(Wait.forListeningPort())
            .withStartupTimeout(Duration.ofSeconds(STARTUP_TIMEOUT))
            .withReuse(true);

    static {
        REDIS_CONTAINER.start();
    }

2️⃣ 동작 구성 정보 추가


    @DynamicPropertySource
    public static void configureProperties(DynamicPropertyRegistry registry) {
        // redis
        registry.add("spring.data.redis.host", redisContainer::getHost);
        registry.add("spring.data.redis.port",
                () -> redisContainer.getMappedPort(SharedTestContainers.REDIS_PORT).toString());
    }

3️⃣ 멱등성을 고려해 기초 데이터 세팅


    @BeforeEach
    void setUp() {
        // clear redis
        clearRedisData();
    }

    /**
     * 레디스의 모든 데이터를 삭제하는 함수
     */
    private void clearRedisData() {
        try {
            redisTemplate.getConnectionFactory().getConnection().flushAll();
            log.info("=== Redis 모든 데이터 초기화 완료 ===");
        } catch (Exception e) {
            log.error("Redis 데이터 초기화 실패: {}", e.getMessage());
        }
    }

재사용 설정

프로젝트 내부에 .testcontainers.properties 파일을 생성한다.

그리고 testcontainers.reuse.enable=true 를 추가한다.


여기까지 테스트 컨테이너를 사용해 테스트를 하고 컨테이너를 각 테스트마다 재사용하는 방법을 알아봤다. 테스트를 실행하면 컨테이너가 생겼다가 사라지는 것을 볼 수 있을 것이다.


엘라스틱 서치 테스트 컨테이너에 관한 레퍼런스가 아에 없는 수준이었는데, 누군가에게 이 글이 도움이 되었으면 좋겠다.


자세한 코드는 아래의 레포지토리를 확인하면 된다.
https://github.com/team-confeti/confeti-server/tree/develop/src/test/java/org/sopt/confeti/restdocs/base


출처

profile
Backend Engineer

0개의 댓글