반응형

들어가며

Testcontainers?

  • Docker 컨테이너를 활용한 일회용 인스턴스를 제공하는 JUnit 테스트 라이브러리
  • MySQL 같은 데이터베이스의 컨테이너 인스턴스를 사용하여 데이터 액세스 계층 코드를 테스트할 수 있다.

전제조건

Quick Start

JUnit 5 Quick Start

dependencyManagement {
    imports {
        mavenBom "org.testcontainers:testcontainers-bom:1.15.3"
    }
}

dependencies {
    ...
    testImplementation 'org.testcontainers:junit-jupiter'
}
@Testcontainers
public class RedisTestContainers {
    @Container
    public GenericContainer redis = new GenericContainer(DockerImageName.parse("redis:5.0.3-alpine")).withExposedPorts(6379); // 각 테스트에 새 컨테이너로 테스트
    // public static GenericContainer redis = new GenericContainer(DockerImageName.parse("redis:5.0.3-alpine")).withExposedPorts(6379); // 모든 테스트에 한 컨테이너로 테스트

    @Test
    public void testRedisContainer() {
        assertEquals("localhost", redis.getHost());
        assertTrue(redis.getFirstMappedPort() > 0);
    }
}

Networking and communicating with containers

Exposing container ports to the host

@Container
private GenericContainer<?> container = new GenericContainer<>(DockerImageName.parse("testcontainers/helloworld:1.1.0"))
    .withExposedPorts(8080, 8081); // 컨테이너 관점에서의 포트
Integer firstMappedPort = container.getMappedPort(8080); // 호스트에서 접근 가능한 포트
Integer secondMappedPort = container.getMappedPort(8081);
Integer firstMappedPort = container.getFirstMappedPort(); // 하나의 포트를 노출하는 경우 편리하게 호스트 포트를 가져올 수 있는 메소드

Getting the container host

String ipAddress = container.getHost(); // 로컬PC일 경우 localhost

Executing commands

Container startup command

@Container
private GenericContainer<?> container = new GenericContainer<>(DockerImageName.parse("redis"))
    .withExposedPorts(7777)
    .withEnv("API_TOKEN", "foo") // Environment Variables 설정
    .withCommand("redis-server --port 7777"); // 컨테이너 실행시 command 실행

Executing a command

// 컨테이너 안에서 command 실행
org.testcontainers.containers.Container.ExecResult result1 = container.execInContainer("ls", "-al", "/");
org.testcontainers.containers.Container.ExecResult result2 = container.execInContainer("printenv");

assertTrue(result1.getStdout().contains(".dockerenv"));
assertEquals(0, result1.getExitCode());

assertTrue(result2.getStdout().contains("API_TOKEN=foo"));
assertEquals(0, result2.getExitCode());

Files and volumes

File mapping

@Container
private GenericContainer<?> container = new GenericContainer<>(DockerImageName.parse("redis"))
    .withFileSystemBind( // 파일 시스템의 파일/디렉토리를 컨테이너 볼륨으로 매핑
        Paths.get(System.getProperty("user.home"), "example.txt").toString(),
        "/example.txt",
        BindMode.READ_ONLY
    );

Volume mapping

@Container
private GenericContainer<?> container = new GenericContainer<>(DockerImageName.parse("redis"))
    .withClasspathResourceMapping( // classpath의 파일/디렉토리를 컨테이너의 볼륨으로 매핑
        "example.txt",
        "/example.txt",
        BindMode.READ_ONLY
    );

Waiting for containers to start or be ready

Wait Strategies

@Container
private GenericContainer<?> container = new GenericContainer<>(DockerImageName.parse("nginx"))
    // .withStartupTimeout(Duration.ofSeconds(60)) // 대기 시간 설정
    .withExposedPorts(80); // 해당 포트가 수신될 때까지 60초동안 대기

HTTP Wait strategy examples

@Container
private GenericContainer<?> container = new GenericContainer<>(DockerImageName.parse("nginx"))
    .waitingFor( 
        // HTTP 요청 응답 결과가 아래와 같을 때까지 대기
        Wait.forHttp("/")
            .forStatusCode(200)
            // .forStatusCodeMatching(code -> Set.of(200, 201).contains(code))
    )
    .withExposedPorts(80);
@Container
private GenericContainer<?> container = new GenericContainer<>(DockerImageName.parse("redis"))
    .waitingFor(
        // 아래 로그가 출력될 때까지 대기
        Wait.forLogMessage(".*Ready to accept connections.*\\n", 1)
    )
    .withExposedPorts(6379);

Startup check Strategies

@Container
private GenericContainer<?> container = new GenericContainer<>(DockerImageName.parse("busybox:1.31.1"))
    .withCommand(String.format("echo %s", "hello"))
    .withStartupCheckStrategy( // 잠시만 실행되고 저절로 종료되는 컨테이너일 경우 종료 코드 0으로 컨테이너가 종료되면 성공
        new OneShotStartupCheckStrategy().withTimeout(Duration.ofSeconds(3))
    );
@Container
private GenericContainer<?> container = new GenericContainer<>(DockerImageName.parse("busybox:1.31.1"))
    .withCommand("sh", "-c", String.format("sleep 5 && echo \"%s\"", "hello"))
    .withStartupCheckStrategy( // 장기 실행 작업이 컨테이너에서 일어날 경우 컨테이너 커맨드 성공/실패할 때까지 대기
        new IndefiniteWaitOneShotStartupCheckStrategy()
    );
@Container
private GenericContainer<?> container = new GenericContainer<>(DockerImageName.parse("busybox:1.31.1"))
    .withCommand("sh", "-c", String.format("sleep 5 && echo \"%s\"", "hello"))
    .withStartupCheckStrategy( // 정의된 최소 기간 동안 실행중인지 확인
        new MinimumDurationRunningStartupCheckStrategy(Duration.ofSeconds(1))
    );

Accessing container logs

Reading all logs (from startup time to present)

String logs = container.getLogs();

Streaming logs

// slf4j logger
container.followOutput(new Slf4jLogConsumer(logger));
// 컨테이너 출력 로그에 해당 문자열이 포함될 때까지 대기
WaitingConsumer consumer = new WaitingConsumer();
container.followOutput(consumer, OutputFrame.OutputType.STDOUT);
consumer.waitUntil(frame -> frame.getUtf8String().contains("Redis is starting"), 30, TimeUnit.SECONDS);

Creating images on-the-fly

Dockerfile from String, file or classpath resource

# ~/src/main/resources/Dockerfile
FROM alpine:3.14
RUN apk add --update nginx
CMD ["nginx","-g","daemon off;"]
@Container
private GenericContainer<?> container = new GenericContainer(
    // classpath의 Dockerfile을 실행
    new ImageFromDockerfile()
        .withFileFromClasspath("Dockerfile", "Dockerfile"))
    .withExposedPorts(80);

Dockerfile DSL

@Container
private GenericContainer<?> container = new GenericContainer(
    // Dockerfile을 코드에서 정의
    new ImageFromDockerfile()
        .withDockerfileFromBuilder(builder ->
            builder
                .from("alpine:3.14")
                .run("apk add --update nginx")
                .cmd("nginx", "-g", "daemon off;")
                .build()))
    .withExposedPorts(80);

Custom configuration

Configuration locations

  • 아래 순서에 따라 설정 파일을 로드한다.
    • 환경변수
    • 사용자 홈 디렉토리의 .testcontainers.properties
    • classpath의 testcontainers.properties
  • 환경변수 사용시 설정명은 대문자로 지정하고 앞에 TESTCONTAINERS_가 붙는다
    • ex) checks.disable → TESTCONTAINERS_CHECKS_DISABLE

Disabling the startup checks

  • Testcontainers는 컨테이너 실행하기 전에 여러 검사들을 수행하여 환경이 올바르게 구성되었는지 확인한다.
  • 속도를 높이기 위해 이 검사를 비활성화할 수 있다.
    checks.disable=true
    

Customizing Docker host detection

docker.client.strategy=org.testcontainers.dockerclient.EnvironmentAndSystemPropertyClientProviderStrategy
docker.host=tcp\://my.docker.host\:1234     # Equivalent to the DOCKER_HOST environment variable. Colons should be escaped.
docker.tls.verify=1                         # Equivalent to the DOCKER_TLS_VERIFY environment variable
docker.cert.path=/some/path                 # Equivalent to the DOCKER_CERT_PATH environment variable

Docker Compose Module

Example

# ~/src/test/resources/docker-compose.yml
nginx:
  image: nginx
redis:
  image: redis
@Container
private DockerComposeContainer<?> container = new DockerComposeContainer(new File("src/test/resources/docker-compose.yml"))
    .withExposedService("nginx_1", 80)
    .withExposedService("redis_1", 6379);

Accessing a container from tests

String nginxUrl = container.getServiceHost("nginx_1", 80) + ":" + container.getServicePort("nginx_1", 80);
String redisUrl = container.getServiceHost("redis_1", 6379) + ":" + container.getServicePort("redis_1", 6379);

Startup timeout

@Container
private DockerComposeContainer<?> container = new DockerComposeContainer(new File("src/test/resources/docker-compose.yml"))
    .withExposedService("nginx_1", 80, Wait.forHttp("/").forStatusCode(200))
    .withExposedService("redis_1", 6379, Wait.forListeningPort().withStartupTimeout(Duration.ofSeconds(30)));

MySQL 테스트

dependencies

dependencies {
    ...
    implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
    implementation 'mysql:mysql-connector-java'
}

init.sql

  • 경로 : ~/src/test/resources/init.sql
DROP TABLE IF EXISTS User;
CREATE TABLE User
(
    seq  BIGINT AUTO_INCREMENT,
    name VARCHAR(50),
    PRIMARY KEY (seq)
);

MySqlContainerInitializer

public class MySqlContainerInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
    @Override
    public void initialize(ConfigurableApplicationContext context) {
        GenericContainer<?> container = new GenericContainer<>(DockerImageName.parse("mysql:5.7.22"))
            .withEnv("TZ", "Asia/Seoul")
            .withEnv("MYSQL_DATABASE", "test")
            .withEnv("MYSQL_USER", "test")
            .withEnv("MYSQL_PASSWORD", "test")
            .withEnv("MYSQL_ROOT_PASSWORD", "test")
            .withCommand(
                "--character-set-server=utf8mb4",
                "--collation-server=utf8mb4_unicode_ci",
                "--skip-character-set-client-handshake",
                "--default-time-zone=+09:00"
            )
            .withClasspathResourceMapping("init.sql", "/docker-entrypoint-initdb.d/init.sql", BindMode.READ_ONLY)
            .withExposedPorts(3306);

        container.start();

        Map<String, String> properties = Map.of(
            "spring.datasource.url", String.format("jdbc:mysql://%s:%d/test?characterEncoding=UTF-8&serverTimezone=UTC&useSSL=false", container.getHost(), container.getFirstMappedPort()),
            "spring.datasource.username", "test",
            "spring.datasource.password", "test",
            "spring.datasource.driver-class-name", "com.mysql.cj.jdbc.Driver"
        );

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

MySqlTest

@SpringBootTest
@ContextConfiguration(initializers = {MySqlContainerInitializer.class})
public class MySqlTest {
    @Autowired
    private JdbcTemplate jdbcTemplate;

    @BeforeEach
    public void init() {
        jdbcTemplate.execute("TRUNCATE User");
        jdbcTemplate.update("INSERT INTO User (name) VALUES (?)", "john");
    }

    @Test
    public void testMySql() {
        String name = jdbcTemplate.queryForObject("SELECT name FROM User WHERE seq = ?", String.class, 1);

        assertEquals("john", name);
    }
}

Redis 테스트

dependencies

dependencies {
    ...
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}

RedisContainerInitializer

public class RedisContainerInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
    @Override
    public void initialize(ConfigurableApplicationContext context) {
        GenericContainer<?> container = new GenericContainer<>(DockerImageName.parse("redis:6.0.1"))
            .withEnv("TZ", "Asia/Seoul")
            .withExposedPorts(6379);

        container.start();

        Map<String, String> properties = Map.of(
            "spring.redis.host", container.getHost(),
            "spring.redis.port", String.valueOf(container.getFirstMappedPort())
        );

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

RedisTest

@SpringBootTest
@ContextConfiguration(initializers = {RedisContainerInitializer.class})
public class RedisTest {
    @Autowired
    private StringRedisTemplate redisTemplate;

    @BeforeEach
    public void clear() {
        redisTemplate.delete("test-key");
        redisTemplate.opsForValue().set("test-key", "Hello");
    }

    @Test
    public void testRedis() {
        String value = redisTemplate.opsForValue().get("test-key");

        assertEquals("Hello", value);
    }
}

Docker Compose를 활용한 다중 컨테이너 테스트

~/src/test/resources/docker-compose.yml

redis:
  image: redis:6.0.1
  ports:
    - "6379:6379"
  environment:
    TZ: Asia/Seoul
mysql:
  image: mysql:5.7.29
  ports:
    - "3306:3306"
  environment:
    TZ: Asia/Seoul
    MYSQL_ROOT_PASSWORD: test
    MYSQL_DATABASE: test
    MYSQL_USER: test
    MYSQL_PASSWORD: test
  volumes:
    - "./init.sql:/docker-entrypoint-initdb.d/init.sql"
  command:
    - "--character-set-server=utf8mb4"
    - "--collation-server=utf8mb4_unicode_ci"
    - "--skip-character-set-client-handshake"
    - "--default-time-zone=+09:00"

DockerComposeContainerInitializer

public class DockerComposeContainerInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
    private static final String REDIS_NAME = "redis_1";
    private static final int REDIS_PORT = 6379;
    private static final String MYSQL_NAME = "mysql_1";
    private static final int MYSQL_PORT = 3306;

    @Override
    public void initialize(ConfigurableApplicationContext context) {
        DockerComposeContainer<?> container = new DockerComposeContainer(new File("src/test/resources/docker-compose.yml"))
            .withExposedService(REDIS_NAME, REDIS_PORT)
            .withExposedService(MYSQL_NAME, MYSQL_PORT);

        container.start();

        Map<String, String> properties = Map.of(
            // redis
            "spring.redis.host", container.getServiceHost(REDIS_NAME, REDIS_PORT),
            "spring.redis.port", String.valueOf(container.getServicePort(REDIS_NAME, REDIS_PORT)),

            // mysql
            "spring.datasource.url", String.format("jdbc:mysql://%s:%d/test?characterEncoding=UTF-8&serverTimezone=UTC&useSSL=false", container.getServiceHost(MYSQL_NAME, MYSQL_PORT), container.getServicePort(MYSQL_NAME, MYSQL_PORT)),
            "spring.datasource.username", "test",
            "spring.datasource.password", "test",
            "spring.datasource.driver-class-name", "com.mysql.cj.jdbc.Driver"
        );

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

ContainerTest

@SpringBootTest
@ContextConfiguration(initializers = {DockerComposeContainerInitializer.class})
public class ContainerTest {
    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Autowired
    private StringRedisTemplate redisTemplate;

    @BeforeEach
    public void init() {
        redisTemplate.delete("test-key");
        redisTemplate.opsForValue().set("test-key", "Hello");

        jdbcTemplate.execute("TRUNCATE User");
        jdbcTemplate.update("INSERT INTO User (name) VALUES (?)", "john");
    }

    @Test
    public void testRedis() {
        String value = redisTemplate.opsForValue().get("test-key");

        assertEquals("Hello", value);
    }

    @Test
    public void testMySql() {
        String name = jdbcTemplate.queryForObject("SELECT name FROM User WHERE seq = ?", String.class, 1);

        assertEquals("john", name);
    }
}

참고

반응형

'Development > Spring' 카테고리의 다른 글

[Spring] spring-retry  (0) 2021.05.27
[Spring] DataSource  (0) 2021.05.19
[Spring] Server Sent Event(SSE)  (0) 2021.03.30
[Spring] H2 Database  (0) 2021.02.27
[Spring] Spring Cloud Feign  (0) 2020.12.27

+ Recent posts