티스토리 뷰

Backend

Testcontainers로 통합테스트 만들기

지마켓 안재열 2023. 4. 26. 16:31

안녕하세요. Shopping Service Backend팀 안재열입니다.

저희 팀에서는 여러 팀에서 세심하게 생성하고 관리한 상품과 관련된 데이터를 고객에게 적절하게 가공하여 상품 목록 정보를 제공하는 API를 개발하는 업무를 담당하고 있습니다. 이 과정에서 다양한 모듈을 결합하여 데이터를 가공하는 작업들을 수행하게 됩니다. 그러한 작업 중 통합 테스트를 진행하게 되는데요, 이번 글에서는 TestContainers를 활용한 통합 테스트 작성 방법에 대해 알아보겠습니다.


통합테스트는 무엇인가요?

통합 테스트(Integration Test)란, 서로 다른 부분들이 원활하게 작동하는지 확인하기 위해 여러 모듈을 같이 테스트하는 과정입니다. 여기서 모듈은 웹서버, WAS, DBMS, 메시지 브로커, 파일서버 등이 포함됩니다.

이 과정에서 통합 테스트의 목적은 새롭게 개발한 API가 데이터베이스에서 정보를 정확하게 가져오는지 확인하는 것이 될 수 있습니다.

 

통합테스트를 작성하기 어렵게 만드는 요인

통합테스트는 크게 아래와 같이 네가지의 이유로 테스트 수행에 어려움을 겪습니다.

1) 복잡한 시스템 구조와 모듈간의 의존성
2) 환경설정과 관리
3) 테스트 데이터관리
4) 비결정적 테스트결과

 

먼저, 복잡한 시스템 구조는 여러 모듈이 상호작용하여 복잡한 구조를 만들 때를 말합니다. 이런 경우 통합 테스트의 범위와 복잡도가 증가하게 되며, 특히 각 모듈이 서로 의존성을 가질 때 테스트 작성이 더 어려워질 수 있습니다. 그러나 저는 아직까지 테스트를 진행할 수 없을 정도로 복잡한 시스템 구조나 모듈 간의 의존성이 있는 경우를 겪지 않았습니다.

 

그래서 남은 세 가지 요인인 '환경 설정과 관리', '테스트 데이터 관리', 그리고 '비결정적 테스트 결과'가 통합 테스트를 어렵게 만드는 주요 요인들로 볼 수 있습니다. 각 모듈마다 환경 설정 값과 사용 방법이 다를 수 있기 때문에 통합 테스트를 수행하는 개발자는 각 모듈별 환경 설정과 관리 방법을 알아야 합니다. 예를 들어, 개발, 테스트, 스테이징, 운영 등의 다양한 환경에서 DBMS의 버전이나 사용자 아이디 등 설정이 달라질 수 있습니다. 이로 인해 통합 테스트를 수행하는 데 어려움이 발생할 수 있습니다.

 

게다가, 테스트 데이터는 실제 데이터와 유사한 테스트 데이터를 사용해야 하는데, 통제되지 않은 환경에서 이를 관리하고 유지하는 데에는 상당한 노력이 필요합니다. 예를 들어, 통합 테스트를 수행할 API가 사용하는 DBMS의 테이블 데이터나 스키마가 변경되는 경우가 있을 수 있습니다. 여러 개발자가 동시에 테스트를 수행하다 보면, 데이터 격리가 제대로 이루어지지 않을 수도 있습니다. 이런 상황에서 동일한 입력값에도 불구하고 매번 다른 결과를 도출하는 비결정적(Non-deterministic) 테스트 결과가 발생할 수 있는데, 이런 비결정적 테스트 결과는 통합 테스트 작성과 유지보수를 어렵게 만드는 주요 요인입니다.

 

비결정론적 알고리즘(Non-deterministic algorithm) - wikipedia

비결정론적 알고리즘(Non-deterministic algorithm)은 같은 입력에 대해 항상 동일한 결과를 내놓지 않는 알고리즘입니다. 이러한 알고리즘은 무작위성이 포함되어 있거나, 실행할 때마다 다른 경로를 선택할 수 있는 특성이 있습니다. 따라서 비결정론적 알고리즘은 여러 번 실행될 때마다 결과가 달라질 수 있어, 예측하기 어렵다는 특징이 있습니다.


테스트 환경구성의 어려움

앞서 살펴본 것처럼, 통합 테스트는 결정적인 테스트 결과를 가져오기 위해 구성되어야 합니다.

이를 위해 테스트는 멱등성(Idempotent)을 보장하도록 작성되곤 합니다. 멱등성이란 수학과 컴퓨터 과학에서 사용되는 개념으로, 어떤 연산이 여러 번 수행되어도 동일한 결과를 내놓는 성질을 의미합니다. 즉, 한 번만 수행한 결과와 여러 번 수행한 결과가 같을 때 그 연산은 멱등성을 가진다고 할 수 있습니다. 예시로, 절댓값 함수는 멱등성을 가지고 있습니다. 절댓값 함수를 여러 번 적용하더라도 결과는 한 번 적용한 결과와 동일합니다. 예를 들어 |(-3)| = 3이고 ||(-3)|| = 3을 말합니다.

 

그렇다면 어떻게 통합테스트를 만드는 것이 좋을까요? DB 테이블을 조회하는 API 서비스를 Java로 개발하는 예를 가지고 살펴보겠습니다.

https://www.collegenote.net/curriculum/web-technology-csit/84/468/

통합 테스트에서 사용할 주요 모듈은 웹 애플리케이션 서버(WAS)와 데이터베이스 관리 시스템(DBMS)입니다. 테스트를 위해서는 이 두 가지 모듈이 어딘가에서 실행되어야 합니다. 테스트 구성을 위해 가장 간편한 환경은 자신의 로컬(Local) 환경입니다. 로컬 환경에서의 실행을 위해 자바에서는 Tomcat과 같은 서블릿 구현체를 설치하면 됩니다. 스프링 부트를 사용하는 경우, 내장된 톰캣을 사용하므로 로컬에서의 실행은 문제가 되지 않습니다.

 

DBMS의 경우, 로컬에 직접 설치하는 방법이 있습니다. 또한, MySQL과 같은 일부 데이터베이스는 임베디드 형태로 테스트를 위한 라이브러리를 제공하기도 합니다. 그 외에도 H2와 같은 인메모리 DB를 대신 사용하는 방법이 있습니다.

 

로컬 환경에 직접 설치, 임베디드 라이브러리 사용, 그리고 인메모리 DB 사용 등의 방식에는 각각 단점이 존재합니다. 우선, 로컬 환경에 DBMS를 직접 설치하는 것은 상당히 번거로운 작업입니다. 팀원들의 로컬 환경 상황에 따라 설치

방법이 다를 수 있으며, 설치 후에도 테스트를 위한 환경 설정을 진행해야 합니다. 테이블을 개별적으로 생성하고 테스트를 위한 데이터를 입력해야 합니다.

 

임베디드 형태의 테스트 라이브러리를 사용하는 경우에는 설치 및 설정 문제는 해결되지만, 모든 데이터베이스가 이를 지원하는 것은 아닙니다. 따라서 선택할 수 있는 데이터베이스가 제한될 수 있습니다. 게다가 프로젝트 운영이 중단되는 경우도 있어, 이러한 이유로 다른 대안을 찾아야 하고, 이전에 작성했던 테스트 코드를 리팩토링 할 필요가 있을 수도 있습니다.

 

마지막으로 인메모리 DB의 경우, DBMS 간 쿼리 지원에 관한 호환성 문제가 발생할 수 있습니다. 이로 인해 서로 다른 DBMS에서 동일한 쿼리를 사용하더라도 예상치 못한 결과를 가져올 수 있습니다.

따라서 이러한 문제들을 Testcontainers 및 도커(Docker)를 활용하여 해결하면 통합 테스트를 원활하게 진행할 수 있습니다.

 

참고로 mysql의 임베디드 라이브러리 wix-embedded-mysql는 Deprecated 상태로 Testcontainers 사용을 권합니다.


Testcontainers를 이용해 테스트 만들기

Testcontainers는 통합 테스트를 지원하기 위해 개발된 오픈 소스 Java 라이브러리입니다. Testcontainers는 도커 컨테이너를 활용하여 외부 의존성들을 포함한 테스트 환경을 구축하고 관리하는 것을 간편하게 해 줍니다. 이를 통해 개발자들은 어플리케이션의 통합 테스트를 더 쉽고 빠르게 작성하고 실행할 수 있습니다.

 

Testcontainers는 다양한 유형의 DBMS를 지원하며, MySQL, PostgreSQL, MongoDB, 그리고 Kafka와 같은 데이터베이스 관리 시스템이 포함됩니다. 또한, Kafka와 RabbitMQ와 같은 메시지 브로커(혹은 큐)를 포함하여 Nginx 등의 웹 서버도 지원합니다. 심지어 HashiCorp Vault와 같은 시크릿 관리 도구도 지원합니다.

 

Testcontainers를 사용하면 외부 서비스를 모방할 수 있어, 실제 서비스와 유사한 환경에서 통합 테스트를 진행할 수 있습니다. 이를 통해 개발자들은 실제 상황에 가까운 테스트 환경에서 코드를 검증하고, 안정성 있는 어플리케이션을 개발할 수 있습니다.

 

https://www.testcontainers.org

Testcontainers의 주요 장점은 다음과 같습니다.

1) 개발자 친화적:
테스트 코드를 작성하는 데 필요한 의존성과 환경 구성을 최소화하여, 개발자가 테스트에만 집중할 수 있게 해줍니다.

2) 환경 독립성:
도커사용으로 로컬 환경, CI/CD 파이프라인, 프로덕션 환경 등에서 동일한 테스트 환경을 구축할 수 있습니다.

3) 높은 확장성:
다양한 서비스와 애플리케이션을 지원하므로, 필요한 경우 새로운 서비스를 추가와 기존 서비스를 업데이트가 쉽습니다.

 

이러한 장점 덕분에 개발자들은 통합 테스트를 효과적으로 수행하고 코드의 안정성을 크게 향상할 수 있습니다. 특히 멱등성을 보장함으로써, 비결정적인 테스트 결과를 방지하고 테스트의 신뢰성을 높일 수 있습니다.

 

이제, Testcontainers를 사용하여 DB 테이블을 조회하는 API 서비스를 Java로 개발하는 방법을 알아보겠습니다.

 

예제시나리오

 


프로젝트 의존성(Dependency)

먼저, 로컬 환경에 도커를 설치해야 합니다. 도커 설치 방법에 대해 자세히 알고 싶으시다면, 공식 사이트를 참조해 주세요.

https://www.docker.com/get-started

Testcontainers를 사용하기 위해서는 프로젝트에 필요한 의존성을 추가해야 합니다.

이번 예제에서 사용할 의존성은 'Testcontainers-oracle-xe' 과 'Testcontainers-spock' 두 가지입니다.

Testcontainers-oracle-xe 추가

  • DB를 사용하기 위해 관련 패키지의 의존성을 추가해야 합니다.
  • 이번 예제에서는 Oracle을 사용하기 위해 관련 패키지를 추가했습니다.
Gradle
- testImplementation "org.testcontainers:oracle-xe:1.18.0"

Maven

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>oracle-xe</artifactId>
    <version>1.18.0</version>
    <scope>test</scope>
</dependency>

Spock 테스트 플러그인 추가

Groovy 문법의 이점을 활용하고, 코드 수준에서 BDD를 사용할 수 있도록 하기 위해 Spock을 사용하여 통합 테스트를 작성하였습니다.

Gradle
- testImplementation "org.testcontainers:spock:1.18.0"

Maven

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>spock</artifactId>
    <version>1.18.0</version>
    <scope>test</scope>
</dependency>

테스트 작성

테스트 컨테이너 생성

테스트를 위해서는 의존성을 추가한 패키지의 컨테이너 객체를 생성하면 테스트를 위한 컨테이너를 도커 환경위에 구성할 수 있습니다.

OracleContainer oracle = new OracleContainer("gvenzl/oracle-xe:21-slim-faststart")
    .withDatabaseName("testDB")
    .withUsername("testUser")
    .withPassword("testPassword")

OracleContainer의 경우에는 JdbcDatabaseContainer의 하위 클래스로 구현되어 있으며 생성자로 받는 인수는 실제 도커에서 사용하는 도커이미지(Image)의 이름입니다. 위 코드에서는 oracle-xe:21-slim-faststart 를 사용합니다.

package org.testcontainers.containers;

public class OracleContainer extends JdbcDatabaseContainer<OracleContainer>
public OracleContainer(final DockerImageName dockerImageName) {
        super(dockerImageName);
        dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME);
        preconfigure();
    }
다른 도커이미지를 사용하고 싶다면 도커허브(hub.docker.com)에서 확인 바랍니다.

oci oracle xe image 참고 - https://hub.docker.com/r/gvenzl/oracle-xe

 

테스트 컨테이너를 통해 구성된 컨테이너가 동작하는지를 확인하기 위한 테스트는 아래와 같이 작성합니다.

@Testcontainers
class IntegrationTest extends Specification {

    @Shared
    OracleContainer oracleContainer = new OracleContainer("gvenzl/oracle-xe:21-slim-faststart")
    .withDatabaseName("testDB")
    .withUsername("testUser")
    .withPassword("testPassword")

    def "waits until postgres accepts jdbc connections"() {

        given: "a jdbc connection"
        HikariConfig hikariConfig = new HikariConfig()
        hikariConfig.setJdbcUrl(oracleContainer.jdbcUrl)
        hikariConfig.setUsername("foo")
        hikariConfig.setPassword("secret")
        HikariDataSource ds = new HikariDataSource(hikariConfig)

        when: "querying the database"
        Statement statement = ds.getConnection().createStatement()
        statement.execute("SELECT 1")
        ResultSet resultSet = statement.getResultSet()
        resultSet.next()

        then: "result is returned"
        int resultSetInt = resultSet.getInt(1)
        resultSetInt == 1

        cleanup:
        ds.close()
    }

}

테스트를 수행하면 아래와 같이 동작합니다.

 

Testcontainers 동작 예시

테스트가 실행될 때 도커 컨테이너가 두 개 동작합니다. 하나는 앞서 JdbcDatabaseContainer 객체 생성 시 생성자의 인수로 할당한 도커 이미지 기반의 컨테이너인 oracle-xe-21-slim-fastst로 테스트에 사용할 DBMS입니다. 다른 하나는 Moby Ryuk로 컨테이너, 네트워크, 볼륨, 이미지를 제거하는 데 사용됩니다.

moby-ryuk 참고 : https://github.com/testcontainers/moby-ryuk

Testcontainers 실행시 동작하는 두 개의 컨테이너


컨테이너 실행 명확히 하기

start() 메서드를 호출하여 도커 컨테이너를 명확하게 실행시키고 그 결과를 받아 볼 수 있습니다.

def "waits until oracle accepts jdbc connections"() {

    given:
    "a jdbc connection"
    oracleContainer.start() // 컨테이너의 명확한 동작
    HikariConfig hikariConfig = new HikariConfig()
    hikariConfig.setJdbcUrl(oracleContainer.jdbcUrl)
    hikariConfig.setUsername(oracleContainer.getUsername())

start() 메서드를 사용하면 내부적으로 doStart() 로직이 실행됩니다. 컨테이너가 정상적으로 동작하면 "Starting container: ..."과 같은 형태의 로그가 남겨집니다. 만약 실패한 경우에는 기본적으로 1회 재시도를 수행합니다. 재시도 후에도 실패하는 경우 "Container startup failed for image ..."와 같은 메시지를 포함하여 ContainerLaunchException 예외가 발생합니다.

 

start() 메서드 사용시 내부적으로 실행되는 컨테이너 정상 동작 로그

package org.testcontainers.containers;

public class GenericContainer<SELF extends GenericContainer<SELF>>
...

private int startupAttempts = 1;

...
@Override
    @SneakyThrows({ InterruptedException.class, ExecutionException.class })
    public void start() {
        if (containerId != null) {
            return;
        }
        Startables.deepStart(dependencies).get();
        // trigger LazyDockerClient's resolve so that we fail fast here and not in getDockerImageName()
        dockerClient.authConfig();
        doStart();
    }

    protected void doStart() {
        try {
            configure();

            Instant startedAt = Instant.now();

            logger().debug("Starting container: {}", getDockerImageName());

            AtomicInteger attempt = new AtomicInteger(0);
            Unreliables.retryUntilSuccess(
                startupAttempts,
                () -> {
                    logger()
                        .debug(
                            "Trying to start container: {} (attempt {}/{})",
                            getDockerImageName(),
                            attempt.incrementAndGet(),
                            startupAttempts
                        );
                    tryStart(startedAt);
                    return true;
                }
            );
        } catch (Exception e) {
            throw new ContainerLaunchException("Container startup failed for image " + getDockerImageName(), e);
        }
    }

 

그렇지만 꼭 start()를 호출할 필요는 없습니다. Testcontainers의 라이프사이클 내에서 객체가 생성될 때 자동으로 호출되고 있기 때문입니다.


테스트 데이터 삽입

integration_init.sql과 같은 스크립트를 작성하여 withInitScript()를 사용해 테스트에 필요한 테이블과 데이터를 추가하여 초기화를 할 수 있습니다.

init.sql 예시

create table testUser.BOOKS
(
    BOOK_SEQ NUMBER(20),
    TITLE    VARCHAR2(100),
    TEXT_1   VARCHAR2(100)
);

INSERT INTO testUser.BOOKS (BOOK_SEQ, TITLE, TEXT_1)
VALUES (1,'TEST1', 'TEST TEXT1');

초기화 스크립트 수행

@Testcontainers
class IntegrationTest extends Specification {

  @Shared
  OracleContainer oracleContainer = new OracleContainer("gvenzl/oracle-xe:21-slim-faststart")
      .withDatabaseName("testDB")
      .withUsername("testUser")
      .withPassword("testPassword")
      .withInitScript('db/init.sql') // Init Script 수행 추가

...

그러나 withInitScript()를 사용한 방법은 초기화 스크립트를 한 번만 사용할 수 있다는 제한점이 있습니다.
컨테이너 객체가 start()를 통해 동작하는 과정에서 withInitScript()를 통해 할당된 initScriptPath 값을 단 한번 사용하고 있기 때문입니다.

 

라이브러리 내부 코드를 자세히 살펴보면 GenericContainer를 상속받은 테스트 컨테이너 객체가 생성되어 실행되는 과정에서 start()를 호출하면, 이어서 tryStart()containerIsStarted()를 호출하는 것을 확인할 수 있습니다. 그리고 이 과정에서 GenericContainer를 상속받은 JdbcDatabaseContainer에서 구현한runInitScriptIfRequired()가 실행되며, 이때 initScriptPath 값을 사용하는 것을 알 수 있습니다.


우리가 사용한 OracleContainerJdbcDatabaseContainer를 상속받고 있고, 결국 GenericContainer를 상속하는 관계에 있습니다. 따라서 컨테이너 객체를 생성하면서 사용한 withInitScript()를 통해서는 초기화 스크립트를 한 번만 사용할 수 있게 됩니다.

참고: 초기화 스크립트 사용을 위한 내부 메서드 및 변수 호출 순서

GenericContainer::start() -> GenericContainer::doStart() -> GenericContainer::containerIsStarted() -> JdbcDatabaseContainer::runInitScriptIfRequired() -> JdbcDatabaseContainer::initScriptPath 사용

Testcontainers 의 OracleContainer 클래스 다이어그램

 

이런 단점은 withInitScript()을 통해 설정된 스크립트의 실행에 사용하는 ScriptUtilsrunInitScript()를 직접 호출하여 테스트 컨테이너에 초기화 스크립트를 적용함으로써 해결할 수 있습니다.

withInitScript()를 통해 할당된 initScriptPath의 값은 Testcontainers가 동작하는 생명주기 내에서 JdbcDatabaseContainer 객체의 runInitScriptIfRequired() 메서드를 통해 실행됩니다. 결국 이 메서드에서 ScriptUtilsrunInitScript()를 이용하고 있습니다.

package org.testcontainers.containers;
public abstract class JdbcDatabaseContainer<SELF extends JdbcDatabaseContainer<SELF>>
    extends GenericContainer<SELF>
    implements LinkableContainer
    // 1. Testcontainers의 GenericContainer는 tryStart()를 통해 containerIsStarted()를 호출한다.
    protected void containerIsStarted(InspectContainerResponse containerInfo) {
        runInitScriptIfRequired();
    }

    // 2. 스크립트는 ScriptUtils의 runInitScript()를 이용하여 수행된다. 
    protected void runInitScriptIfRequired() {
        if (initScriptPath != null) {
            ScriptUtils.runInitScript(getDatabaseDelegate(), initScriptPath);
        }
    }

ScriptUtilsrunInitScript()는 경로에 위치한 스크립트파일을 읽고 executeDatabaseScript() 메서드에 실행을 위임합니다.

package org.testcontainers.ext;

public abstract class ScriptUtils {

public static void runInitScript(DatabaseDelegate databaseDelegate, String initScriptPath) {
        try {
            URL resource = Thread.currentThread().getContextClassLoader().getResource(initScriptPath);
            if (resource == null) {
                resource = ScriptUtils.class.getClassLoader().getResource(initScriptPath);
                if (resource == null) {
                    LOGGER.warn("Could not load classpath init script: {}", initScriptPath);
                    throw new ScriptLoadException("Could not load classpath init script: " + initScriptPath + ". Resource not found.");
                }
            }

            String scripts = IOUtils.toString(resource, StandardCharsets.UTF_8);
            executeDatabaseScript(databaseDelegate, initScriptPath, scripts);
        } catch (IOException var4) {
            LOGGER.warn("Could not load classpath init script: {}", initScriptPath);
            throw new ScriptLoadException("Could not load classpath init script: " + initScriptPath, var4);
        } catch (ScriptException var5) {
            LOGGER.error("Error while executing init script: {}", initScriptPath, var5);
            throw new UncategorizedScriptException("Error while executing init script: " + initScriptPath, var5);
        }
    }

따라서, 테스트를 위한 DB 테이블의 생성과 데이터의 생성을 분리하여 적용은 아래와 같이 진행할 수 있습니다.

package com.ebaykorea.corner.api.config

...
import org.testcontainers.ext.ScriptUtils
import org.testcontainers.jdbc.JdbcDatabaseDelegate

@Testcontainers
class IntegrationTest extends Specification {

def "select data oracle by jdbc"() {

    given:
    "a jdbc connection"
    oracleContainer.start()
    var jdbcDatabaseDelegate = new JdbcDatabaseDelegate(oracleContainer, "")
    ScriptUtils.runInitScript(jdbcDatabaseDelegate, 'db/integration_init_table.sql')
    ScriptUtils.runInitScript(jdbcDatabaseDelegate, 'db/integration_init_data.sql')

저희는 내부적으로 이러한 방법을 스프링 프레임워크(Spring Framework)의 ApplicationContextInitializer를 활용하여 테이블 생성을 위한 Migration과 데이터 생성을 위한 Seed을 분리해서 통합 테스트에 적용하고 있습니다.


스프링 프레임워크에서 ApplicationContextInitializer로 선언된 객체를 @ContextConfiguration와 함께 사용하면 테스트를 수행할 때 초기화된 컨텍스트를 활용할 수 있습니다. 이 방법을 통해 통합 테스트에 필요한 테이블과 데이터를 준비하는 과정이 보다 간편해졌습니다.

public class TestcontainersInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {

    @Override
    public void initialize(@NotNull ConfigurableApplicationContext applicationContext) {
        this.addInDatasourceToEnvironment(applicationContext);

        var scriptHelper = new TestcontainersScriptHelper();

        CONTAINERS.values().forEach((jdbcDatabaseContainer) -> {
            try {
                if (scriptHelper.isReadyOrSetDefaultResources(jdbcDatabaseContainer)) {
                    scriptHelper.runMigrationScripts(jdbcDatabaseContainer);
                    scriptHelper.runSeedScripts(jdbcDatabaseContainer);
                }
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        });
    }

 

사용중인 Migrations와 Seeds


테스트 수행

테스트를 진행합니다. 이번 예시에서는 Spring Boot와 Spock 기반의 테스트를 수행합니다. 필요한 어노테이션을 본인의 프로젝트 성향에 맞춰서 기호에 따라 별도로 묶어서 facade 형태로 선언하는 것도 좋습니다.

 

아래의 예시에서는 위에서 사용한 ApplicationContextInitializer의 확장 객체인 TestcontainersInitializer를 사용하기 위해 @ContextConfiguration(initializers = {TestcontainersInitializer.class})를 추가해주었습니다.

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootTest
@AutoConfigureMockMvc
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@interface IntegrationTest {

}

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@IntegrationTest
@TestPropertySource(properties = {
        "spring.config.location=classpath:application-env.yaml",
})
@Import(DataSourceTestConfig.class)
@ContextConfiguration(initializers = {TestcontainersInitializer.class})
public @interface TestOnContainer {

}

통합테스트는 아래와 같이 작성할 수 있습니다.

@TestOnContainer
class BookStoreRestControllerSpec extends Specification {

  @Autowired
  private MockMvc mvc

  @Autowired(required = false)
  private BookStoreRestController bookStoreController

  def "Book 조회 API - /books/{bookSeq}"() {
    given:
    def bookSeq = "1"
    def request = MockMvcRequestBuilders.get("/books/" + bookSeq)

    when: "call request"
    def resultAction = mvc.perform(request).andDo(print())

    then: "Status is 200 and the response is 'Books1'"
    def response = resultAction.andExpect(status().isOk())
        .andReturn().response

    response.getContentType() == "application/json;charset=UTF-8"
    response.getContentAsString() == "{\"bookSeq\":\"1\",\"TITLE\":\"TEST1\",\"TEXT_1\":\"TEST TEXT1\"}]}"
  }

마무리하며

이번 글을 통해 통합 테스트의 개념과 Testcontainers 및 도커를 이용하여 테스트 작성 방법을 간략하게 살펴보았습니다.

그러나 Testcontainers는 많은 장점이 있음에도 불구하고, 몇 가지 단점도 존재합니다.

 

우선, 도커 컨테이너를 사용하여 테스트 환경을 구성하므로 로컬 환경의 리소스 고려가 필요합니다. 통합 테스트에 사용하는 모듈에 따라 추가적인 시스템 리소스(메모리, CPU, 디스크 공간)가 필요할 수 있으며, 동시에 여러 테스트를 실행할 경우 리소스 사용량이 급격히 증가할 수 있습니다. 리소스가 부족한 경우 테스트가 느리게 동작하거나 시스템이 간혹 멈추거나 실행되지 않을 수도 있습니다. 또한, 테스트 환경을 구성할 때 필요한 도커 이미지를 다운로드해야 하는데, 이미지 크기에 따라 다운로드 시간이 길어질 수 있습니다. 이로 인해 테스트 수행 시간이 증가할 수 있습니다.

 

그렇지만 충분한 리소스와 네트워크 환경이 확보된 상황이라면, Testcontainers를 사용하여 멱등성을 보장하는 결정적인 테스트를 작성함으로써 테스트의 신뢰성을 높일 수 있습니다. 이를 통해 전반적인 코드 품질과 프로젝트 생산성이 향상을 기대할 수 있다고 생각합니다.

댓글