안녕하세요, 광고공통개발팀에 yong입니다.
테스트를 작성하는 것은 코드의 품질과 서비스 안정성을 증가시키고, 위험한 코드가 운영 환경에 배포되지 않도록 도움을 줍니다. 소속한 팀에서는, 깃헙(Github)에 PR(Pull Request)가 발생하면, CI(Continuous Integration) 서버에서 전체 테스트 케이스를 수행하여 결과 메시지를 수신하는 구조로 되어 있습니다.
CI(Continuous Integration) 서버에서 실패된 테스트를 로컬 환경에서 재수행하여 통과되는 경우가 발생했습니다. 멱등성이 지켜지지 않는 테스트가 생기면서 결과에 대한 신뢰도가 떨어지자, CI(Continuous Integration) 서버의 경고를 무시하게 되고, 문제가 발생했을 때 놓칠 가능성이 높아졌습니다.
테스트 결과를 무시하는 상황은 코드 품질 저하와 소프트웨어의 안정성 문제로 이어질 수 있으며, 사용자 경험에 부정적인 영향을 미칠 수 있다는 생각이 들었습니다.
테스트의 멱등성을 보장하고 신뢰도를 높이기 위한 노력이 필요했습니다. 쾌적한 테스트 환경을 만들기 위한 과정에서 겪은 경험들을 소개하도록 하겠습니다.
간헐적으로 깨지는 테스트 케이스들은 임베디드 데이터베이스를 사용하는 통합 테스트인 경우가 많았습니다. 데이터베이스의 상태를 확인하기 위한 Assertion을 사용한 코드가 실패하는 경우였습니다. 신기하게도, 실패한 테스트만 단독으로 수행시키면, 테스트가 성공하는 경우도 있습니다.
실패한 테스트를 단독으로 실행했을 때와 모듈 내의 모든 클래스들을 실행했을 때의 차이가 무엇인지 알아보기 위해서는 테스트 환경에서 스프링 컨텍스트가 동작하는 방식을 이해해야 합니다.
동일한 스프링 컨텍스트라면 재사용하기 위한 캐싱 기능이 제공됩니다. 동일한 키를 가진 컨텍스트가 재사용되면, 테스트 시간 단축도움이 되는 장점이 있습니다. 하지만, 동일한 컨텍스트 공유로 인해 테스트 격리성이 지켜지지 않는 상황들이 존재할 수 있습니다.
테스트 환경에서 스프링 컨텍스트를 매번 띄우는 비용을 최소화하기 위해 컨텍스트 캐싱이 지원되고 있습니다. 일반적으로 작성된 테스트 클래스에서 여러 @Test 메소드들이 동일한 컨텍스트를 재사용하는 것을 확인할 수 있습니다.
스프링 컨텍스트가 재사용되는 로그를 확인하기 위해 아래 프로퍼티를 추가해줍니다.
logging.level.root=DEBUG
아래 두 개의 테스트 클래스에서는 스프링 컨텍스트가 공유되지 않습니다. TestExample2에서 @MockBean을 사용했기 때문입니다.
@SpringBootTest
public class TestExample1 {
@Test
public void test() {
System.out.println("test!");
}
}
@SpringBootTest
public class TestExample2 {
@MockBean
Repository repository;
@Test
public void test() {
System.out.println("test!");
}
}
두 테스트가 캐싱된 컨텍스트를 사용하지 않았다는 사실은 로깅을 통해서 확인할 수 있습니다. 비교를 위해 MockBean을 제거하고 두 테스트를 수행해보면, missCount가 줄어든 것을 볼 수 있습니다.
ApplicationContext cache statistics: [DefaultContextCache@19fc0ef7 size = 1, maxSize = 32, parentContextCount = 0, hitCount = 12, missCount = 2]
동일한 컨텍스트로 판단되는 기준은 스프링 문서를 통해서 자세히 확인하실 수 있습니다.
MockBean이 사용되지 않아도 독립적인 컨텍스트를 사용하고 싶을 때는 DirtiesContext 어노테이션을 통해 테스트 격리성을 보장할 수 있습니다.
@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
@SpringBootTest
public class ServiceTest {
}
모든 테스트를 격리하기 위해서, DirtiesContext를 모든 클레스에 붙이게 되면 매번 컨텍스트를 띄우는 오버헤드가 발생합니다. 스프링 컨텍스트를 띄우는 횟수가 증가함에 따라 테스트 전체 수행 시간이 증가될 수 있습니다.
테스트 전체 수행 시간이 늘어나는 비용을 감당할 수 없다면, 컨텍스트 캐싱을 사용하면서 격리성을 유지할 수 있는 방법을 찾아야 합니다. 방법으로서 Transactional, BeforeEach, AfterEach 어노테이션을 사용해볼 수 있습니다.
테스트 컨텍스트에서의 트랜잭션은 격리성과 롤백을 보장하여 다른 테스트에 영향을 주지 않기 위한 용도로 사용됩니다.
@SpringBootTest
@Transactional
class TransactionalTest {
@Autowired
UserRepository userRepository;
@Test
@Order(0)
void create() {
userRepository.save(UserMaker.create());
}
@Test
@Order(1)
void isEmpty() {
assertThat(userRepository.findAll().isEmpty()).isTrue();
}
}
테스트 롤백이 발생하면, 아래의 로그를 확인할 수 있습니다.
org.springframework.test.context.transaction.TransactionContext org.springframework.test.context.transaction.TransactionContext endTransaction:139 : Rolled back transaction for test:
동기 환경에서는 PlatformTransactionManager를 구현하고 있지만, 비동기 환경에서는 PlatformTransactionManager를 구현하고 있지 않기 때문에 해당 어노테이션을 사용할 수 없습니다.
비동기 환경이라면, Github을 참고하여 테스트를 작성해볼 수 있습니다.
PlatformTransactionManager를 구현하지 않는 데이터베이스라면 Transactional 어노테이션을 사용할 수 없습니다. BeforeEach나 AfterEach로 리소스를 정리하는 코드를 추가하면 격리성을 유지할 수 있습니다.
@SpringBootTest
class TransactionalTest {
@Autowired
ReactiveMongoTemplate template;
@BeforeEach
void beforeEach() {
template.dropCollection("test").block();
}
}
@Transactional을 사용하지 못하면 트랜잭션 격리성(Isolation)이 보장되지 않으므로, AfterEach, BeforeEach를 사용하여 테스트 데이터를 정리하는 경우에 빌드 --parallel 옵션을 사용하려면 아래 설정이 필요합니다.
tasks.test {
useJUnitPlatform()
maxParallelForks = 1
}
ExtendWith 어노테이션을 사용하므로서 공유 리소스에 대해 데이터를 지우는 코드를 공통화할 수 있습니다.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Import(RedisTestContainerConfig.class)
@ExtendWith(RedisTestContainerRollbackExtension.class)
@ContextConfiguration(initializers = RedisTestContainerConfig.Initializer.class)
public @interface RedisTestContainer {
}
테스트 컨테이너를 사용하는 경우에 아래의 BeforeEachCallback 구현체를 이용할 수 있습니다.
public class RedisTestContainerRollbackExtension implements BeforeEachCallback {
@Override
public void beforeEach(ExtensionContext extensionContext) throws Exception {
RedisTestContainerConfig.REDIS_SERVER
.execInContainer("redis-cli", "flushall");
}
}
통합 테스트를 진행하기 위해서 임베디드 데이터베이스를 사용하는 경우가 많습니다. 상용 데이터베이스를 임베디드 형태로 제공하는 라이브러리를 사용해서 쉽게 데이터베이스 환경을 구축할 수 있습니다.
쉽게 사용할 수 있다는 장점도 있지만, 실제 환경과 다르다는 단점이 있습니다. 대표적인 경우로 운영 환경에서 MySQL, Oracle를 사용하는 경우에는 테스트 환경에서 H2를 임베디드 형태로 사용하는 경우가 있습니다.
임베디드 데이터베이스 환경은 호스트 환경을 이용해서 프로세스를 띄우는 형태이므로 호스트 환경과 결합이 강합니다. 임베디드 몽고 라이브러리로 널리 사용되는 de.flapdoodle.embed.mongo의 특정 버전에서 호스트 환경에서 실행하기 위해서 아래 코드처럼 호스트 환경과 밀접한 코드가 필요한 경우도 있습니다.
static { System.setProperty("os.arch", "i686_64"); }
임베디드 데이터베이스 환경의 한계를 극복하기 위해, 실제 환경과 동일한 데이터베이스처럼 사용할 수 있는 Testcontainer가 있습니다. 테스트 컨테이너를 사용하면, 운영 환경과 동일한 설정들을 사용할 수 있습니다.
Testcontainer는 운영 환경의 데이터베이스 버전과 일치하는 이미지를 받아와서 도커 환경에서 컨테이너를 띄우게 됩니다. 임베디드와 다르게 호스트 환경에서 포트는 도커 환경과 통신하기 위한 용도로만 사용됩니다. 즉, 호스트 환경에 영향받지 않고 Testcontainer를 사용할 수 있는 장점이 있습니다.
모든 테스트에서 Testcontainer를 코드를 추가하지 않고, 하나의 어노테이션으로 Testcontainer를 사용할 수 있는 환경을 만들어 코드의 중복을 제거할 수 있습니다.
아래 어노테이션을 붙이면, 사용하는 테스트 클래스에서 테스트 컨테이너를 사용할 수 있는 상태가 될 수 있습니다.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Import(value = {MongoDBTestContainerConfig.class})
@ExtendWith(MongoDBTestContainerRollbackExtension.class)
@ContextConfiguration(initializers = TestContainerInitializer.Initializer.class)
public @interface MongoDBTestContainer {
}
아래 클래스의 BeforeAll 메소드를 통해서 Testcontainer를 동작시키고, BeforeEach를 통해 리소스를 정리하는 작업을 통해서 테스트 격리성을 유지시켜 줄 수 있습니다.
class MongoDBTestContainerRollbackExtension implements BeforeAllCallback, BeforeEachCallback {
@Override
public void beforeEach(ExtensionContext context) throws Exception {
ReactiveMongoTemplate reactiveMongoTemplate = ReactiveMongoTemplateHolder.INSTANCE;
reactiveMongoTemplate.getCollectionNames()
.flatMap(
collectionName ->
reactiveMongoTemplate.remove(
new Query(),
collectionName
)
)
.blockLast();
}
@Override
public void beforeAll(ExtensionContext extensionContext) throws Exception {
if (MongoDBTestContainerConfig.MONGO_DB_CONTAINER.isRunning()) {
return;
}
MongoDBTestContainerConfig.MONGO_DB_CONTAINER.start();
}
}
ReactiveMongoTemplate을 static 환경에서 사용하기 위한 ReactiveMongoTemplateHolder 클래스도 추가됩니다.
@Component
public class ReactiveMongoTemplateHolder {
public ReactiveMongoTemplateHolder(@Autowired ReactiveMongoTemplate reactiveMongoTemplate) {
this.INSTANCE = reactiveMongoTemplate;
}
public static ReactiveMongoTemplate INSTANCE;
}
아래 MongoDBTestContainerConfig를 통해서, static한 MongoDBContainer를 제공할 수 있게 됩니다.
public class MongoDBTestContainerConfig {
public static MongoDBContainer MONGO_DB_CONTAINER;
static {
if (!StringUtils.isEmpty(PropertiesExtractor.getMongoDBVersion())) {
MONGO_DB_CONTAINER = new MongoDBContainer(PropertiesExtractor.getMongoDBVersion());
}
}
}
아래 PropertiesExtractor 클래스는 static 환경에서 프로퍼티를 제공하기 위해 만들어진 클래스입니다.
public class PropertiesExtractor {
private static Properties properties = new Properties();
static {
try (InputStream input = PropertiesExtractor.class
.getClassLoader()
.getResourceAsStream("testcontainer.properties")) {
properties.load(input);
} catch (IOException ex) {
ex.printStackTrace();
}
}
public static String getMongoDBVersion() {
return properties.getProperty("testcontainer.mongodb.version");
}
public static String getMongoDBPrefix() {
return properties.getProperty("testcontainer.mongodb.prefix");
}
public static String getElasticsearchVersion() {
return properties.getProperty("testcontainer.elasticsearch.version");
}
public static String getElasticsearchPrefix() {
return properties.getProperty("testcontainer.elasticsearch.prefix");
}
}
멀티 모듈 환경에서 testcontainer.properties에서 존재하는 프로퍼티들을 오버라이드하여 필요한 형태로 사용할 수 있습니다.
# mongodb
testcontainer.mongodb.version=mongo:5.0.13
testcontainer.mongodb.prefix=spring.data.mongodb
# elasticsearch
testcontainer.elasticsearch.version=docker.elastic.co/elasticsearch/elasticsearch:7.17.16
testcontainer.elasticsearch.prefix=spring.data.elasticsearch
ApplicationContextInitializer를 구현한 static 클래스에서 운영 환경에서 필요한 프로퍼티들을 주입합니다. 주입된 프로퍼티를 통해 테스트 환경과 운영 환경이 동일한 시스템 설정을 사용할 수 있게 해줍니다.
public class TestContainerInitializer {
public static class Initializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
@Override
public void initialize(ConfigurableApplicationContext applicationContext) {
initializeMongoDBProperty(applicationContext);
initializerElasticsearchProperty(applicationContext);
}
private static void initializeMongoDBProperty(ConfigurableApplicationContext applicationContext) {
if (MONGO_DB_CONTAINER != null && MONGO_DB_CONTAINER.isRunning()) {
MongoDBTestContainerConnectionInfo mongoDBTestContainerConnectionInfo =
new MongoDBTestContainerConnectionInfo(MONGO_DB_CONTAINER.getReplicaSetUrl());
TestPropertyValues.of(
PropertiesExtractor.getMongoDBPrefix() + ".host=" + mongoDBTestContainerConnectionInfo.hostPort(),
PropertiesExtractor.getMongoDBPrefix() + ".id=",
PropertiesExtractor.getMongoDBPrefix() + ".password=",
PropertiesExtractor.getMongoDBPrefix() + ".database=" + mongoDBTestContainerConnectionInfo.database(),
PropertiesExtractor.getMongoDBPrefix() + ".autoIndexCreation=true"
)
.applyTo(applicationContext);
}
}
private static void initializerElasticsearchProperty(ConfigurableApplicationContext applicationContext) {
if (ELASTIC_SEARCH_CONTAINER != null && ELASTIC_SEARCH_CONTAINER.isRunning()) {
TestPropertyValues.of(
PropertiesExtractor.getElasticsearchPrefix() + ".host=" + ELASTIC_SEARCH_CONTAINER.getHttpHostAddress()
).applyTo(applicationContext);
}
}
}
}
위의 코드들을 통해서, 최종적으로는 아래의 형태로 테스트 클래스를 작성할 수 있게 됩니다. 위에서 MongoDBTestContainer만 구현됐지만, ElasticSearchTestContainer 커스텀 어노테이션을 구현하는 것을 시도해보시길 바랍니다.
@MongoDBTestContainer
@ElasticSearchTestContainer
@SpringBootTest
public class ManagedBizWalletQueryServiceTest {
임베디드 데이터베이스는 어떤 호스트 환경에서 쉽게 사용할 수 있지만, Testcontainer는 CI(Continuous Integration) 서버에서 도커(docker)가 지원되어야 합니다. 운영 환경과 테스트 환경을 유사하게 만들어야 하는 상황이라면, TestContainer를 도입하는 것을 추천드립니다.
멀티 모듈의 환경에서, 다른 모듈의 test에 존재하는 클래스에 대해서 의존성을 가질 수 없습니다. 모듈의 test에서 사용되는 클래스들을 특정 모듈의 main으로 이동시켜 클래스를 사용하는 경우도 있습니다.
테스트 환경을 위해 사용되는 클래스가 main에 존재하면, 다른 모듈의 main에서 테스트를 위한 용도의 클래스를 사용할 수 있는 가능성이 생기게 됩니다.
a-module
- main
- test
b-moudle
- main
- test
test-only-module // 테스트만을 위한 모듈이지만, 공통 사용을 위해 main에 클래스 추가
- main
테스트 환경에서 공통적으로 사용하기 위해서 만든 Config, Builder, Factory와 같은 클래스들은 testFixtures에 두면 main과 구분될 수 있습니다.
testFixtures를 제공하려는 모듈에서, 아래 플러그인을 추가하고 의존성을 추가합니다. 플러그인이 추가되면, testFixtures에서 사용하는 의존성을 추가하는 문법인 testFixturesImplementation 사용할 수 있습니다.
plugins {
id("java-test-fixtures")
}
project(:"a-module"){
testFixturesImplementation("org.springframework.boot:spring-boot-starter-data-redis")
testFixturesImplementation("org.testcontainers:testcontainers:1.19.8")
testFixturesImplementation("com.redis:testcontainers-redis:2.2.2")
testFixturesImplementation("org.springframework.boot:spring-boot-starter-test")
}
a-module의 testFixutres를 사용할 수 있게 되면, 아래와 같이 모듈이 구성된 것을 확인할 수 있습니다.
a-module
- main
- test
- testFixtures
이제 b-module에서 a-module의 testFixtures를 사용하려면, 아래 의존성을 추가하면 사용할 수 있습니다. 아래 의존성으로 b-module에서는 a-module의 testFixtures에 선언된 클래스를 공통으로 사용할 수 있게 됩니다.
plugins {
id("java-test-fixtures")
}
project(:"b-module"){
testImplementation(testFixtures(project(":a-module")))
}
testFixtures를 통해서 테스트 환경에서 코드의 중복을 줄일 수 있게 되어, 테스트 코드 작성에 필요한 시간을 줄일 수 있게 됩니다.
테스트 격리성 보장, 테스트 컨테이너, 테스트 코드 중복 제거 등의 방법들을 통해 테스트 환경을 개선할 수 있었습니다. 테스트 환경 개선에 관심이 있는 분들이 계시다면, 위의 방법들을 적용해서 테스트 환경이 개선되면 좋겠습니다.
감사합니다.