반응형
들어가며
Testcontainers?
- Docker 컨테이너를 활용한 일회용 인스턴스를 제공하는 JUnit 테스트 라이브러리
- MySQL 같은 데이터베이스의 컨테이너 인스턴스를 사용하여 데이터 액세스 계층 코드를 테스트할 수 있다.
전제조건
- 로컬 PC에 Docker가 설치되어 있어야 한다.
- 설치 참고 : https://sg-choi.tistory.com/235
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 |