본문 바로가기

SearchDeveloper/Java

Java 에서 테스트용 도커 컨테이너 띄우는 법 : TestContainer

도커 컨테이너에 접근하는 프로젝트에서 테스트할 때의 컨테이너는 어떤 걸 사용하시나요? 혹시 서버에서 실운영되고 있는 컨테이너를 바라보고 있나요? 엇 그러면 만약 어떠한 이유에 의해 컨테이너 안 애플리케이션의 설정이 변경되었다면... 그래서 어제와 같은 소스인데도 불구하고 테스트가 실패한다면... 억울해!!

 

멱등성(idempotent)
: 연산을 여러 번 적용하더라도 결괏값이 달라지지 않는 일

프로젝트 테스트 환경에서 멱등성은 중요한 속성입니다. 내가 짠 소스에 대해서만 테스트하는 것이 목적이기 때문에 외부 환경이 테스트 결과에 영향을 미쳐선 안되니까요. 그래서 독립적인 테스트 환경을 구축하기 위해 테스트할 때만 잠깐 사용할 컨테이너를 띄어주는 게 testContainer가 해주는 일입니다!

 

디펜던시

testCompile("org.testcontainers:testcontainers:1.12.4")

 

먼저 테스트로 띄워주고 싶은 컨테이너 설정을 보겠습니다

docker-compose.yml

version: "2"
services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:7.8.1
    environment:
      - discovery.type=single-node
      - http.host=0.0.0.0
    ports:
      - 9200:9200
    volumes:
      - ./dict:/usr/share/elasticsearch/config/dict

elasticsearch 7.8.1 버전에 포트 바인딩은 9200, 볼륨 마운트는 ./dict 를 컨테이너 안 /usr/share/elasticsearch/config/dict 과 시켜주고 있네요

 

이제 자바 테스트 코드로 컨테이너를 어떻게 띄워주는지 보겠습니다. Junit5 코드까지 추가되어 뭐가 조금 더 붙었지만 메소드 안의 내용만 보시면 됩니다.

import com.github.dockerjava.api.model.*;
import org.junit.jupiter.api.extension.AfterAllCallback;
import org.junit.jupiter.api.extension.BeforeAllCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.images.builder.ImageFromDockerfile;

public class ContainerExtension implements BeforeAllCallback, AfterAllCallback {
    static GenericContainer<?> esContainer;

    @Override
    public void beforeAll(ExtensionContext context) throws Exception {
        esContainer = new GenericContainer<>(new ImageFromDockerfile().withDockerfileFromBuilder(
                builder -> builder.from("docker.elastic.co/elasticsearch/elasticsearch:7.8.1")
                        .run("bin/elasticsearch-plugin", "install", "analysis-nori")
                        .build()
        ));

        esContainer
                .withEnv("discovery.type", "single-node")
                .withEnv("http.host", "0.0.0.0")
                .withFileSystemBind("./dict", "/usr/share/elasticsearch/config/dict", BindMode.READ_ONLY)
                .addExposedPorts(9200)
                ;

        esContainer.start();

        String host = String.format("http://%s:%s", esContainer.getContainerIpAddress(), esContainer.getMappedPort(9200));
        System.out.println(host); // http://localhost:32888
    }

    @Override
    public void afterAll(ExtensionContext context) {
        esContainer.close();
    }
}
  • builder -> builder.from("docker.elastic.co/elasticsearch/elasticsearch:7.8.1") : 이미지 지정
  • .run("bin/elasticsearch-plugin", "install", "analysis-nori") :
    • 이미지 띄우기 전 실행할 커맨드 
    • 엘라스틱서치 플러그인은 기동 전에 미리 설치해야하기 때문에 추가
  • .withEnv("discovery.type", "single-node") : 컨테이너 안 환경변수 
  • .withFileSystemBind("./dict", "/usr/share/elasticsearch/config/dict", BindMode.READ_ONLY) : 디렉토리 마운트
  • .addExposedPorts(9200) : 포트 바인딩 (컨테이너 안에서 노출할 포트 지정)
  • esContainer.getContainerIpAddress() : 컨테이너에 접근할 수 있는 IP
  • esContainer.getMappedPort(9200) : 컨테이너 안 9200 포트에 바인딩 된 외부 포트 반환
  • esContainer.start() : 드디어 컨테이너 띄우기
  • esContainer.stop() : 컨테이너 내리기

이 경우에는 사용 가능한 포트를 찾아 컨테이너 내부의 9200 포트와 랜덤으로 바인딩시켜줍니다. 랜덤말고 지정된 포트를 사용하고 싶으시다구요? 전 이렇게 했습니다

 

import com.github.dockerjava.api.model.*;
import org.junit.jupiter.api.extension.AfterAllCallback;
import org.junit.jupiter.api.extension.BeforeAllCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.images.builder.ImageFromDockerfile;

public class ContainerExtension implements BeforeAllCallback, AfterAllCallback {
    static GenericContainer<?> esContainer;

    @Override
    public void beforeAll(ExtensionContext context) throws Exception {
        esContainer = new GenericContainer<>(new ImageFromDockerfile().withDockerfileFromBuilder(
                builder -> builder.from("docker.elastic.co/elasticsearch/elasticsearch:7.8.1")
                        .run("bin/elasticsearch-plugin", "install", "analysis-nori")
                        .build()
        ));

        esContainer
                .withEnv("discovery.type", "single-node")
                .withEnv("http.host", "0.0.0.0")
                .withCreateContainerCmdModifier(command -> {
                            HostConfig hostConfig = new HostConfig();
                            hostConfig.withBinds(new Bind("./dict", new Volume("/usr/share/elasticsearch/config/dict")));
                            hostConfig.withPortBindings(new PortBinding(Ports.Binding.bindPort(32880), new ExposedPort(9200)));
                            command.withHostConfig(hostConfig);
                        }
                );
        esContainer.start();

        String host = String.format("http://%s:%s", esContainer.getContainerIpAddress(), esContainer.getMappedPort(9200));
        System.out.println(host); // http://localhost:32880
    }

    @Override
    public void afterAll(ExtensionContext context) {
        esContainer.close();
    }
}

달라진 부분은 이 부분입니다.

.withCreateContainerCmdModifier(command -> {
            HostConfig hostConfig = new HostConfig();
            hostConfig.withBinds(new Bind("./dict", new Volume("/usr/share/elasticsearch/config/dict")));
            hostConfig.withPortBindings(new PortBinding(Ports.Binding.bindPort(32880), new ExposedPort(9200)));
            command.withHostConfig(hostConfig);
        });

withPortBindings() 로 직접 매핑할 포트를 지정할 수 있습니다.

다만 withCreateContainerCmdModifier() 를 적용하고나니 withFileSystemBind() 로는 디렉토리 마운트가 안 먹혀서 hostConfig.withBinds() 로 변경했습니다. 

 

자 이제 테스트를 실행하면,

특정 조건일 때 이미지를 지워주는 testcontainers-* 라는 이름의 testcontainers/ryuk 이미지 컨테이너가 먼저 올라가고, 곧바고 랜덤 이름(happy_thompson)의 엘라스틱 이미지가 올라갑니다. 지정된 포트 32880 가 보이시죠?

 

Java 에서 테스트용 도커 컨테이너 띄우는 법 : TestContainer

 

글 읽어주셔서 언제나 감사합니다. 좋은 피드백, 개선 피드백 너무나도 환영합니다.