Server/JUnit, Spock

JUnit - TestContainers 사용하는 방법! (+ 장단점 비교)

JaeHoney 2022. 6. 6. 19:29

CI / CD 자동화된 테스트

내가 회사에서 개발하는 서비스의 DB 환경은 아래와 같다. test 환경에서는 어떤 DBMS를 사용할 수 있는가?

  • prod, stage -> mysql 5.7
  • dev -> mysql 5.7
  • test -> ?

test 환경에서 h2 데이터베이스를 많이 사용한다. 이 경우 CI/CD에서 h2 데이터베이스를 사용해서 자동화된 테스트를 어렵지 않게 할 수 있다.

 

문제는 h2와 mysql은 다른 부분이 적잖게 존재한다. 가령 격리 수준, 전파 속성, 지원하는 SQL차이점이 많이 존재한다.

 

실제 운영환경과 유사하게 테스트하기 위해서는 test용 db도 mysql로 실행해야 한다. 그래서 고민을 하게 된다. 로컬에서는 도커나 로컬DB를 띄워서 테스트할 수 있지만 CI/CD의 자동화된 테스트에서는 어떻게 해야 할까?

해결 방안

해결 방법은 간단하다. 테스트용 DB를 하나 띄워서 배포하면 된다. 하지만 관리해야 하는 DB가 늘어나고 자원을 소모하게 된다. 추가로 여러개의 서비스가 동시에 배포될 때 테스트가 서로 간섭을 받을 수 있다.

 

다른 방법도 있는데 CI/CD 파이프라인을 추가해서 Test 이전에 Docker로 테스트용 DB를 띄우는 방법이다. 하지만 DB가 담긴 Container가 올라가는 시점과 테스트가 실행되는 시점을 맞추는 작업이 추가로 필요하게 된다. 그래서 나온 것이 TestContainers이다.

 

Testcontainers는 테스트에서 도커 컨테이너를 자동으로 띄워주고 테스트가 종료되면 컨테이너도 종료해주는 라이브러리이다.

  • 테스트 실행시 DB를 설정하거나 별도의 프로그램 또는 스크립트를 실행할 필요가 없다.
  • Production 환경에 가까운 테스트를 만들 수 있다.
  • 테스트 시 컨테이너를 생성, 삭제하기 때문에 테스트가 느려진다.

Testcontainers

Testcontainers를 사용하려면 먼저 의존성을 추가한다.

testImplementation "org.testcontainers:testcontainers:1.17.2"
testImplementation "org.testcontainers:junit-jupiter:1.17.2"

이제 테스트 코드에서 Testcontainers 라이브러리가 지원하는 컨테이너를 생성한 후 setup, teardown만 하면 된다.

@SpringBootTest
@ActiveProfiles("test")
class StudyServiceTest {

    static PostgreSQLContainer postgreSQLContainer = new PostgreSQLContainer()
            .withDatabaseName("testDB");

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

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

    @Test
    void createNewStudy() {
        // ... 생략
    }

    @Test
    void openStudy() {
        // ... 생략
    }

}

예제에서는 테스트가 실행될 때 postgreSQLContainer.start()가 실행되면서 도커로 테스트용 DB를 컨테이너로 띄우고 자동으로 연결하고 테스트가 종료될 때 postgreSQLContainer.stop()이 실행되면서 컨테이너를 종료한다.

 

추가적으로 해당 start()와 stop()을 생략할 수도 있는데 클래스에 @TestContainers, 컨테이너필드에 @Container 애노테이션을 붙이면 된다.

// ...생략
@TestContainers
class StudyServiceTest {

    @Container
    static PostgreSQLContainer postgreSQLContainer = new PostgreSQLContainer()
            .withDatabaseName("testDB");
            
    // ... 생략

}

다만 아직은 테스트가 무시(ignore)될텐데 간단한 데이터소스 설정을 하지 않아서 그렇다. 아래의 프로퍼티를 추가하자.

spring.datasource.url=jdbc:tc:postgresql:///testDB
spring.datasource.driver-class-name=org.testcontainers.jdbc.ContainerDatabaseDriver

원래 datasource-url은 jdbc:postgresql://{host}:{port}/{database} 형식으로 작성한다. 테스트 컨테이너를 사용하면 알아서 호스트와 비어있는 포트를 잡아서 테스트를 붙여준다. 즉 host와 port가 중요하지 않으므로 생략할 수 있다. username과 password도 필요 없다.

 

반면 url에 tc라는 path를 추가해야 하고 driver-class-name으로 테스트 컨테이너가 지원하는 드라이버 프록시를 잡아준다. 이제 테스트는 잘 실행된다.

전체 테스트를 실행하는 데 8초~10초 정도 소모되었다. 그런데 실제 테스트는 63ms밖에 소요되지 않았다. setup, teardown 하면서 컨테이너를 띄우고 종료하는 데 시간이 많이 소요됨을 알 수 있다.

 

추가로 예제에서는 컨테이너를 선언할 때 static을 사용했다. 컨테이너 필드를 static으로 선언하면 컨테이너를 한번 띄우고 모든 테스트 메서드가 해당 컨테이너를 공유해서 사용하게 된다. 만약 non-static으로 컨테이너를 선언하면 각 테스트 메서드마다 컨테이너를 새로 띄우게 된다.

GenericContainer

일반적인 DBMS의 경우 testcontainers가 지원해주는 모듈을 사용하면 된다. 지원되지 않는 모듈을 컨테이너로 띄워야 하거나 특정 이미지를 지정하려면 GenericContainer를 사용할 수 있다.

@Container
static GenericContainer postgreSQLContainer = new GenericContainer("postgres:13.3")
        .withExposedPorts(5432)
        .withEnv("PROGRES_DB", "testDB");

Wait

만약 컨테이너로 띄워야 하는 이미지가 앱처럼 구동하는 데 시간이 오래 걸린다면 앱이 구동되기도 전에 테스트가 실행되서 테스트가 깨지게 된다. 

 

이 때는 watingFor을 사용할 수 있다.

public GenericContainer ngix = new GenericContainer(DockerImageName.parse("nginx:1.9.4"))
        .withExposedPorts(80)
        .waitingFor(Wait.forHttp("/"));

위 코드는 "/" 경로에 GET 요청을 보내서 200코드가 반환될 때까지 테스트를 하지 않고 기다리는 코드이다.

 

그 밖에도 아래처럼 다양한 메서드를 사용해서 대기 전략을 설정할 수 있다.

  • Wait.forHttp("/") .forStatusCode(200).forStatusCode(301);
  • Wait.forHealthcheck();
  • Wait.forHttp("/all") .forStatusCodeMatching(it -> it >= 200 && it < 300 || it == 401);
  • Wait.forLogMessage("start");

LogConsumer

followOutput 메서드를 사용하면 컨테이너의 로그를 스트리밍해서 받아올 수 있다. 

// ...생략
@Slf4j
@TestContainers
class StudyServiceTest {

    @Container
    static PostgreSQLContainer postgreSQLContainer = new PostgreSQLContainer()
            .withDatabaseName("testDB");
            
    @BeforeAll
    static void beforeAll() {
        Slf4jLogConsumer logConsumer = new Slf4jLogConsumer(log);
        postgreSQLContainer.followOutput(logConsumer);
    }
            
    // ... 생략

}

위 예제는 앱 로그에 해당 컨테이너의 로그도 같이 볼 수 있게 된다.

Docker Compose 사용

테스트용 DB를 셋업할 때 docker-compose를 많이 사용한다. Testcontainers는 docker-compose를 사용해서 간단하게 컨테이너를 띄우는 기능을 제공한다.

@Container
static DockerComposeContainer dockerComposeContainer =
        new DockerComposeContainer(new File("src/test/resources/docker-compose.yml"));

주의할 점은 Docker-compose로 여러 개의 컨테이너를 띄우면 시간이 오래 걸리므로 Wait을 사용해서 컨테이너들이 사용 가능한 상태까지 기다리는 것이 좋다.

@Container
public static DockerComposeContainer dockerComposeContainer =
    new DockerComposeContainer(new File("src/test/resources/docker-compose.yml"))
            .withExposedService("redis_1", REDIS_PORT, Wait.forListeningPort())
            .withExposedService("elasticsearch_1", ELASTICSEARCH_PORT, 
                Wait.forHttp("/all")
                    .forStatusCode(200)
                    .forStatusCode(401)
                    .usingTls());

(Tip!) 추가로 테스트용 docker-compose에는 port 매핑을 하지 않는 것이 더 좋다. 그래야 충돌 가능성을 줄일 수 있다.

version: "3"

services:
  study-db:
    image: postgres
    ports:
      # - 5432:5432
      - 5432
    environment:
      POSTGRES_PASSWORD: study
      POSTGRES_USER: study
      POSTGRES_DB: study

실제 구현 코드

@ContextConfiguration(initializers = TestContainerTest.ContainerPropertyInitializer.class)
public class TestContainerTest {

    @Container
    static DockerComposeContainer<?> composeContainer;

    static {
        composeContainer = new DockerComposeContainer(new File("docker-compose.yml"))
                .withExposedService("slave", 3306, Wait.forListeningPort())
                .withExposedService("master", 3306, Wait.forListeningPort());

        composeContainer.start();
    }

    static class ContainerPropertyInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
        @Override
        public void initialize(ConfigurableApplicationContext context) {;
            Map<String, String> properties = new HashMap<>();
            properties.put("app.master.port", Integer.toString(composeContainer.getServicePort("master", 3306)));
            properties.put("app.slave.port", Integer.toString(composeContainer.getServicePort("slave", 3306)));

            TestPropertyValues.of(properties)
                    .applyTo(context);
        }
    }
}

 

동적인 외부 포트를 Test 프로퍼티로 삽입해서 Datasource가 연결되도록 처리한다.

 

(test의 yml에서 해당 port 설정은 지워준다)

마무리

Testcontainers를 사용하면 완벽히 고립된 자동화된 테스트를 Production과 동일한 환경으로 적용할 수 있다.

 

하지만, 테스트가 느려진다는 게 TDD에서는 굉장히 중요하다. TDD의 FIRST 원칙 초두에 나오는게 F(FAST)이다. 따라서 통합테스트에만 해당 라이브러리를 적용하는 방향을 적용한다거나 라이브러리 적용에 대해 신중하게 검토할 필요가 있어 보인다.


Reference