도커의 등장이후 컨테이너 가상화 기술이 널리 보급되기 시작하며 컨테이너 없이 무언가를 개발하고 배포하는 것은 상상하기 어려워진 것 같습니다. 컨테이너 가상화 기술의 확장성, 표준성, 멱등성, 일관성, 편의성 등의 특징이 기존 IT계의 문제점을 많이 해결해주었기 때문입니다.
컨테이너 기술은 인프라계에도 혁명을 가져와주었지만 개발문화가 바뀌어가며 DevOps
가 성장하는 것에도 많은 영향을 주었다고 생각합니다. 대표적으로 CI / CD
를 수행하는 과정에서 개발하고 빌드하고 테스트하고 배포하는 일련의 과정 속에서 발생할 수 있는 의존성 등등의 문제를 대부분 컨테이너 기술을 도입하여 쉽게 해결합니다.
오늘은 저희 팀에서 운영하는 서비스에 CI
를 도입하는 과정에서 통합테스트의 어려움을 컨테이너 기술의 특징을 이용해 해결하는 Testcontainers
를 알게되었고 이를 소개해보고자 합니다.
테스트는 개발자가 로컬에서 수동으로 할 수도 있고 코드를 통해 자동화를 할 수도 있습니다.
단위테스트는 코드로 대부분 해결이 가능합니다. 단위테스트는 하나 하나 개별 모듈을 테스트하는 것이기 때문에 다른 모듈이나 외부 의존성이 있으면 그건 모킹처리하고 하나하나 순수 로직만 테스트하면 되서 코드로 작성하기 매우 쉽기 때문입니다.
하지만 통합테스트는 코드로 해결이 어렵습니다. 통합테스트는 외부 모듈과 의존성들을 포함해 이들과 상호작용이 원활히 이루어지는지 테스트하기 때문입니다.
다른 모듈이야 내가 코드로 제어가 가능한다쳐,, DB
나 AWS SQS, S3
같은 경우 내가 코드로 제어를 하지못해 이들 버전에 대한 의존성과 접근에 대한 어려움이 상당합니다.
먼저 DB
부터 보겠습니다.
Local
: 로컬에 설치해서 환경 구축 후 테스트에 사용In-Memory
: 인 메모리 DB를 활용하여 테스트 구동시 사용Embedded Library
: Library를 이용하여 테스트 구동시 사용DB 테스트 수행 시에는 위 3가지 방법을 고려해볼 수 있습니다.
하지만 각각 로컬에 개발자마다 DB환경을 구성해줘야한다던가 호환성 문제, 임베디드 라이브러리를 지원하지 않는 경우가 있다는 문제점이 있습니다.
DB 의존성 뿐만 아니라 AWS 의존성에 대한 문제점도 있습니다.
저희 팀의 경우 비즈니스 로직에 특정 작업에 대해 가시성을 수정하는 AWS SQS
에 대한 의존성이 존재했습니다. 이에 대한 테스트코드를 작성하려면 엑세스키와 시크릿키를 발급받아야하는 보안상 문제점이 있습니다.
Testcontainers
는 컨테이너 가상화 기술의 특징을 활용해 이러한 문제점을 해결하기 위해 등장하였습니다.
테스트컨테이너란 코드로 도커 컨테이너를 제어하여 통합테스트를 도와주는 라이브러리입니다.
로컬에 설치된 도커데몬과 연동되어 테스트코드가 실행되기 전 코드를 통해 해당 테스트를 위한 일회성 컨테이너를 생성하고 테스트 수행 후 컨테이너를 삭제합니다. 테스트컨테이너를 응용하면 테스트 때 뿐만 아니라 런타임 중에도 컨테이너를 생성하고 활용할 수 있습니다.
테스트컨테이너는 자바 뿐만 아니라 파이썬, 고 등 다양한 언어를 지원하며 이번 시간엔 자바를 통해 테스트컨테이너를 사용해보겠습니다.
기본적으로 Testcontainers for Java
에서는 org.testcontainers:junit-jupiter
를 통해 junit
을 지원합니다.
우리는 테스트컨테이너를 통해 테스트마다 컨테이너가 독립적으로 수행되며 멱등성을 보장하고 운영환경과 거의 동일한 환경에서 통합테스트를 수행하여 보다 완벽한 통합테스트 코드를 작성할 수 있게됩니다.
기본적으로 테스트컨테이너 라이브러리를 사용할 경우 도커 이미지만 있다면 어떠한 유형의 컨테이너도 생성해서 테스트에 사용할 수 있습니다.
모든 도커 이미지 중에서도 특히 자주 사용되는 솔루션들에 대해 모듈형태로 테스트컨테이너를 쉽게 사용할 수 있도록 테스트컨테이너 모듈을 지원합니다.
예를 들어 MySQL 모듈을 사용할 경우 MySQL 테스트컨테이너를 매우 쉽게 생성하고 사용할 수 있습니다.
먼저 저희 팀의 경우 VerticaDB
를 사용하는데 이는 테스트컨테이너에서 모듈로 제공해주지 않고 있습니다.
그래서 순수 testcontainers
만 활용하여 버티카DB에 대한 통합테스트 코드를 작성하는 예제로 알아보도록 하겠습니다. 저는 버티카 디비를 예제로 하였지만 제 예제를 참고하여 이미지만 바꾸고 유틸클래스만 잘 수정한다면 원하는 테스트가 가능할 것입니다.
테스트컨테이너를 생성하는 대표적인 예로 GenericContainer
를 이용해 코드로 컨테이너를 생성하는 방식과 docker-compose
를 이용해 설정을 구성하여 생성하는 방식이 있습니다. 이번 시간엔 docker-compose를 알아보도록 하겠습니다.
밑에 예제에서는 두가지 방식 모두 존재합니다.
먼저 의존성을 추가해줍니다.
testImplementation group: 'org.testcontainers', name: 'testcontainers', version: '1.17.6'
testImplementation group: 'org.testcontainers', name: 'junit-jupiter', version: '1.17.6'
.. 이외에 사용하는 디비 라이브러리 등
version: "3.8"
services:
vertica:
platform: linux/amd64
environment:
VERTICA_DB_USER: "dbadmin"
VERTICA_DB_PASSWORD: ""
VERTICA_DB_NAME: "test"
VERTICA_MEMDEBUG: 2
TZ: "Asia/Seoul"
image: vertica/vertica-ce:10.1.1-0
ports:
- "5433"
- "5444"
테스트컨테이너로 구동하고자 하는 docker-compose를 구성하여 src/test/resources
디렉토리에 위치해주세요.
@Slf4j
public class VerticaContainerUtils
{
private VerticaContainerUtils()
{
}
public static void initializeVerticaContainer(
ContainerState verticaContainer,
EmbeddedVerticaProperties properties,
ConfigurableEnvironment environment) throws Throwable
{
// init schema
initVerticaSchema(verticaContainer, properties);
updateVerticaEnvironment(
verticaContainer.getHost(),
verticaContainer.getMappedPort(properties.getPort()),
properties,
environment);
}
public static void initVerticaSchema(
ContainerState container,
EmbeddedVerticaProperties properties) throws IOException, InterruptedException
{
MountableFile resources = MountableFile.forClasspathResource("vertica/init-scripts");
container.copyFileToContainer(resources, "/vertica/init-scripts");
File dir = new File(resources.getResolvedPath());
String[] files = dir.list();
Arrays.sort(files);
for (String file : files)
{
execSqlInContainer(
container,
properties.getDatabase(),
properties.getUser(),
"/vertica/init-scripts/" + file);
}
}
private static void execSqlInContainer(
ContainerState container,
String database,
String dbUser,
String file) throws IOException, InterruptedException
{
Container.ExecResult execResult = container.execInContainer(
"/opt/vertica/bin/vsql",
"-d",
database,
"-U",
dbUser,
"-w",
"password",
"-h",
"localhost",
"--variable",
"dpt=101",
"-f",
file);
log.info(execResult.getStdout());
}
public static GenericContainer<?> createVerticaContainer(EmbeddedVerticaProperties properties)
{
LinkedHashMap<String, String> map = new LinkedHashMap<>();
map.put("VERTICA_DB_NAME", properties.getDatabase());
map.put("VERTICA_DB_PASSWORD", properties.getPassword());
map.put("VERTICA_MEMDEBUG", "2");
return new GenericContainer<>(DockerImageName.parse(properties.getDefaultDockerImage()))
.withExposedPorts(properties.getPort())
.withEnv(map)
.waitingFor(Wait.forLogMessage(".*Database " + properties.getDatabase() + " created successfully..*", 1)
.withStartupTimeout(Duration.ofMinutes(3))); // 3분 내로 해당 로그가 뜨면 컨테이너가 생성된 후 준비됐다고 판단, 더 좋은 방법이 있을까?
}
public static void updateVerticaEnvironment(
String host,
int mappedPort,
EmbeddedVerticaProperties properties,
ConfigurableEnvironment environment)
{
log.info("host: {}, port: {}", host, mappedPort);
LinkedHashMap<String, Object> map = new LinkedHashMap<>();
map.put("embedded.vertica.port", mappedPort);
map.put("embedded.vertica.host", host);
map.put("embedded.vertica.database", properties.getDatabase());
map.put("embedded.vertica.user", properties.getUser());
map.put("embedded.vertica.password", properties.getPassword());
MapPropertySource propertySource = new MapPropertySource("embeddedVerticaInfo", map);
environment.getPropertySources().addFirst(propertySource);
log.info("Started Vertica server. Connection details: {}, ", map);
}
}
우리는 copyFileToContainer
메서드를 통해 호스트에서 컨테이너로 파일을 옮기고 execInContainer
메서드를 통해 컨테이너 내부에서 명령어를 실행시킬 수 있습니다.
해당 유틸 예제는 테스트컨테이너로 생성한 버티카컨테이너로 init 스키마 파일을 옮긴 후, 해당 스크립트 파일들을 정렬하여 순차적으로 컨테이너 내부에서 스키마 초기화 스크립트를 실행하는 예제입니다.
DB를 도커로 말아 쓰는 형태에서는 표준으로
docker-entrypoint-initdb.d
디렉토리에 init 스키마를 넣어두면 초기화를 시켜줍니다.
하지만 버티카DB에서는 최초 1번 실행되지 않는 버그가 있어 위와 같은 작업으로 처리하였습니다.
Testcontainers
에서는withClasspathResourceMapping
메서드를 통해resources
폴더와 컨테이너 내 특정 폴더와 볼륨 매핑이 가능합니다.
@Slf4j
public class DockerComposeUtils
{
private DockerComposeUtils()
{
}
public static DockerComposeContainer createContainers(EmbeddedVerticaProperties properties)
{
return new DockerComposeContainer(new File(MountableFile.forClasspathResource("docker-compose.yml")
.getResolvedPath()))
.withExposedService("vertica", properties.getPort(), Wait.forListeningPort())
.waitingFor(
"vertica",
Wait.forLogMessage(".*Database " + properties.getDatabase() + " created successfully..*", 1)
.withStartupTimeout(Duration.ofMinutes(3))); // 3분 내로 해당 로그가 뜨면 컨테이너가 생성된 후 준비됐다고 판단, 더 좋은 방법이 있을까?
}
}
컨테이너가 생성된다고 테스트코드를 돌릴 수 있는 상태임을 의미하지 않습니다.
testcontainers
에서는 컨테이너가 생성되고 준비됐음을 의미하는 Waiting 전략
을 지정할 수 있습니다.
헬스체크를 사용하는 방식, 로그를 기다리는 방식 등 여러 전략이 존재하며 자세한 내용은 아래 docs
를 확인해주세요.
저는 log를 기다리는 전략을 선택하였습니다. 해당 로그가 출력되면 컨테이너가 준비됐다고 판단하고 테스트코드를 돌립니다.
@Slf4j
@TestConfiguration
@EnableConfigurationProperties(EmbeddedVerticaProperties.class)
public class TestContainersDockerComposeConfiguration
{
/**
* testcontainers를 docker-compose로 구성하여 생성
*
* @param environment
* @param properties
* @return
*/
@Bean(name = "testcontainers", destroyMethod = "stop")
public DockerComposeContainer testcontainers(
ConfigurableEnvironment environment,
EmbeddedVerticaProperties properties) throws Throwable
{
DockerComposeContainer composeContainer = DockerComposeUtils.createContainers(properties);
composeContainer.start();
ContainerState containerState = (ContainerState) composeContainer.getContainerByServiceName("vertica")
.orElseThrow(() -> new RuntimeException());
VerticaContainerUtils.initializeVerticaContainer(containerState, properties, environment);
return composeContainer;
}
}
앞서 구성한 도커컴포즈 구성파일을 이용해 컨테이너들을 생성하고, 버티카 컨테이너의 경우 커티카컨테이너 유틸로 스크립트를 초기화하는 예제입니다.
@Import({TestContainersDockerComposeConfiguration.class})
@SpringBootTest
public class TestContainersIntegrationSupport
{
}
/**
* 버티카 테스트컨테이너를 빈으로 등록하여 @SpringBootTest를 통해 테스트코드에서 확인하는 예제
*/
public class EmbeddedVerticaSpringBootTest extends TestContainersIntegrationSupport
{
@Autowired
JdbcTemplate jdbcTemplate;
@Autowired
ConfigurableEnvironment environment;
@Test
@DisplayName("버티카 연결에 성공한다.")
public void shouldConnectToVertica()
{
assertThat(jdbcTemplate.queryForObject("SELECT version()", String.class)).contains("Vertica Analytic Database");
}
@Test
@DisplayName("임베디드 버티카 환경변수를 잘 불러온다.")
public void propertiesAreAvailable()
{
assertThat(environment.getProperty("embedded.vertica.port")).isNotEmpty();
assertThat(environment.getProperty("embedded.vertica.host")).isNotEmpty();
assertThat(environment.getProperty("embedded.vertica.database")).isNotEmpty();
assertThat(environment.getProperty("embedded.vertica.user")).isNotEmpty();
assertThat(environment.getProperty("embedded.vertica.password")).isNotNull();
}
@Test
@DisplayName("seed 값을 잘 불러온다.")
public void shouldDiscoveryDBSeed()
{
assertThat(jdbcTemplate.queryForObject("SELECT count(*) FROM hello_world", Integer.class)).isEqualTo(4);
}
}
@TestConfiguration
으로 테스트컨테이너를 빈으로 등록하여 @SpringBootTest
에서 확인하는 통합테스트 예제입니다.
@Testcontainers
public class TestContainersUnitSupport
{
@Container
protected static final DockerComposeContainer composeContainer;
static
{
EmbeddedVerticaProperties properties = new EmbeddedVerticaProperties();
composeContainer = DockerComposeUtils.createContainers(properties);
composeContainer.start();
try
{
// init schema
ContainerState containerState = (ContainerState) composeContainer.getContainerByServiceName("vertica")
.orElseThrow(() -> new RuntimeException());
VerticaContainerUtils.initVerticaSchema(containerState, properties);
}
catch (Throwable e)
{
throw new RuntimeException(e);
}
}
}
해당 코드는 docker-compose
로 테스트컨테이너를 생성하고 스키마 초기화 스크립트를 실행하는 예제입니다.
/**
* @SpringBootTest 없이 테스트컨테이너 활용해 단위테스트 예제
*/
public class EmbeddedVerticaUnitTest extends TestContainersUnitSupport
{
private JdbcTemplate jdbcTemplate;
@BeforeEach
void beforeEach() throws Throwable
{
// 테스트 컨테이너가 실행되면 환경변수 host 가 바뀌므로 springBootTest가 아니라면 이를 jdbcTemplate에 수동으로 반영해야한다. 더 좋은 방법이 있으려나
ContainerState containerState = (ContainerState) composeContainer.getContainerByServiceName("vertica")
.orElseThrow(() -> new RuntimeException());
EmbeddedVerticaProperties properties = new EmbeddedVerticaProperties();
DataSource dataSource = new DataSource();
dataSource.setHost(containerState.getHost());
dataSource.setPort(containerState.getMappedPort(properties.getPort()));
dataSource.setDatabase(properties.getDatabase());
dataSource.setUser(properties.getUser());
dataSource.setUserID(properties.getUser());
jdbcTemplate = new JdbcTemplate(dataSource);
}
@Test
@DisplayName("버티카 연결에 성공한다.")
public void shouldConnectToVertica()
{
assertThat(jdbcTemplate.queryForObject("SELECT version()", String.class)).contains("Vertica Analytic Database");
}
@Test
@DisplayName("seed 값을 잘 불러온다.")
public void shouldDiscoveryDBSeed()
{
assertThat(jdbcTemplate.queryForObject("SELECT count(*) FROM hello_world", Integer.class)).isEqualTo(4);
}
}
다음과 같이 @SpringBootTest
없이 테스트컨테이너를 이용해 DB 테스트를 진행할 수 있습니다.
테스트컨테이너와 금상첨화를 이루는 Localstack
에 대해 알면 더욱 좋습니다.
앞서 AWS
서비스에 대해 비즈니스 로직에 의존성을 가질경우 테스트코드를 작성하기 매우 어렵다 하였습니다.
이를 Localstack
이 해결해줍니다. 우리는 Localstack
을 활용하여 로컬에 AWS 클라우드 환경을 컨테이너로 구성할 수 있습니다.
유료버전 무료버전이 존재하며 유료버전은 좀 더 풍부한 서비스에 대해 테스트를 할 수 있다고 합니다.
Testcontainers
에서는 localstack
에 대해 모듈을 지원하여 편리하게 구성하여 사용할 수 있습니다.
https://github.com/suhongkim98/testcontainers-embedded-vertica-demo
안녕하세요 포스팅 잘 읽었습니다. 궁금한 점이 있어 질문을 남깁니다.
CI/CD 과정 중에서 테스트 컨테이너를 활용한 통합 테스트를 거치고 도커 이미지 빌드/푸시를 할 경우, 테스트 컨테이너 사용을 위한 따로 필요한 설정이 있을까요?
감사합니다.