백엔드/Test

Spring Boot에서 Testcontainers로 통합 테스트 환경 구축하기

dami97 2024. 8. 23. 19:16
반응형

 

 

 

 

Testcontainers를 이용한 애플리케이션 통합 테스트 환경 구축

이 글에서는 Testcontainers를 활용하여 애플리케이션 통합 테스트 시 데이터베이스 환경을 어떻게 구성할 수 있는지에 대해 설명합니다.

Testcontainers 공식 문서를 참고하여 현재 버전에 맞게 직접 테스트해보며 작성했습니다.

 

 

서론

테스트 코드를 작성할 때, Docker를 사용해 테스트용 데이터베이스를 직접 띄우거나 H2 메모리 데이터베이스를 활용할 수 있습니다. 하지만 Docker로 테스트용 DB를 직접 실행하고 종료하는 과정은 번거로울 수 있으며, H2 메모리 DB는 설정이 간편하고 테스트 속도가 빠르지만 운영 환경과 다른 DB를 사용할 때 문제가 발생할 가능성이 있습니다. 이는 테스트에서 문제가 없더라도 실제 운영 환경에서 오류가 발생할 수 있음을 의미합니다.

이러한 문제를 해결하기 위해 Testcontainers를 사용할 수 있습니다. Testcontainers는 통합 테스트를 지원하기 위해 개발된 오픈 소스 Java 라이브러리로, Docker 컨테이너를 활용하여 외부 의존성들을 포함한 테스트 환경을 쉽게 구축하고 관리할 수 있습니다.

 


 

Testcontainers의 장점

  • 자동화된 DB 설정: 테스트 실행 시 Testcontainers가 자동으로 데이터베이스를 설정해주기 때문에, 별도의 프로그램이나 스크립트를 실행할 필요가 없습니다.
  • 운영 환경과 유사한 테스트: 실제 운영 환경과 유사한 데이터베이스 환경에서 테스트를 수행할 수 있어, 운영 환경에서 발생할 수 있는 잠재적인 문제를 사전에 발견할 수 있습니다.

 

Testcontainers의 단점

  • 테스트 속도 저하: Docker 컨테이너를 실행하는 데 시간이 걸리기 때문에, 테스트 속도가 다소 느려질 수 있습니다.

 


 

Testcontainers 설정 방법

Testcontainers는 Docker를 활용해 컨테이너를 생성하는 방식이므로, Docker가 설치되어 있어야 합니다. 이번 예제에서는 Spring Boot 3.0.1과 Java 17을 사용합니다.

 

 

첫 번째 방법

1. Testcontainers 의존성 추가

먼저, JUnit을 지원하는 Testcontainers 의존성을 추가해야 합니다. 이 글에서는 현재 최신 버전인 1.20.1을 사용합니다.

Gradle (Kotlin DSL)

testImplementation("org.testcontainers:junit-jupiter:1.20.1")

 

 

2. @Testcontainers 어노테이션 추가

테스트 클래스에 @Testcontainers 어노테이션을 선언하여 Testcontainers를 활성화합니다.

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.testcontainers.junit.jupiter.Testcontainers;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Testcontainers
class SupportLinkServiceTest {
    // 테스트 코드
}

 

 

3. 데이터베이스 모듈 의존성 추가

Testcontainers에서 사용하는 데이터베이스 모듈의 의존성도 추가합니다. 여기서는 MySQL을 예시로 들었습니다.

Gradle (Kotlin DSL)

testImplementation("org.testcontainers:mysql:1.20.1")

 

 

4. MySQLContainer 추가

테스트 클래스에 MySQLContainer를 추가하여 MySQL 데이터베이스 컨테이너를 설정합니다.

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Testcontainers;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Testcontainers
class SupportLinkServiceTest {

    private static final String USERNAME = "username";
    private static final String PASSWORD = "password";
    private static final String DATABASE_NAME = "testdb";

    static MySQLContainer<?> mySQLContainer = new MySQLContainer<>("mysql:8.0")
            .withUsername(USERNAME)
            .withPassword(PASSWORD)
            .withDatabaseName(DATABASE_NAME);
}
  • MySQLContainer는 static으로 선언해야 합니다. 이렇게 하면 해당 테스트 클래스의 여러 테스트 메서드에서 동일한 컨테이너를 사용할 수 있습니다. 만약 static으로 선언하지 않으면, 각 테스트 메서드가 실행될 때마다 새로운 컨테이너가 생성되어 테스트 속도가 굉장히 느려집니다.

 

 

5. 컨테이너의 실행 및 종료

테스트 실행 전 컨테이너를 시작하고, 테스트 종료 후에는 컨테이너를 종료합니다.

import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.AfterAll;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Testcontainers
class SupportLinkServiceTest {

    private static final String USERNAME = "username";
    private static final String PASSWORD = "password";
    private static final String DATABASE_NAME = "testdb";

    static MySQLContainer<?> mySQLContainer = new MySQLContainer<>("mysql:8.0")
            .withUsername(USERNAME)
            .withPassword(PASSWORD)
            .withDatabaseName(DATABASE_NAME);

    @BeforeAll
    static void beforeAll() {
        mySQLContainer.start();
    }

    @AfterAll
    static void afterAll() {
        mySQLContainer.stop();
    }
}

위의 코드를 작성하면, 해당 테스트 클래스의 모든 테스트에서 하나의 컨테이너를 사용할 수 있습니다.

 

 

6. @Container 어노테이션 활용

@BeforeAll과 @AfterAll 메서드를 생략하고 @Container 어노테이션을 사용하면 코드의 중복을 줄일 수 있습니다.

아래의 코드는 위의 5. 컨테이너 실행 및 종료 코드와 동일한 동작입니다.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Testcontainers
class SupportLinkServiceTest {

    private static final String USERNAME = "username";
    private static final String PASSWORD = "password";
    private static final String DATABASE_NAME = "testdb";

    @Container
    static MySQLContainer<?> mySQLContainer = new MySQLContainer<>("mysql:8.0")
            .withUsername(USERNAME)
            .withPassword(PASSWORD)
            .withDatabaseName(DATABASE_NAME);
}

 

 

7. 컨테이너의 URL 확인

컨테이너가 어떤 URL 형태로 생성되는지 궁금하다면 getJdbcUrl() 메서드를 사용해 확인할 수 있습니다.

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Testcontainers
class SupportLinkServiceTest {

    private static final String USERNAME = "username";
    private static final String PASSWORD = "password";
    private static final String DATABASE_NAME = "testdb";

    @Container
    static MySQLContainer<?> mySQLContainer = new MySQLContainer<>("mysql:8.0")
            .withUsername(USERNAME)
            .withPassword(PASSWORD)
            .withDatabaseName(DATABASE_NAME);

    @DisplayName("테스트1")
    @Test
    void 테스트1() {
        System.out.println(mySQLContainer.getJdbcUrl());
    }
}

위의 테스트를 실행하면, 컨테이너의 URL이 출력됩니다.

테스트 실행 시점마다 URL 포트가 달라지므로, 실행할 때마다 확인할 수 있습니다.

예를 들어, 첫 번째 실행 시 jdbc:mysql://localhost:64772/testdb와 같이 출력될 수 있으며, 두 번째 실행 시에는 jdbc:mysql://localhost:64945/testdb와 같이 포트 번호가 달라질 수 있습니다.

하지만 위와 같은 설정만으로는 테스트 실행 시 H2 데이터베이스의 메모리 DB와 연결되는 것을 확인할 수 있습니다.

24-08-23 12:29:44.272 [main] DEBUG [HikariConfig:1132] - driverClassName................."org.h2.Driver"
24-08-23 12:29:44.274 [main] DEBUG [HikariConfig:1132] - jdbcUrl.........................jdbc:h2:mem:ad49dbea-90cb-4ab2-b641-64589204c171;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE

 

 

8. 동적 데이터베이스 설정

테스트 컨테이너로 생성한 데이터베이스를 사용하려면, 스프링에서 Datasource를 생성할 수 있도록 @DynamicPropertySource를 통해 동적으로 프로퍼티 값을 설정해야 합니다.

import org.springframework.boot.test.context.DynamicPropertyRegistry;
import org.springframework.boot.test.context.DynamicPropertySource;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Testcontainers
class SupportLinkServiceTest {

    private static final String USERNAME = "username";
    private static final String PASSWORD = "password";
    private static final String DATABASE_NAME = "testdb";

    @Container
    static MySQLContainer<?> mySQLContainer = new MySQLContainer<>("mysql:8.0")
            .withUsername(USERNAME)
            .withPassword(PASSWORD)
            .withDatabaseName(DATABASE_NAME);

    @DynamicPropertySource
    public static void overrideProps(DynamicPropertyRegistry dynamicPropertyRegistry) {
        dynamicPropertyRegistry.add("spring.datasource.url", () -> mySQLContainer.getJdbcUrl());
        dynamicPropertyRegistry.add("spring.datasource.username", () -> USERNAME);
        dynamicPropertyRegistry.add("spring.datasource.password", () -> PASSWORD);
        dynamicPropertyRegistry.add("spring.jpa.hibernate.ddl-auto", () -> "create");
    }

    @DisplayName("테스트1")
    @Test
    void 테스트1() {
        // 테스트 로직
    }
}

위와 같이 설정한 후 테스트를 다시 실행하면, 테스트 컨테이너와 올바르게 연결되었는지 확인할 수 있습니다.

24-08-23 12:42:44.442 [main] DEBUG [HikariConfig:1132] - driverClassName................."com.mysql.cj.jdbc.Driver"
24-08-23 12:42:44.445 [main] DEBUG [HikariConfig:1132] - jdbcUrl.........................jdbc:mysql://localhost:30035/testdb

위와 같이 MySQL 데이터베이스로 올바르게 연결된 것을 확인할 수 있습니다.

 

 

9. GenericContainer 사용 예시

Testcontainers에서 지원하지 않는 데이터베이스를 사용해야 하는 경우, GenericContainer를 사용하여 컨테이너를 생성할 수 있습니다. 이 경우 데이터베이스에 특화된 withDatabaseName() 메서드 같은 것은 사용할 수 없지만, withEnv() 메서드를 사용해 환경 변수를 설정할 수 있습니다.

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Testcontainers
class SupportLinkServiceTest {

    @Container
    private static GenericContainer mySQLContainer = new GenericContainer("mysql")
            .withEnv("MYSQL_DB", "testdb");
}

 


 

두 번째 방법

1. application-test.yml 설정

위의 복잡한 설정은 모두 필요 없이, application-test.yml 파일에 아래와 같이 작성하면 Testcontainers DB가 위의 과정을 모두 포함한 채로 바로 컨테이너와 연결되어 사용할 수 있습니다.

아래의 yml 파일만 작성하면 static으로 인스턴스를 등록하고 @DynamicPropertySource를 설정한 첫 번째 방법과 동일하게 동작합니다.

spring:
  config:
    activate:
      on-profile: test
  datasource:
    url: jdbc:tc:mysql:8.0.31:///testdb
    driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
  jpa:
    hibernate:
      ddl-auto: create
  • jdbc:tc:mysql:8.0.31:///testdb에서 tc가 추가된 부분이 가장 중요합니다.
  • tc는 Testcontainers를 통해 MySQL 8.0.31 버전을 사용하는 데이터베이스를 설정한다는 것을 의미합니다.
  • driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver는 Spring Boot 2.3 버전 이후부터 생략 가능합니다.
spring:
  config:
    activate:
      on-profile: test
  datasource:
    url: jdbc:tc:mysql:8.0.31:///testdb
    # driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
  jpa:
    hibernate:
      ddl-auto: create

 

 

2. 클래스를 통해 직접 설정

application-test.yml을 작성하지 않고, 클래스에서 바로 설정할 수도 있습니다.

아래는 Testcontainers의 공식 GitHub 예제를 참고한 예시입니다.

@SpringBootTest(
        webEnvironment = WebEnvironment.RANDOM_PORT,
        properties = {
                "spring.datasource.url=jdbc:tc:mysql:8.0.31:///testdb",
                "spring.jpa.hibernate.ddl-auto=create"
        }
)
@ActiveProfiles("test")
public abstract class AbstractIntegrationTest {

    static GenericContainer redis = new GenericContainer(DockerImageName.parse("redis:6-alpine"))
            .withExposedPorts(6379);

    @DynamicPropertySource
    static void redisProperties(DynamicPropertyRegistry registry) {
        redis.start();
        registry.add("spring.redis.host", redis::getHost);
        registry.add("spring.redis.port", redis::getFirstMappedPort);
    }
}

위와 같이 yml을 생략하고 @SpringBootTest 어노테이션에 properties 옵션 값으로 직접 작성할 수 있습니다.

그리고 위의 예시처럼 다른 컨테이너(Redis)는 첫 번째 방법과 같이 필요한 다른 컨테이너를 함께 구성할 수 있습니다.

이러한 방식으로 구성 클래스를 abstract 클래스로 만들어 두면, 통합 테스트가 필요한 테스트 클래스는 이 추상 클래스를 상속하기만 하면 됩니다.

 

 

3. 구성 추상 클래스 사용 코드

이제 실제 데이터베이스가 필요한 통합 테스트를 작성할 때는 추상 클래스를 상속하여 사용하면, 개발자는 테스트 코드 작성에만 집중할 수 있습니다.

class SupportLinkServiceTest extends AbstractIntegrationTest {

    @Test
    void 테스트1(){
        // 테스트 로직 작성
    }
}

 

 

세 번째 방법

1. docker-compose.yml 작성

docker-compose.yml을 아래와 같이 작성합니다. 예제 테스트에서는 아래와 같이 작성했습니다.

# src/test/resources
version: "3"

services:
  testdb:
    image: mysql:8.0.31
    ports:
      - 3306
    environment:
      MYSQL_ROOT_PASSWORD: password
      MYSQL_DATABASE: testdb

 

 

2. DockerComposeContainer 추가

DockerComposeContainer를 활용해 컨테이너들을 한 번에 Testcontainers로 관리할 수 있습니다. 앞선 예제 코드에서 @Testcontainers를 사용한 코드와 구성 방식은 동일합니다.

@SpringBootTest(
        classes = SerafinApplication.class,
        webEnvironment = WebEnvironment.RANDOM_PORT
)
@ActiveProfiles("test")
@Testcontainers
public abstract class AbstractIntegrationTest {

    public static final String MYSQL_CONTAINER_NAME = "testdb";
    public static final String MYSQL_DB_NAME = "testdb";
    public static final int MYSQL_PORT = 3306;

    @Container
    static DockerComposeContainer composeContainer =
            new DockerComposeContainer(new File("src/test/resources/docker-compose.yml"))
                    .withExposedService(
                            MYSQL_CONTAINER_NAME,
                            MYSQL_PORT,
                            Wait.forListeningPort().withStartupTimeout(Duration.ofSeconds(30))
                    );

    @DynamicPropertySource
    public static void overrideProps(DynamicPropertyRegistry dynamicPropertyRegistry) {

        final String host = composeContainer.getServiceHost(MYSQL_CONTAINER_NAME, MYSQL_PORT);
        final Integer port = composeContainer.getServicePort(MYSQL_CONTAINER_NAME, MYSQL_PORT);

        dynamicPropertyRegistry.add("spring.datasource.url",
                () -> "jdbc:mysql://%s:%d/%s".formatted(host, port, MYSQL_DB_NAME));
        dynamicPropertyRegistry.add("spring.datasource.username", () -> "root");
        dynamicPropertyRegistry.add("spring.datasource.password", () -> "password");
        dynamicPropertyRegistry.add("spring.jpa.hibernate.ddl-auto", () -> "create");
    }
}

위의 코드에서 docker-compose.yml의 경로를 파일 객체로 넣어주고, withExposedService() 메서드의 매개변수로 compose 파일에 작성한 서비스 이름, 포트, 그리고 사용 가능한 상태가 될 때까지 대기할 수 있도록 설정합니다.

Wait.forListeningPort().withStartupTimeout(Duration.ofSeconds(30))는 컨테이너가 실행되기까지 시간이 소요되는데, 사용 가능한 상태가 될 때까지 기다려주는 코드입니다.

그런 다음, 만든 DockerComposeContainer 인스턴스를 스프링이 사용할 수 있도록 동적으로 설정해주면 됩니다.

 

 


 

 

마무리하며

이 글에서는 Testcontainers를 사용하여 애플리케이션 테스트 환경을 설정하는 여러 가지 방법을 살펴보았습니다. Testcontainers는 Docker 컨테이너를 이용해 테스트 환경을 자동으로 설정해 주기 때문에, 테스트를 운영 환경과 유사한 환경에서 수행할 수 있으며 간단한 코드 작성만으로 구성할 수 있습니다.

첫 번째 방법은 직접 MySQLContainer를 설정하고 @DynamicPropertySource를 사용해 데이터베이스 연결을 동적으로 설정하는 방법이었습니다.

두 번째 방법은 application-test.yml을 통해 더 간단하게 설정하는 방법을 소개했습니다.

마지막으로, 세 번째 방법은 docker-compose.yml을 사용해 여러 컨테이너를 한 번에 관리할 수 있는 방법이었습니다.

이러한 다양한 방법들을 통해 Testcontainers를 효과적으로 활용할 수 있으며, 상황에 따라 적절한 방법을 선택하여 사용하시면 됩니다.

Testcontainers는 특히 운영 환경과 동일한 조건에서 테스트를 수행해야 할 때 유용하며 테스트 코드의 멱등성을 유지하고 이를 통해 테스트의 신뢰성과 정확성을 높일 수 있습니다.