1. 배경
테스트 환경
테스트 환경은 프로젝트 설정을 할 때 가장 중요한 부분 중 하나이다.
가장 어렵고 귀찮은 작업이기도 하지만 처음 한번만 고생하면 추후 테스트 작성 시에 걱정 없이 아주 깔끔한 테스트 코드를 짤 수 있게 된다. 하지만 그만큼 프로젝트 환경 설정에서 가장 많은 시간을 들이게 되고, 많은 시행착오를 겪는 구간 중 하나라고 볼 수 있다.
테스트 환경을 만드는 과정에서 신경써야 할 부분은 다양하겠지만 그중에서도 특히나 주의해야할 부분 중 하나는 바로 멱등성이다. 멱등성을 간과한 경우에는 예상치 못한 상황에서 다른 테스트 혹은 외부 모듈로 인해 테스트가 간헐적으로 실패할 수 있으며, 이 경우 실패 구간을 찾기 매우 어렵다는 특징을 가지고 있기 때문이다.
여기서 멱등성(idempotent)이란?
연산을 여러 번 적용하더라도 결과가 바뀌지 않는 성질을 뜻한다.
쉽게 말해서 여러 번 함수를 실행하더라도 늘 같은 결과가 나와야 한다는 의미다.
멱등성은 특히나 테스트에서 매우 중요한 개념으로 자리잡고 있는데, 바로 멱등성을 지키는 것이 테스트 전체의 생산성에 아주 큰 영향을 주기 때문이다.
멱등성을 간과한 사례
코드를 추가하거나 변경한 것이 전혀 없는데도 갑자기 모든 CI 와 테스트가 깨지는 경우가 발생할 수 있다.
그 중 하나로, 다른 micro service 컴포넌트를 테스트에서 실제로 참조하고 있었는데 중간에 해당 컴포넌트의 설정이 바뀐 상황을 예로 들 수 있다.
다른 컴포넌트를 mocking 하기보다 직접 dev 환경의 서버를 호출하는 것이 쉽겠다는 판단 하에 작업했지만 결국 문제가 발생하게 된다.
그 이후에도 해당 컴포넌트 서버에 변경이 생길때마다 잘 돌아가던 테스트가 깨지기 시작하고 그로 인해 팀의 생산성이 크게 저하되게 된다.
로컬 환경에서 잘 실행되던 테스트가 누군가의 환경이나 CI 에서는 깨지기 시작하니 모두들 테스트를 신뢰할 수 없게되고, 테스트를 실행하는 것에 대한 극심한 피로감을 겪기 시작한다.
망가진 테스트를 고치는 데에 기존보다 훨씬 더 많은 시간과 리소스를 쏟아야 하기 때문이다.
위의 사례에서 원인을 꼽아본다면 바로 dev 환경의 micro service component 가 매번 일정하게 응답을 내려줄 것이라고 가정한 점이라고 볼 수 있다. 바로 테스트 환경에서 외부 모듈에 대한 멱등성이 유지되길 기대했던 것이다.
이처럼 흔히 멱등성이 깨지기 쉬운 구간 중 하나는 바로 외부 모듈이라고 볼 수 있다.
그리고 이를 해결하기 위해서는 테스트 환경 구축과 코드 작성에 있어 멱등성을 반드시 고려해야 한다.
2. 문제점
DB로 알아보는 Integration Test 방법들
먼저 테스트 환경을 위해 가장 많이 쓰이는 외부 의존성을 떠올리자면 가장 먼저 떠오르는 것은 무엇보다 DB 라고 할 수 있다. DB를 테스트 환경에서 실행하는 방법은 여러가지가 있다.
2-1 Local 에 실제 DB 를 띄우기
Local 환경에 실제 DB 를 띄워서 테스트함으로써 실제와 거의 유사한 환경에서 테스트할 수 있지만 동시에 여러 테스트가 이루어지거나 테스트가 끝났음에도 테스트용 데이터가 남아있는 문제들로 인하여 멱등성 관리가 매우 어렵다. 특히 DDL 을 테스트하는 경우 매번 drop 을 해줘야 하는 부담 또한 존재하고 있다.
2-2 In-memory DB 활용하기
주로 H2 와 같은 in-memory DB 를 사용하고 ORM 을 통해서 특정 DB 의 종속성을 해결한다. 매우 빠르게 동작하는 장점이 있지만, 특정 DB 에 특화된 기능을 테스트하거나 DDL 을 명시할 때 실제 동작과는 다른 SQL 을 작성해야한다는 문제가 있다.
2-3 사용하고자 하는 DB 의 Embedded Library 사용하기
in-memory DB 를 사용하였을 때의 문제점 중 하나인, 특정 DB 에 종속적인 기능을 테스트하기 어려운 이슈를 해결할 수 있는 방법이다. 일반적으로 실제 DB 코드를 경량화해서 제공하며 in-memory DB 와 거의 동일한 방식으로 구현할 수 있다.
다만 DB 별로 Embedded Library 가 존재하지 않는 경우도 있고 특정 버전 혹은 특정 OS 가 지원되지 않기도 한다. 또한 DB 별로 라이브러리가 다르기 때문에 DB 별로 구현을 다르게 해야 하는 문제점도 존재한다.
현재 mysql embedded 오픈소스에 접속해보면 deprecated 되었고 더 나은 대안인 Testcontainers를 사용하라고 추천해주고 있다.
MongoDB : https://github.com/flapdoodle-oss/de.flapdoodle.embed.mongo
Mysql : https://github.com/wix/wix-embedded-mysql
Postgresql : https://github.com/zonkyio/embedded-postgres
2-4 Docker Compose 활용하기
Docker가 나온 이후로 in-memory DB 를 지원하지 않는 경우에도 도커 이미지만 있다면 테스트 환경을 구축할 수 있게 되었다. 기존에는 누군가 똑같이 포팅한 in-memory DB 가 있어야만 그 DB 를 테스트환경에 넣을 수 있었지만 이제는 컨테이너 이미지만으로도 production 과 유사한 환경에서 테스트를 할 수 있다.
그야말로 완벽한 솔루션처럼 보이지만 docker-compose 파일을 따로 관리해야 하고, docker 컨테이너와의 통신을 설정하기 어렵다는 불편함이 여전히 존재하고 있다. 예를 들어 docker-compose 에서 port 를 바꾼다면 코드에서도 한번 더 port를 바꿔줘야 한다. 이는 결국 random port 를 통한 local 포트 충돌 방지 및 parallel 테스트를 매우 하기 어렵게 만든다.
설정 파일과 테스트 수행 전후로 Container를 관리해야 한다. Container의 포트를 변경할 때마다 테스트 코드의 포트도 바꿔 줘야 하고, 병렬 테스트 시에 포트 충돌 문제가 발생하기도 한다.
2-5 Mocking
DB와의 통신을 건너뛰고 순수한 로직만을 테스트할 수 있도록 Mock을 사용하는 것도 한 방법이다.
- SpyBean : given에서 선언한 코드 외에는 전부 실체의 것을 사용
- MockBean : 껍데기만 있는 객체, 내부의 구현 부분은 사용자에게 위임
Mock은 테스트의 수행을 쉽게 하고 비즈니스 로직에 집중할 수 있게 하는 장점이 있다. 다만 완전한 Mock을 구현하는 것은 불가능하기 때문에, 실제 환경에서 DB와 연동했을 때 문제가 발생할 가능성을 무시할 수 없다.
이런 불-편한 점들을 Testcontainer를 통해 해결할 수 있었다.
3. 해결
Testcontainers는 Docker를 기반으로 Junit 수행 시 테스트를 도와주는 Java Library 이다.
사실 동작 원리는 Docker Compose 와 다를 바 없지만 docker-compose 와 같은 외부 설정 파일 없이 Java 언어만으로 docker container 를 활용한 테스트 환경을 설정할 수 있다.
특히 compose 를 활용할때에 어려운 부분인 container 와의 통신 또한 언어 레벨에서 처리할 수 있다.
따라서 container 에 변경사항이 생기더라도 여러 곳을 변경할 필요없이 하나의 코드로 관리할 수 있다.
다시 말해 자바 코드로 도커 이미지를 실행하고 끌 수 있는데, 이걸로 얻을 수 있는 장점들은 다음과 같다.
- 실제 운영 DB와 동일한 환경 사용가능
- 환경에 구애받지 않고 멱등성 있는 테스트 품질 향상을 이룰 수 있다.
- 테스트 실행 시 자동으로 컨테이너를 실행, 테스트 끝나면 자동 종료
- 테스트를 위한 compose 파일을 관리하지 않아도 됨
(Dockerfile, docker-compose, docker hub로 Container를 동작시킬 수 있다.) - 컨테이너를 매번 랜덤한 포트로 사용하기에 포트도 신경쓰지 않아도 된다!
- 다양한 module 을 제공하고 있다.(DB, Selenium 웹 브라우저 등 Docer 컨테이너에서 실행할 수 있는 경령화된 이미지를 제공)
4. 사용법
장점에 비해, 사용법은 생각보다 너무나 간단하다.
우선 의존성을 추가해 준다.
testImplementation 'org.testcontainers:junit-jupiter:1.19.0'
testImplementation 'org.testcontainers:mysql'
이러면 사실 준비 끝. 직접 적용만 해보면 된다.
class ExampleTc {
MySQLContainer mySQLContainer = new MySQLContainer("mysql:8");
@BeforeEach
void setUp() {
mySQLContainer.start();
}
@AfterEach
void tearDown() {
mySQLContainer.stop();
}
@Test
void test1() {
System.out.println(mySQLContainer.getMappedPort(3306));
}
@Test
void test2() {
System.out.println(mySQLContainer.getMappedPort(3306));
}
}
일단 Testcontainer는 기본적으로 매 테스트마다 랜덤한 포트에 새로운 컨테이너를 실행하고 종료한다.
그 역할을 해주는게 start() 와 stop() 이다.
실제로 test1, 2에 찍은 sout을 확인해보면 실행된 포트가 다른 것을 확인할 수 있다.
근데, 저 start와 stop을 매번 해야된다는게 번거롭다 생각했는지 @Container 어노테이션을 통해 start() 와 stop()을 대신할 수 있다.
class ExampleTc {
@Container
MySQLContainer mySQLContainer = new MySQLContainer("mysql:8");
}
이렇게 번거로움은 해결 했지만, 매 테스트 마다 컨테이너를 실행하고 종료하고를 반복하다 보니,
테스트 실행속도가 현저하게 느려진다.
container를 static 필드로 선언하면, 컨테이너를 한번만 실행한다.
class ExampleTc {
static MySQLContainer mySQLContainer = new MySQLContainer("mysql:8");
static {
mySQLContainer.start();
}
}
아까 처럼 두개의 테스트에서 포트넘버를 출력해보면 이번에는 같은 포트넘버가 출력된다.
@SpringBootTest
@ActiveProfiles("test")
public abstract class BaseTest {
private static final String ROOT = "root";
private static final String ROOT_PASSWORD = "test";
@Autowired
private DataInitializer dataInitializer;
@Container
protected static MySQLContainer container;
static {
container = (MySQLContainer) new MySQLContainer("mysql:8.0")
.withDatabaseName(“testDB)
.withEnv("MYSQL_ROOT_PASSWORD", ROOT_PASSWORD);
container.start();
}
@DynamicPropertySource
static void configureProperties(final DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", container::getJdbcUrl);
registry.add("spring.datasource.username", () -> ROOT);
registry.add("spring.datasource.password", () -> ROOT_PASSWORD);
}
}
BaseTest 라는 추상 클래스 내에서 컨테이너를 만들고 실행시킨 뒤, 이를 상속한 테스트 클래스에서 테스트를 진행한다.
이렇게 하면, 모든 테스트가 진행되는 동한 최초 한번만 컨테이너가 실행된다.
5. 추가 개선 사항
- 컨테이너 내부 쿼리 로그 출력 필요
- 초기 스키마 파일 테이블 별로 분리해서 관리 (flyway)
6. 마치며
여기서는 TestContainer 를 이용하여 parallel 한 테스트 환경에서도 외부 모듈에 대한 멱등성을 유지하며 테스트 코드를 작성을 Testcontainers를 통해 쉽게 할 수 있음을 알게 되었다.
이처럼 멱등성이 유지되는 테스트 설정은 간헐적인 테스트 실패를 방지함으로써 생산성을 높여준다.
그 외로 MockServer, Localstack 등을 활용해 AWS 환경에 맞는 테스트를 구축할 수 있고, 이는 Robust한 어플리케이션을 구현하는데 중요한 역할을 담당할 것으로 생각한다.
- Testcontainers 공식 사이트
- Testcontainers for Java 공식 문서
- Testcontainers로 MariaDB 통합 테스트하기 - 최범균 님
- TestContainer로 멱등성있는 integration test 환경 구축하기