적용한 이유
'Test는 어느 환경에서나 미리 구축할 필요없이 작동해야한다.'
라는 개념과 어디에서나 자동으로 동일한 테스트 환경을 구축하는 라이브러리가 없을까? 라는 생각으로 출발하여 Test 실행시 서버 테스트에 필요한 환경을 자동으로 구축해주는 라이브러리를 찾아보았습니다.
역시나 TestContainer 라는 착한 친구가 이미 만들어져 있었죠.
이 라이브러리를 사용하면 Docker container 를 통하여 테스트 실행시 자동으로 환경을 구축해 주고 테스트가 끝나면 자동으로 정리까지 해줍니다.
이를 통해 h2로 테스트하고 배포는 Mysql로 하면서 몰려오는 찝찝함 또한 해결이 가능합니다.
Testcontainers의 장점으로는 다음이 있습니다.
개발자 친화적: 테스트 코드를 작성하는데 필요한 의존성과 환경 구성을 최소화하여, 개발자가 테스트에만 집중할 수 있게 해줍니다.
환경 독립성: 도커사용으로 로컬환경, CI/CD 파이프라인, 프로덕션 환경 등에서 동일한 테스트 환경을 구축할 수 있습니다.
높은 확장성: 다양한 서비스와 애플리케이션을 지원하므로, 필요한 경우 새로운 서비스 추가와 기존 서비스를 업데이트하기 쉽습니다.
적용
build.gradle
dependencies {
...
// TestContainer 기본 의존성
testImplementation "org.testcontainers:testcontainers:1.20.1"
// MySQL 의존성
testImplementation 'org.testcontainers:mysql:1.20.1'
// Redis 의존성
testImplementation "com.redis.testcontainers:testcontainers-redis-junit:1.6.4"
// Kafka 의존성
testImplementation "org.testcontainers:kafka:1.20.1"
...
}
TestContainerConfig.java
package org.kakaopay.coffee.config;
import jakarta.validation.constraints.NotNull;
import java.io.File;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.extension.BeforeAllCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.springframework.boot.test.util.TestPropertyValues;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.test.context.ActiveProfiles;
import org.testcontainers.containers.DockerComposeContainer;
import org.testcontainers.containers.KafkaContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.utility.DockerImageName;
@ActiveProfiles("test")
public class TestContainerConfig implements BeforeAllCallback {
private static final DockerComposeContainer<?> DOCKER_COMPOSE = new DockerComposeContainer<>(
new File("src/test/resources/docker-compose.yml"))
.withExposedService("mysql", 3306, Wait.forLogMessage(".*ready for connections.*", 1))
.withExposedService("redis", 6379,
Wait.forLogMessage(".*Ready to accept connections.*", 1));
private static final KafkaContainer KAFKA_CONTAINER = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.5.0"));
@Override
public void beforeAll(ExtensionContext extensionContext) throws Exception {
// MySQL 컨테이너 시작
// Redis 컨테이너 시작
DOCKER_COMPOSE.start();
// Kafka 컨테이너 시작
KAFKA_CONTAINER.start();
}
public static class IntegrationTestInitializer implements
ApplicationContextInitializer<ConfigurableApplicationContext> {
@Override
public void initialize(@NotNull ConfigurableApplicationContext applicationContext) {
Map<String, String> properties = new HashMap<>();
// 각 서비스의 연결 정보 설정
setKafkaProperties(properties);
setDatabaseProperties(properties);
setRedisProperties(properties);
// 애플리케이션 컨텍스트에 속성값 적용
TestPropertyValues.of(properties).applyTo(applicationContext);
}
// Kafka 연결 정보 설정
private void setKafkaProperties(Map<String, String> properties) {
properties.put("spring.kafka.bootstrap-servers", KAFKA_CONTAINER.getBootstrapServers());
}
// 데이터베이스 연결 정보 설정
private void setDatabaseProperties(Map<String, String> properties) {
String rdbmsHost = DOCKER_COMPOSE.getServiceHost("mysql", 3306);
int rdbmsPort = DOCKER_COMPOSE.getServicePort("mysql", 3306);
properties.put("spring.datasource.url",
"jdbc:mysql://" + rdbmsHost + ":" + rdbmsPort + "/coffee-test");
properties.put("spring.datasource.username", "root");
properties.put("spring.datasource.password", "1234");
}
// Redis 연결 정보 설정
private void setRedisProperties(Map<String, String> properties) {
String redisHost = DOCKER_COMPOSE.getServiceHost("redis", 6379);
Integer redisPort = DOCKER_COMPOSE.getServicePort("redis", 6379);
properties.put("spring.data.redis.host", redisHost);
properties.put("spring.data.redis.port", redisPort.toString());
}
}
}
docker-compose.yml
# Use root/example as user/password credentials
version: '3.1'
services:
mysql:
image: mysql:latest
restart: always
environment:
MYSQL_DATABASE: coffee-test
MYSQL_ROOT_PASSWORD: 1234
ports:
- 33066:3306 # 테스트 환경에서 호스트 매칭(예: 3306:3306) 을 생략하면 포트 충돌을 피할 수 있습니다. - 외부와 포트와 연결한다면 외부 클라이언트에서 접속이 가능합니다
redis:
image: redis:6
restart: always
ports:
- 63799:6379 # 테스트 환경에서 호스트 매칭(예: 6379:6379) 을 생략하면 포트 충돌을 피할 수 있습니다. - 외부와 포트와 연결한다면 외부 클라이언트에서 접속이 가능합니다
사용법
@ExtendWith(TestContainerConfig.class) 로 해당 Config 파일을 사용하고
@ContextConfiguration(initializers = TestContainerConfig.IntegrationTestInitializer.class) 를 통하여 yaml 설정을 덮어 씁니다.
package org.kakaopay.coffee.api.order;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.tuple;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.kakaopay.coffee.api.order.request.OrderServiceRequest;
import org.kakaopay.coffee.api.order.response.OrderResponse;
import org.kakaopay.coffee.config.TestContainerConfig;
import org.kakaopay.coffee.db.menu.MenuEntity;
import org.kakaopay.coffee.db.menu.MenuJpaManager;
import org.kakaopay.coffee.db.menu.MenuJpaReader;
import org.kakaopay.coffee.db.order.OrderJpaManager;
import org.kakaopay.coffee.db.order.OrderJpaReader;
import org.kakaopay.coffee.db.ordermenu.OrderMenuEntity;
import org.kakaopay.coffee.db.ordermenu.OrderMenuJpaManager;
import org.kakaopay.coffee.db.ordermenu.OrderMenuJpaReader;
import org.kakaopay.coffee.db.user.UserEntity;
import org.kakaopay.coffee.db.user.UserJpaManager;
import org.kakaopay.coffee.db.user.UserJpaReader;
import org.kakaopay.coffee.db.userpointhistory.UserPointHistoryEntity;
import org.kakaopay.coffee.db.userpointhistory.UserPointHistoryJpaManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.ContextConfiguration;
@ActiveProfiles("test")
@SpringBootTest
@ExtendWith(TestContainerConfig.class)
@ContextConfiguration(initializers = TestContainerConfig.IntegrationTestInitializer.class)
class OrderServiceTest {
private Long MENU_ID = null;
private final Integer CONCURRENT_COUNT = 10;
private final int INIT_USER_POINT = 10000;
spring boot test log
로그를 잘 보시면 Docker container를 올리는 과정에 대한 로그를 확일할 수 있습니다.
...
18:39:13.253 [main] INFO org.testcontainers.DockerClientFactory -- Testcontainers version: 1.20.1
18:39:13.328 [main] INFO org.testcontainers.dockerclient.DockerClientProviderStrategy -- Loaded org.testcontainers.dockerclient.UnixSocketClientProviderStrategy from ~/.testcontainers.properties, will try it first
18:39:13.444 [main] INFO org.testcontainers.dockerclient.DockerClientProviderStrategy -- Found Docker environment with local Unix socket (unix:///var/run/docker.sock)
18:39:13.445 [main] INFO org.testcontainers.DockerClientFactory -- Docker host IP address is localhost
18:39:13.453 [main] INFO org.testcontainers.DockerClientFactory -- Connected to docker:
Server Version: 26.1.4
API Version: 1.45
Operating System: OrbStack
Total Memory: 15966 MB
18:39:13.453 [main] INFO org.testcontainers.utility.RyukResourceReaper -- Ryuk started - will monitor and terminate Testcontainers containers on JVM exit
18:39:13.453 [main] INFO org.testcontainers.DockerClientFactory -- Checking the system...
18:39:13.453 [main] INFO org.testcontainers.DockerClientFactory -- ✔︎ Docker server version should be at least 1.6.0
18:39:13.469 [main] INFO tc.testcontainers/ryuk:0.8.1 -- Creating container for image: testcontainers/ryuk:0.8.1
18:39:13.556 [main] INFO org.testcontainers.utility.RegistryAuthLocator -- Credential helper/store (docker-credential-osxkeychain) does not have credentials for https://index.docker.io/v1/
18:39:13.692 [main] INFO tc.testcontainers/ryuk:0.8.1 -- Container testcontainers/ryuk:0.8.1 is starting: a4d620da1113b2d46f28a94f9b4ff4c4ed423c114af995f9600197de7c3d560d
18:39:13.894 [main] INFO tc.testcontainers/ryuk:0.8.1 -- Container testcontainers/ryuk:0.8.1 started in PT0.424823S
18:39:13.896 [main] INFO org.testcontainers.containers.ComposeDelegate -- Preemptively checking local images for 'mysql:latest', referenced via a compose file or transitive Dockerfile. If not available, it will be pulled.
18:39:13.897 [main] INFO org.testcontainers.containers.ComposeDelegate -- Preemptively checking local images for 'redis:6', referenced via a compose file or transitive Dockerfile. If not available, it will be pulled.
18:39:13.899 [main] INFO tc.docker/compose:1.29.2 -- Copying all files in /Users/itbuddy/github/kakaopay-coffe/src/test/resources into the container
18:39:13.900 [main] INFO tc.docker/compose:1.29.2 -- Creating container for image: docker/compose:1.29.2
18:39:14.216 [main] INFO tc.docker/compose:1.29.2 -- Container docker/compose:1.29.2 is starting: 14b6d71b42e36249d0a2d8aea847fd9149200db7052223b719b4e1b631015ce3
18:39:14.308 [main] WARN tc.docker/compose:1.29.2 -- The architecture 'amd64' for image 'docker/compose:1.29.2' (ID sha256:32d8a4638cd83922fdd94cadf4f1850b595d29757488c73adf05f4b99ebd1318) does not match the Docker server architecture 'arm64'. This will cause the container to execute much more slowly due to emulation and may lead to timeout failures.
18:39:15.400 [main] INFO tc.docker/compose:1.29.2 -- Container docker/compose:1.29.2 started in PT1.499759S
18:39:15.401 [main] INFO tc.docker/compose:1.29.2 -- Docker Compose container is running for command: up -d
18:39:15.404 [docker-java-stream-2029356766] INFO tc.docker/compose:1.29.2 -- STDERR: Creating network "a8zq7zhzpnah_default" with the default driver
18:39:15.404 [docker-java-stream-2029356766] INFO tc.docker/compose:1.29.2 -- STDERR: Creating a8zq7zhzpnah_redis_1 ...
18:39:15.405 [docker-java-stream-2029356766] INFO tc.docker/compose:1.29.2 -- STDERR: Creating a8zq7zhzpnah_mysql_1 ...
18:39:15.405 [docker-java-stream-2029356766] INFO tc.docker/compose:1.29.2 -- STDERR: Creating a8zq7zhzpnah_mysql_1 ... done
18:39:15.405 [docker-java-stream-2029356766] INFO tc.docker/compose:1.29.2 -- STDERR: Creating a8zq7zhzpnah_redis_1 ... done
18:39:15.408 [main] INFO tc.docker/compose:1.29.2 -- Docker Compose has finished running
18:39:15.408 [main] INFO tc.alpine/socat:1.7.4.3-r0 -- Creating container for image: alpine/socat:1.7.4.3-r0
18:39:15.442 [main] INFO tc.alpine/socat:1.7.4.3-r0 -- Container alpine/socat:1.7.4.3-r0 is starting: ea9eb968711ccf44106d2ec3a184809d58935c41d60e33ea90ecbcc2a382768e
18:39:15.618 [main] INFO tc.alpine/socat:1.7.4.3-r0 -- Container alpine/socat:1.7.4.3-r0 started in PT0.209128S
18:39:19.349 [main] INFO tc.confluentinc/cp-kafka:7.5.0 -- Creating container for image: confluentinc/cp-kafka:7.5.0
18:39:19.409 [main] INFO tc.confluentinc/cp-kafka:7.5.0 -- Container confluentinc/cp-kafka:7.5.0 is starting: 96158a8797752a2fdc11717b7874d74255ffd5dd871fbfa9aa89cc0fe941a9c7
18:39:21.672 [main] INFO tc.confluentinc/cp-kafka:7.5.0 -- Container confluentinc/cp-kafka:7.5.0 started in PT2.323717S
18:39:21.680 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [org.kakaopay.coffee.api.user.UserServiceTest]: UserServiceTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
18:39:21.683 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration org.kakaopay.coffee.CoffeeApplication for test class org.kakaopay.coffee.api.user.UserServiceTest$Login
18:39:21.686 [main] INFO org.testcontainers.containers.ComposeDelegate -- Preemptively checking local images for 'mysql:latest', referenced via a compose file or transitive Dockerfile. If not available, it will be pulled.
18:39:21.686 [main] INFO org.testcontainers.containers.ComposeDelegate -- Preemptively checking local images for 'redis:6', referenced via a compose file or transitive Dockerfile. If not available, it will be pulled.
18:39:21.686 [main] INFO tc.docker/compose:1.29.2 -- Copying all files in /Users/itbuddy/github/kakaopay-coffe/src/test/resources into the container
18:39:21.686 [main] INFO tc.docker/compose:1.29.2 -- Creating container for image: docker/compose:1.29.2
18:39:21.778 [main] INFO tc.docker/compose:1.29.2 -- Container docker/compose:1.29.2 is starting: 7ca84f83d1799d311632c78faeee10aed080ad8cb57127f9aec64934019529ac
18:39:21.865 [main] WARN tc.docker/compose:1.29.2 -- The architecture 'amd64' for image 'docker/compose:1.29.2' (ID sha256:32d8a4638cd83922fdd94cadf4f1850b595d29757488c73adf05f4b99ebd1318) does not match the Docker server architecture 'arm64'. This will cause the container to execute much more slowly due to emulation and may lead to timeout failures.
18:39:22.660 [main] INFO tc.docker/compose:1.29.2 -- Container docker/compose:1.29.2 started in PT0.973538S
18:39:22.661 [main] INFO tc.docker/compose:1.29.2 -- Docker Compose container is running for command: up -d
18:39:22.662 [docker-java-stream--1092898408] INFO tc.docker/compose:1.29.2 -- STDERR: a8zq7zhzpnah_redis_1 is up-to-date
18:39:22.662 [docker-java-stream--1092898408] INFO tc.docker/compose:1.29.2 -- STDERR: a8zq7zhzpnah_mysql_1 is up-to-date
18:39:22.664 [main] INFO tc.docker/compose:1.29.2 -- Docker Compose has finished running
.....
적용후 장단점
장점
CI/CD 에서도 문제없이 작동한다는 점 (물론 설정 미스 때문에 한번에 성공하지는 못했습니다.)
테스트를 위해 따로 mysql, Kafka, redis를 구축할 필요가 없어졌습니다.
h2 로 테스트 중에는 디버깅으로 진행 단계를 멈추고 데이터 베이스를 확인 하고 싶어도 h2 서버에 접근할 수가 없었지만, TestContainer는 외부 포트 설정만 해주면 Datagrip으로 접근하여 데이터를 두눈으로 직접 확인이 가능합니다.
단점
테스트가 속도가 눈에 띄게 느려졌습니다. 테스트 환경구성에 필요한 Docker Image를 이미 모두 다운 받았더라도 컨테이너 4개 기준 7초 정도 걸리고, 도커 이미지를 모두 새로 다운 받아야 한다면 1분 이상의 시간이 더 소요 됩니다. (특히 CI/CD 에서는 매번 새로운 이미지를 받아 실행합니다. 이를 해결할 방법도 찾아봐야 겠습니다.)
느낀점
단점도 있지만 개발자가 구축해야 하는 테스트 환경 TestContainer가 직접 해주니 편안합니다. 시간이 7초나 더 소요 되지만 개발자가 직접 구축한다면 개발자 마다 1~2시간은 족히 걸릴 일입니다.
이제 저와 같이 일하는 동료가 TDD와 TestContainer 까지 2명이 되었네요.