Testcontainers를 활용하여 통합테스트 환경 구축하기

ssongkim·2023년 1월 20일
2

테스트코드

목록 보기
4/5
post-thumbnail

Overview

도커의 등장이후 컨테이너 가상화 기술이 널리 보급되기 시작하며 컨테이너 없이 무언가를 개발하고 배포하는 것은 상상하기 어려워진 것 같습니다. 컨테이너 가상화 기술의 확장성, 표준성, 멱등성, 일관성, 편의성 등의 특징이 기존 IT계의 문제점을 많이 해결해주었기 때문입니다.

컨테이너 기술은 인프라계에도 혁명을 가져와주었지만 개발문화가 바뀌어가며 DevOps가 성장하는 것에도 많은 영향을 주었다고 생각합니다. 대표적으로 CI / CD를 수행하는 과정에서 개발하고 빌드하고 테스트하고 배포하는 일련의 과정 속에서 발생할 수 있는 의존성 등등의 문제를 대부분 컨테이너 기술을 도입하여 쉽게 해결합니다.

오늘은 저희 팀에서 운영하는 서비스에 CI를 도입하는 과정에서 통합테스트의 어려움을 컨테이너 기술의 특징을 이용해 해결하는 Testcontainers를 알게되었고 이를 소개해보고자 합니다.

통합테스트의 어려움..

테스트는 개발자가 로컬에서 수동으로 할 수도 있고 코드를 통해 자동화를 할 수도 있습니다.

단위테스트는 코드로 대부분 해결이 가능합니다. 단위테스트는 하나 하나 개별 모듈을 테스트하는 것이기 때문에 다른 모듈이나 외부 의존성이 있으면 그건 모킹처리하고 하나하나 순수 로직만 테스트하면 되서 코드로 작성하기 매우 쉽기 때문입니다.

하지만 통합테스트는 코드로 해결이 어렵습니다. 통합테스트는 외부 모듈과 의존성들을 포함해 이들과 상호작용이 원활히 이루어지는지 테스트하기 때문입니다.

다른 모듈이야 내가 코드로 제어가 가능한다쳐,, DBAWS SQS, S3 같은 경우 내가 코드로 제어를 하지못해 이들 버전에 대한 의존성과 접근에 대한 어려움이 상당합니다.

먼저 DB부터 보겠습니다.

  • Local : 로컬에 설치해서 환경 구축 후 테스트에 사용
  • In-Memory : 인 메모리 DB를 활용하여 테스트 구동시 사용
  • Embedded Library : Library를 이용하여 테스트 구동시 사용

DB 테스트 수행 시에는 위 3가지 방법을 고려해볼 수 있습니다.
하지만 각각 로컬에 개발자마다 DB환경을 구성해줘야한다던가 호환성 문제, 임베디드 라이브러리를 지원하지 않는 경우가 있다는 문제점이 있습니다.

DB 의존성 뿐만 아니라 AWS 의존성에 대한 문제점도 있습니다.
저희 팀의 경우 비즈니스 로직에 특정 작업에 대해 가시성을 수정하는 AWS SQS에 대한 의존성이 존재했습니다. 이에 대한 테스트코드를 작성하려면 엑세스키와 시크릿키를 발급받아야하는 보안상 문제점이 있습니다.

Testcontainers는 컨테이너 가상화 기술의 특징을 활용해 이러한 문제점을 해결하기 위해 등장하였습니다.

1. Testcontainers란

테스트컨테이너란 코드로 도커 컨테이너를 제어하여 통합테스트를 도와주는 라이브러리입니다.

로컬에 설치된 도커데몬과 연동되어 테스트코드가 실행되기 전 코드를 통해 해당 테스트를 위한 일회성 컨테이너를 생성하고 테스트 수행 후 컨테이너를 삭제합니다. 테스트컨테이너를 응용하면 테스트 때 뿐만 아니라 런타임 중에도 컨테이너를 생성하고 활용할 수 있습니다.

테스트컨테이너는 자바 뿐만 아니라 파이썬, 고 등 다양한 언어를 지원하며 이번 시간엔 자바를 통해 테스트컨테이너를 사용해보겠습니다.

기본적으로 Testcontainers for Java에서는 org.testcontainers:junit-jupiter를 통해 junit을 지원합니다.

우리는 테스트컨테이너를 통해 테스트마다 컨테이너가 독립적으로 수행되며 멱등성을 보장하고 운영환경과 거의 동일한 환경에서 통합테스트를 수행하여 보다 완벽한 통합테스트 코드를 작성할 수 있게됩니다.

1-1. Testcontainers modules

기본적으로 테스트컨테이너 라이브러리를 사용할 경우 도커 이미지만 있다면 어떠한 유형의 컨테이너도 생성해서 테스트에 사용할 수 있습니다.

모든 도커 이미지 중에서도 특히 자주 사용되는 솔루션들에 대해 모듈형태로 테스트컨테이너를 쉽게 사용할 수 있도록 테스트컨테이너 모듈을 지원합니다.

예를 들어 MySQL 모듈을 사용할 경우 MySQL 테스트컨테이너를 매우 쉽게 생성하고 사용할 수 있습니다.

1-2. Testcontainers 시작하기

먼저 저희 팀의 경우 VerticaDB를 사용하는데 이는 테스트컨테이너에서 모듈로 제공해주지 않고 있습니다.

그래서 순수 testcontainers만 활용하여 버티카DB에 대한 통합테스트 코드를 작성하는 예제로 알아보도록 하겠습니다. 저는 버티카 디비를 예제로 하였지만 제 예제를 참고하여 이미지만 바꾸고 유틸클래스만 잘 수정한다면 원하는 테스트가 가능할 것입니다.

테스트컨테이너를 생성하는 대표적인 예로 GenericContainer를 이용해 코드로 컨테이너를 생성하는 방식과 docker-compose를 이용해 설정을 구성하여 생성하는 방식이 있습니다. 이번 시간엔 docker-compose를 알아보도록 하겠습니다.

밑에 예제에서는 두가지 방식 모두 존재합니다.

1-2-a. 의존성 추가

먼저 의존성을 추가해줍니다.

testImplementation group: 'org.testcontainers', name: 'testcontainers', version: '1.17.6'
testImplementation group: 'org.testcontainers', name: 'junit-jupiter', version: '1.17.6'

.. 이외에 사용하는 디비 라이브러리 등

1-2-b. docker-compose 작성

version: "3.8"
services:
  vertica:
    platform: linux/amd64
    environment:
      VERTICA_DB_USER: "dbadmin"
      VERTICA_DB_PASSWORD: ""
      VERTICA_DB_NAME: "test"
      VERTICA_MEMDEBUG: 2
      TZ: "Asia/Seoul"
    image: vertica/vertica-ce:10.1.1-0
    ports:
      - "5433"
      - "5444"

테스트컨테이너로 구동하고자 하는 docker-compose를 구성하여 src/test/resources 디렉토리에 위치해주세요.

1-2-c. utils 작성

@Slf4j
public class VerticaContainerUtils
{
    private VerticaContainerUtils()
    {
    }

    public static void initializeVerticaContainer(
        ContainerState verticaContainer,
        EmbeddedVerticaProperties properties,
        ConfigurableEnvironment environment) throws Throwable
    {
        // init schema
        initVerticaSchema(verticaContainer, properties);

        updateVerticaEnvironment(
            verticaContainer.getHost(),
            verticaContainer.getMappedPort(properties.getPort()),
            properties,
            environment);
    }

    public static void initVerticaSchema(
        ContainerState container,
        EmbeddedVerticaProperties properties) throws IOException, InterruptedException
    {
        MountableFile resources = MountableFile.forClasspathResource("vertica/init-scripts");
        container.copyFileToContainer(resources, "/vertica/init-scripts");

        File dir = new File(resources.getResolvedPath());
        String[] files = dir.list();
        Arrays.sort(files);

        for (String file : files)
        {
            execSqlInContainer(
                container,
                properties.getDatabase(),
                properties.getUser(),
                "/vertica/init-scripts/" + file);
        }
    }

    private static void execSqlInContainer(
        ContainerState container,
        String database,
        String dbUser,
        String file) throws IOException, InterruptedException
    {
        Container.ExecResult execResult = container.execInContainer(
            "/opt/vertica/bin/vsql",
            "-d",
            database,
            "-U",
            dbUser,
            "-w",
            "password",
            "-h",
            "localhost",
            "--variable",
            "dpt=101",
            "-f",
            file);

        log.info(execResult.getStdout());
    }

    public static GenericContainer<?> createVerticaContainer(EmbeddedVerticaProperties properties)
    {
        LinkedHashMap<String, String> map = new LinkedHashMap<>();
        map.put("VERTICA_DB_NAME", properties.getDatabase());
        map.put("VERTICA_DB_PASSWORD", properties.getPassword());
        map.put("VERTICA_MEMDEBUG", "2");

        return new GenericContainer<>(DockerImageName.parse(properties.getDefaultDockerImage()))
            .withExposedPorts(properties.getPort())
            .withEnv(map)
            .waitingFor(Wait.forLogMessage(".*Database " + properties.getDatabase() + " created successfully..*", 1)
                .withStartupTimeout(Duration.ofMinutes(3))); // 3분 내로 해당 로그가 뜨면 컨테이너가 생성된 후 준비됐다고 판단, 더 좋은 방법이 있을까?
    }

    public static void updateVerticaEnvironment(
        String host,
        int mappedPort,
        EmbeddedVerticaProperties properties,
        ConfigurableEnvironment environment)
    {
        log.info("host: {}, port: {}", host, mappedPort);

        LinkedHashMap<String, Object> map = new LinkedHashMap<>();
        map.put("embedded.vertica.port", mappedPort);
        map.put("embedded.vertica.host", host);
        map.put("embedded.vertica.database", properties.getDatabase());
        map.put("embedded.vertica.user", properties.getUser());
        map.put("embedded.vertica.password", properties.getPassword());


        MapPropertySource propertySource = new MapPropertySource("embeddedVerticaInfo", map);
        environment.getPropertySources().addFirst(propertySource);

        log.info("Started Vertica server. Connection details: {}, ", map);
    }
}

우리는 copyFileToContainer 메서드를 통해 호스트에서 컨테이너로 파일을 옮기고 execInContainer 메서드를 통해 컨테이너 내부에서 명령어를 실행시킬 수 있습니다.
해당 유틸 예제는 테스트컨테이너로 생성한 버티카컨테이너로 init 스키마 파일을 옮긴 후, 해당 스크립트 파일들을 정렬하여 순차적으로 컨테이너 내부에서 스키마 초기화 스크립트를 실행하는 예제입니다.

DB를 도커로 말아 쓰는 형태에서는 표준으로 docker-entrypoint-initdb.d 디렉토리에 init 스키마를 넣어두면 초기화를 시켜줍니다.
하지만 버티카DB에서는 최초 1번 실행되지 않는 버그가 있어 위와 같은 작업으로 처리하였습니다.
Testcontainers에서는 withClasspathResourceMapping 메서드를 통해 resources 폴더와 컨테이너 내 특정 폴더와 볼륨 매핑이 가능합니다.

@Slf4j
public class DockerComposeUtils
{
    private DockerComposeUtils()
    {
    }

    public static DockerComposeContainer createContainers(EmbeddedVerticaProperties properties)
    {
        return new DockerComposeContainer(new File(MountableFile.forClasspathResource("docker-compose.yml")
            .getResolvedPath()))
            .withExposedService("vertica", properties.getPort(), Wait.forListeningPort())
            .waitingFor(
                "vertica",
                Wait.forLogMessage(".*Database " + properties.getDatabase() + " created successfully..*", 1)
                    .withStartupTimeout(Duration.ofMinutes(3))); // 3분 내로 해당 로그가 뜨면 컨테이너가 생성된 후 준비됐다고 판단, 더 좋은 방법이 있을까?
    }
}

컨테이너가 생성된다고 테스트코드를 돌릴 수 있는 상태임을 의미하지 않습니다.
testcontainers 에서는 컨테이너가 생성되고 준비됐음을 의미하는 Waiting 전략을 지정할 수 있습니다.

헬스체크를 사용하는 방식, 로그를 기다리는 방식 등 여러 전략이 존재하며 자세한 내용은 아래 docs를 확인해주세요.

저는 log를 기다리는 전략을 선택하였습니다. 해당 로그가 출력되면 컨테이너가 준비됐다고 판단하고 테스트코드를 돌립니다.

1-2-d. @TestConfiguration 구성

@Slf4j
@TestConfiguration
@EnableConfigurationProperties(EmbeddedVerticaProperties.class)
public class TestContainersDockerComposeConfiguration
{
    /**
     * testcontainers를 docker-compose로 구성하여 생성
     *
     * @param environment
     * @param properties
     * @return
     */
    @Bean(name = "testcontainers", destroyMethod = "stop")
    public DockerComposeContainer testcontainers(
        ConfigurableEnvironment environment,
        EmbeddedVerticaProperties properties) throws Throwable
    {
        DockerComposeContainer composeContainer = DockerComposeUtils.createContainers(properties);
        composeContainer.start();

        ContainerState containerState = (ContainerState) composeContainer.getContainerByServiceName("vertica")
            .orElseThrow(() -> new RuntimeException());
        VerticaContainerUtils.initializeVerticaContainer(containerState, properties, environment);

        return composeContainer;
    }

}

앞서 구성한 도커컴포즈 구성파일을 이용해 컨테이너들을 생성하고, 버티카 컨테이너의 경우 커티카컨테이너 유틸로 스크립트를 초기화하는 예제입니다.

1-2-e. 통합 테스트코드 작성

@Import({TestContainersDockerComposeConfiguration.class})
@SpringBootTest
public class TestContainersIntegrationSupport
{
}
/**
 * 버티카 테스트컨테이너를 빈으로 등록하여 @SpringBootTest를 통해 테스트코드에서 확인하는 예제
 */
public class EmbeddedVerticaSpringBootTest extends TestContainersIntegrationSupport
{
    @Autowired
    JdbcTemplate jdbcTemplate;

    @Autowired
    ConfigurableEnvironment environment;

    @Test
    @DisplayName("버티카 연결에 성공한다.")
    public void shouldConnectToVertica()
    {
        assertThat(jdbcTemplate.queryForObject("SELECT version()", String.class)).contains("Vertica Analytic Database");
    }

    @Test
    @DisplayName("임베디드 버티카 환경변수를 잘 불러온다.")
    public void propertiesAreAvailable()
    {
        assertThat(environment.getProperty("embedded.vertica.port")).isNotEmpty();
        assertThat(environment.getProperty("embedded.vertica.host")).isNotEmpty();
        assertThat(environment.getProperty("embedded.vertica.database")).isNotEmpty();
        assertThat(environment.getProperty("embedded.vertica.user")).isNotEmpty();
        assertThat(environment.getProperty("embedded.vertica.password")).isNotNull();
    }

    @Test
    @DisplayName("seed 값을 잘 불러온다.")
    public void shouldDiscoveryDBSeed()
    {
        assertThat(jdbcTemplate.queryForObject("SELECT count(*) FROM hello_world", Integer.class)).isEqualTo(4);
    }
}

@TestConfiguration으로 테스트컨테이너를 빈으로 등록하여 @SpringBootTest에서 확인하는 통합테스트 예제입니다.

1-2-f. DB 단위테스트 작성

@Testcontainers
public class TestContainersUnitSupport
{
    @Container
    protected static final DockerComposeContainer composeContainer;

    static
    {
        EmbeddedVerticaProperties properties = new EmbeddedVerticaProperties();
        composeContainer = DockerComposeUtils.createContainers(properties);
        composeContainer.start();

        try
        {
            // init schema
            ContainerState containerState = (ContainerState) composeContainer.getContainerByServiceName("vertica")
                .orElseThrow(() -> new RuntimeException());
            VerticaContainerUtils.initVerticaSchema(containerState, properties);
        }
        catch (Throwable e)
        {
            throw new RuntimeException(e);
        }
    }
}

해당 코드는 docker-compose로 테스트컨테이너를 생성하고 스키마 초기화 스크립트를 실행하는 예제입니다.

/**
 * @SpringBootTest 없이 테스트컨테이너 활용해 단위테스트 예제
 */
public class EmbeddedVerticaUnitTest extends TestContainersUnitSupport
{
    private JdbcTemplate jdbcTemplate;

    @BeforeEach
    void beforeEach() throws Throwable
    {
        // 테스트 컨테이너가 실행되면 환경변수 host 가 바뀌므로 springBootTest가 아니라면 이를 jdbcTemplate에 수동으로 반영해야한다. 더 좋은 방법이 있으려나
        ContainerState containerState = (ContainerState) composeContainer.getContainerByServiceName("vertica")
            .orElseThrow(() -> new RuntimeException());
        EmbeddedVerticaProperties properties = new EmbeddedVerticaProperties();

        DataSource dataSource = new DataSource();
        dataSource.setHost(containerState.getHost());
        dataSource.setPort(containerState.getMappedPort(properties.getPort()));
        dataSource.setDatabase(properties.getDatabase());
        dataSource.setUser(properties.getUser());
        dataSource.setUserID(properties.getUser());

        jdbcTemplate = new JdbcTemplate(dataSource);
    }

    @Test
    @DisplayName("버티카 연결에 성공한다.")
    public void shouldConnectToVertica()
    {
        assertThat(jdbcTemplate.queryForObject("SELECT version()", String.class)).contains("Vertica Analytic Database");
    }

    @Test
    @DisplayName("seed 값을 잘 불러온다.")
    public void shouldDiscoveryDBSeed()
    {
        assertThat(jdbcTemplate.queryForObject("SELECT count(*) FROM hello_world", Integer.class)).isEqualTo(4);
    }
}

다음과 같이 @SpringBootTest 없이 테스트컨테이너를 이용해 DB 테스트를 진행할 수 있습니다.

2. Localstack

테스트컨테이너와 금상첨화를 이루는 Localstack에 대해 알면 더욱 좋습니다.
앞서 AWS 서비스에 대해 비즈니스 로직에 의존성을 가질경우 테스트코드를 작성하기 매우 어렵다 하였습니다.

이를 Localstack이 해결해줍니다. 우리는 Localstack을 활용하여 로컬에 AWS 클라우드 환경을 컨테이너로 구성할 수 있습니다.

유료버전 무료버전이 존재하며 유료버전은 좀 더 풍부한 서비스에 대해 테스트를 할 수 있다고 합니다.

Testcontainers에서는 localstack에 대해 모듈을 지원하여 편리하게 구성하여 사용할 수 있습니다.

깃헙예제보기

https://github.com/suhongkim98/testcontainers-embedded-vertica-demo

profile
鈍筆勝聰✍️

2개의 댓글

comment-user-thumbnail
2024년 10월 6일

안녕하세요 포스팅 잘 읽었습니다. 궁금한 점이 있어 질문을 남깁니다.
CI/CD 과정 중에서 테스트 컨테이너를 활용한 통합 테스트를 거치고 도커 이미지 빌드/푸시를 할 경우, 테스트 컨테이너 사용을 위한 따로 필요한 설정이 있을까요?
감사합니다.

1개의 답글