본문 바로가기

프로젝트/트러블슈팅

[트러블 슈팅] Testcontainers로 통합 테스트 환경 만들기 (+ Testcontainers를 Spring Bean으로 등록)

배경

이전 글, [트러블 슈팅] 멀티모듈에서 JaCoCo와 SonarCloud를 적용하여 코드 품질 높이기에서 인프라 영역이 나타났고 그로 인해 통합 테스트가 어려워졌다고 이야기했다.

 

테스트는 멱등성(Idempotent)을 보장하도록 작성되어야 한다. 멱등성이란 어떤 연산이 여러 번 수행되어도 동일한 결과를 내놓는 성질을 의미한다. 결국 테스트는 한 번만 수행한 결과와 여러 번 수행한 결과가 동일해야 한다. 그렇다면 결정적인 테스트 결과를 가져오도록 통합 테스트를 구성하려면 어떻게 해야할까?

 

원데이히어로 프로젝트 내 통합 테스트에서 사용할 주요 모듈은 웹 애플리케이션 서버(WAS)와  MongoDB이다. 테스트를 위해서 이 두 가지 모듈이 어딘가에서 실행되어야 한다. 프로젝트에서 스프링 부트를 사용하고 있다. 따라서 내장된 톰캣을 사용하므로 WAS 실행은 문제가 되지 않는다.

 

MongoDB의 경우, 로컬에 직접 설치하는 방법이 있다. 또한 일부 데이터베이스는 임베디드 형태로 테스트를 위한 라이브러리를 제공한다.

찾아보니 임베디드 MongoDB는 de.flapdoodle.embed.mongo에서 제공하고 있다. 하지만 두 방식 모두 단점이 있다.

 

먼저 로컬 환경의 직접 설치하는 것은 상당히 번거로운 작업이다. 팀원들의 로컬 환경에 따라 설치 방법이 다르고 설치 후 테스트를 위한 환경 설정을 진행해야 한다. 이러한 설치 및 설정 문제는 임베디드 형태의 라이브러리를 사용하면 해결된다. 하지만 앞서 언급했던 flapdoodle의 임베디드는 공식 MongoDB 제품이 아니므로 운영 환경에서 예기치 못한 문제가 발생할 수 있다. 그리고 오픈소스 프로젝트이므로 프로젝트가 중단될 경우, 다른 대안을 찾아야 하고 이전에 작성했던 테스트 코드를 리팩토링해야 한다.

 

따라서 이러한 문제들은 도커(Docker)를 활용하여 해결할 수 있다. 도커로 테스트용 데이터베이스를 컨테이너로 띄운다면 운영 환경과 가깝고 원활한 통합 테스트를 진행할 수 있다. 하지만 개발자가 직접 터미널로 컨테이너를 띄우고 다 사용한 컨테이너를 내리는 것은 불편하다. 따라서 자동으로 도커 컨테이너를 관리해주는 Testcontainers 라이브러리를 사용하여 간편하게 통합 테스트 환경을 구축해보자.

 

Testcontainers 적용하면서 발생한 문제

 

Testcontainers를 JUnit5에서 어떻게 적용하는지 어떤 기능이 있는지는 Testcontainers 공식문서에 친절히 나와있으므로 스킵하겠다. 

 

우리 팀은 테스트 수행 시간을 줄이기 위해 테스트 실행 환경을 통합하여, 전체 테스트를 수행할 때 스프링 컨테이너를 띄우는 비용을 절감하고 있다. 추상 클래스에 자신을 상속하고 있는 테스트 클래스가 필요한 의존성들을 모두 가지고 있다. 그리고 타겟 필드에 @Container를 클래스 레벨에 @Testcontainers를 붙여 도커 컨테이너의 생명주기를 관리한다. 마지막으로 매핑되는 호스트 포트는 동적으로 변한다. 따라서 동적 프로퍼티를 스프링이 참조하도록 @DynmicPropertySource 어노테이션을 사용했다.

 

아래는 프로젝트 내 코드이다. 일부 필드는 생략했다.

 

@ActiveProfiles("test")
@SpringBootTest
@Testcontainers
public abstract class IntegrationMongoRepositoryTest {

    @Container
    private static final MongoDBContainer mongoDbContainer = new MongoDBContainer(DockerImageName.parse("mongo:4.0.10"));

    @DynamicPropertySource
    static void registerMongoTestProperties(
        final DynamicPropertyRegistry registry
    ) {
        registry.add("spring.data.mongodb.uri", mongoDbContainer::getReplicaSetUrl);
    }
}

 

하지만 이렇게 코드를 작성했더니 문제가 발생했다. 개별 테스트에선 성공했지만 전체 테스트를 수행하니 첫 번째 테스트를 제외하고 그 다음 테스트는 실패했다. 스택 트레이스를 확인해보니 MongoDB 접속에 실패했다고 한다. 

 

전체 테스트에서 실패

 

처음부터 로그를 찬찬히 읽어보니 도커 컨테이너가 추상 클래스를 상속받는 클래스의 수만큼 다시 띄워지고 있었다. 나는 도커가 처음 한 번만 띄워지고 모든 테스트가 이 컨테이너를 사용한다고 생각했다.

 

Testcontainers의 단점은 느리다는 것이다. 추상 클래스를 상속받는 클래스의 수가 늘어난다면 그만큼 도커 컨테이너가 다시 띄워질테니 테스트 수행시간도 늘어날 것이다. 문제를 해결하기 전에 왜 이렇게 동작하는지 살펴보겠다.

 

@Testcontainers 어노테이션는TestcontainersExtension 클래스를 @ExtendWith하고 있다. TestcontainersExtension 클래스를 들어가보면 BeforeEachCallback, BeforeAllCallback, AfterEachCallback, AfterAllCallback의 추상 메서드를 재정의하고 있다.

 

이 중 BeforeAllCallback의 beforeAll를 재정의한 메서드를 잠깐 살펴보면, (많은 코드를 생략했으므로 궁금하면 들어가보길 바란다.) 

 

findSharedContainers에서 리플렉션을 이용해 @Container 어노테이션이 붙어있고 static한 필드를 찾는다. 그리고 startContainers로 컨테이너를 실행시킨다. 그리고 static하지 않은 필드는 beforeEach로 실행되고 있음을 코드를 통해 확인할 수 있다. 

@Override
public void beforeAll(ExtensionContext context) {
    List<StoreAdapter> sharedContainersStoreAdapters = findSharedContainers(testClass);

    startContainers(sharedContainersStoreAdapters, store, context);
}

private List<StoreAdapter> findSharedContainers(Class<?> testClass) {
    return ReflectionSupport
        .findFields(testClass, isSharedContainer(), HierarchyTraversalMode.TOP_DOWN)
        .stream()
        .map(f -> getContainerInstance(null, f))
        .collect(Collectors.toList());
}

private Predicate<Field> isSharedContainer() {
    return isContainer().and(ModifierSupport::isStatic);
}

 

결국 컨테이너를 static으로 선언할 때 BeforeAll로 동작하므로 새로운 테스트 클래스를 수행할 때마다 컨테이너가 새로 띄워졌다. 따라서 바인딩된 호스트 포트가 변경된다.

 

MongoDB 커넥션은 스프링 서버가 구동될 때 한 번만 생성된다. 따라서 스프링 서버가 구동되기 전 띄워진 도커 컨테이너와 바인딩된 호스트 포트로 커넥션이 생성된다.

 

하지만 커넥션이 생성된 이후 띄워진 도커 컨테이너와 바인딩된 호스트 포트는 이전과 다르므로  MongoDB와 통신이 불가능하다.

 

정리하자면,

 

  1. 도커 컨테이너 띄워짐 (바인딩된 호스트 포트 1234)
  2. 스프링 서버 구동되면서 MongoDB 커넥션 생성
  3. 도커 컨테이너 다시 띄워짐 (바인딩된 호스트 포트 5678)
  4. 이전에 생성된 커넥션으로 통신 불가 ❌

컨테이너가 한 번만 생성되도록 Spring Bean에 등록

 

나는 도커 컨테이너가 한 번만 실행되고 모든 테스트에서 공유되기를 바란다. 어떻게 해결할지 고민하다, Testcontainers를 스프링 빈으로 등록하여 도커 컨테이너의 라이프사이클을 스프링에게 위임했다는 글을 봤다. 프로젝트에서 MongoDB 테스트 실행 환경을 통합하므로써 스프링 컨테이너가 한 번만 띄워지고 있다. 따라서 도커 컨테이너도 스프링 서버가 구동될 때 딱 한 번 띄워진다.

 

아래는 프로젝트 내 코드이다. 코드를 살펴보자면,

 

mongoDBContainer라는 빈이 생성될 때, start 메서드 호출하고 소멸될 때 stop 메서드를 호출함으로써 자동으로 도커 컨테이너를 관리해준다.

 

mongoDatabaseFactory는 MongoDB 커넥션을 빈으로 등록하고 있다. 컨테이너 정보를 이용해 커넥션을 만들어야 하므로 @DependsOn을 이용해 mongoDBContainer 빈이 생성된 뒤 mongoDatabaseFactory 빈이 생성도록 한다.

 

@ActiveProfiles("test")
@SpringBootTest
@Import(IntegrationMongoRepositoryTest.IntegrationMongoTestConfiguration.class)
public abstract class IntegrationMongoRepositoryTest {

    @TestConfiguration
    static class IntegrationMongoTestConfiguration {

        @Bean(initMethod = "start", destroyMethod = "stop")
        public MongoDBContainer mongoDBContainer() {
            return new MongoDBContainer(DockerImageName.parse("mongo:4.0.10"));
        }

        @Bean
        @DependsOn("mongoDBContainer")
        public MongoDatabaseFactory mongoDatabaseFactory(
            final MongoDBContainer mongoDBContainer
        ) {
            return new SimpleMongoClientDatabaseFactory(mongoDBContainer.getReplicaSetUrl());
        }
    }
}

 

전체 테스트에서 통과하고 MongoDB 컨테이너가 스프링이 구동될 때 한 번만 생성되는 것을 확인할 수 있다.

전체 테스트에 통과

맺음

백기선님의 "더 자바, 애플리케이션을 테스트하는 다양한 방법"에서 Testcontainers가 소개되었다. 강의를 통해 사용법을 익힌 뒤 프로젝트에 적용해봤다! 

 

사실 Testcontainers에 대해 설명한 글은 널리고 널렸다. 그래서 글에서 자세한 설명은 생략하고 적용하면서 겪은 트러블슈팅을 공유하고자 했다. 그리고 공식 문서보면 다 나와있다..

 

우아한 기술 블로그 "LocalStack을 활용한 Integration Test 환경 만들기"에서 LocalStack에 대해 소개해주고 있다. LocalStack은 AWS 클라우드 리소스의 기능을 에뮬레이션하여 제공한다고 한다. Testcontainers와 함께 사용하면 좋은 기능같아 다음에 적용해보면 좋겠다고 생각했다.

 

참고