Spring 테스트 컨테이너

jhkim31·2024년 8월 1일
0

지금까지 테스트코드 실행시 CRUD만 테스트하면 되었기 때문에 H2를 임베디드로 띄우기만 해도 충분히 테스트를 진행할 수 있었다.

하지만 기능들이 추가되고, 외부 환경에 대한 의존이 생기게 되면서 H2만으로는 테스트를 진행하기 어려워 졌고, 그로인해 테스트시 외부 자원 (MySQL, Redis) 등이 필요하게 되었다.

테스트는 저러한 외부 자원이 항상 제공되는 상태가 아닐 수 있다. 그렇기 때문에 어디서든 환경에 구애받지 않고 테스트를 진행할 수 있는 방법을 찾아보다 테스트 컨테이너에 대한 정보를 찾을 수 있었다.

이 글에서는 테스트 컨테이너를 테스트가 무엇인지 알아보고, 나의 테스트에 테스트 컨테이너를 적용하는 방법에 대해 다룬다.

테스트 컨테이너

테스트 컨테이너는, 테스트시 필요한 의존을 경량 컨테이너로 띄워 제공하기 위한 오픈소스 프레임워크다.

테스트에 DB, Redis, 메시지 브로커등이 필요할때 이를 구축하지 않고 자바 코드로 컨테이너를 띄워 사용할 수 있다.

하지만 docker 를 사용해 컨테이너를 띄우기에 테스트를 실행하는 호스트에 docker 가 설치되어 있어야 한다는 제약이 있긴 하다.

테스트 컨테이너 설정

gradle 을 사용한다면 다음 의존을 추가할 수 있다.

# build.gradle

dependencies {
	testImplementation 'org.testcontainers:testcontainers:1.20.0'
    testImplementation 'org.testcontainers:junit-jupiter:1.20.0'
}

테스트 컨테이너로 띄울 MySQL을 간단하게 띄워서 확인해보자.

MySQL을 띄우기 위해서는 다음 의존을 추가해줘야 한다.

# build.gradle

dependencies {
	testImplementation "org.testcontainers:mysql:1.20.1"
}

그리고 샘플 테스트 파일 하나를 만든다.

// TestContainerMySQL.java

@Testcontainers
@SpringBootTest
public class TestContainerMySQL {

    @Autowired
    UserRepository userRepository;

    @PersistenceContext
    EntityManager em;

    @Container
    static final MySQLContainer<?> mysqlContainer = new MySQLContainer<>("mysql:8.0")
        .withDatabaseName("jshop_test")
        .withEnv("MYSQL_ROOT_PASSWORD", "1234");

    @DynamicPropertySource
    public static void init(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.driver-class-name", mysqlContainer::getDriverClassName);
        registry.add("spring.datasource.url", mysqlContainer::getJdbcUrl);
        registry.add("spring.datasource.username", mysqlContainer::getUsername);
        registry.add("spring.datasource.password", mysqlContainer::getPassword);
    }

    @Test
    public void test() {
        User user = User
            .builder().username("kim").build();

        userRepository.save(user);

        em.flush();
        em.clear();

        List<User> users = userRepository.findAll();

        System.out.println(users);
    }
}

설정을 살펴보면 다음과 같다.

@Testcontainers

org.testcontainers.junit.jupiter.Testcontainers

이 테스트가 Testcontainers 환경에서 진행됨을 나타낸다. 이 어노테이션이 있다면 내부 컨테이너들을 자동으로 시작하고 종료시킨다.

@Container

테스트에 사용할 컨테이너를 정의한다.
이 어노테이션이 붙은 컨테이너는 테스트가 시작될때 자동으로 시작하고 테스트가 종료하면 자동으로 종료되게 된다.

@DynamicPropertySource

application properties 를 동적으로 변경한다.
기존 application.yml 에 설정이 있더라도 동적으로 덮어쓰게 된다.
이러한 동작이 필요한 이유는 컨테이너의 포트를 랜덤으로 띄우기 때문이다.
만약 고정된 포트로 컨테이너를 띄우게 된다면 해당 포트를 사용하고 있는 다른 프로세스가 있는 경우 테스트를 진행할 수 없게 된다.
때문에 랜덤으로 포트를 지정해 띄우고, 동적으로 해당 포트로 설정을 덮어씀으로써 JPA, JDBC가 초기화할 수 있도록 한다.

실제로 jdbc URL을 살펴보면 다음과 같이 랜덤으로 포트가 붙는것을 확인할 수 있다.

테스트 컨테이너 확인

이제 실행을 시켜 테스트 컨테이너가 정상적으로 동작하는지 확인해보자.

테스트를 실행시키면 호스트의 docker 컨테이너 리스트에서 testcontainersmysql 이 뜬것을 확인할 수 있다.

또한 애플리케이션 로그를 보면

다음과 같이 컨테이너를 띄워 실행시키는것을 확인할 수 있다.

테스트 컨테이너 주의할점

테스트 컨테이너를 사용하면서 주의할점이 몇가지 있다.

1. 실행시간

테스트 컨테이너를 사용하게 된다면 어쨌든 자바 코드로 docker 컨테이너를 띄우는것이기 때문에 많많치 않은 비용이 들어가게 된다.

그중에서도 가장 주목해야 하는 점이 시간으로, 테스트 컨테이너를 사용하게 될경우 테스트를 위해 컨테이너를 띄워야 하기 때문에 테스트 시간이 크게 늘어나게 된다.

또한 테스트마다 컨테이너를 각각 띄우도록 설정하게 된다면 매 테스트마다 컨테이너를 실행, 종료 시켜야 하기 때문에 시간이 급격하게 늘어나게 된다.

실제로 모든 테스트마다 위의 예시처럼 설정하게 된다면 모든 테스트에서 컨테이너를 올렸다 내렸다 반복하게 된다.

테스트 컨테이너를 설정하는 방법에는 크게 3가지가 있다.

  1. 모든 테스트 (메서드) 단위로 재실행
  2. 테스트 클래스 단위로 재실행
  3. 싱글톤 컨테이너

각 테스트 컨테이너의 장단점과 설정 방법에 대해 알아보자.

자세한 내용은 아래 문서에 나와있다.

https://java.testcontainers.org/test_framework_integration/junit_5/#restarted-containers

1.1 메서드 단위로 재실행

이경우 테스트 컨테이너를 non-static 으로 설정하면 된다.

@Testcontainers
@Transactional
public class TestContainer {

    @Container
    private final MySQLContainer<?> mysqlContainer = new MySQLContainer<>("mysql:8.0")
        .withEnv("MYSQL_ROOT_PASSWORD", "1234");

    @Test
    public void test1() {
        int bindingPort = mysqlContainer.getMappedPort(3306);
        System.out.println("container : " + mysqlContainer.getJdbcUrl());
    }

    @Test
    public void test2() {
        int bindingPort = mysqlContainer.getMappedPort(3306);
        System.out.println("container : " + mysqlContainer.getJdbcUrl());
    }

    @Test
    public void test3() {
        int bindingPort = mysqlContainer.getMappedPort(3306);
        System.out.println("container : " + mysqlContainer.getJdbcUrl());
    }
}

일반 필드로 테스트 컨테이너를 설정하면 매 테스트(메서드) 마다 컨테이너가 생성된다.

하나의 테스트에서 mysql 컨테이너 3개가 뜨는것을 확인할 수 있다.

이경우 모든 테스트상태를 격리할 수 있어 테스트 격리에는 좋지만, 모든 테스트마다 테스트 컨테이너가 생성되고 제거되기 때문에 시간이 오래 걸리게 된다.

실제로 저 간단한 테스트 코드를 수행하는데 약 25초 가량 걸린다.

1.2 테스트 클래스 단위로 재실행

테스트 단위로 컨테이너를 사용하며 최상위 클래스의 정적 필드로 선언되어야 하고 @Container 가 필요하다.

1.1 테스트 코드에서 컨테이너 필드를 static 으로 바꿔주기만 하면 된다.

    @Container
    private static final MySQLContainer<?> mysqlContainer = new MySQLContainer<>("mysql:8.0")
        .withEnv("MYSQL_ROOT_PASSWORD", "1234");

테스트 클래스 단위로 사용하게 되면 메서드 단위보다는 조금 덜 격리가 되지만, 이정도면 충분히 테스트가 격리된다.

또한 테스트 컨테이너를 재사용하기 때문에 비용적인 측면에서도 크게 부담이 적어진다.

1.3 싱글톤 테스트 컨테이너

이 방법은 모든 테스트 클래스에서 하나의 테스트 컨테이너를 사용하는 방법으로, 비용을 크게 줄일 수 있다는 장점이 있다.

정의하는 방법은 static 으로 정의된 필드를 가진 클래스를 하나 만들고, 이 클래스를 테스트 컨테이너가 필요한 모든 테스트 클래스에서 사용하는 것이다.

이때 @Container 를 사용하지 않고, 컨테이너에 대한 제어를 수동으로 해야 한다.

아래는 내가 사용하는 MySQL, Redis 테스트 컨테이너 베이스 클래스다.

@Testcontainers
public abstract class BaseTestContainers {
    
    static final MySQLContainer<?> mysqlContainer;

    static final RedisContainer redisContainer;

    static {
        mysqlContainer = new MySQLContainer<>("mysql:8.0")
            .withDatabaseName("jshop_test")
            .withEnv("MYSQL_ROOT_PASSWORD", "1234");
        redisContainer = new RedisContainer("redis:7.2.5");

        mysqlContainer.start();
        redisContainer.start();
    }

    @DynamicPropertySource
    public static void init(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.driver-class-name", mysqlContainer::getDriverClassName);
        registry.add("spring.datasource.url", mysqlContainer::getJdbcUrl);
        registry.add("spring.datasource.username", mysqlContainer::getUsername);
        registry.add("spring.datasource.password", mysqlContainer::getPassword);

        registry.add("spring.data.redis.host", redisContainer::getRedisHost);
        registry.add("spring.data.redis.port", redisContainer::getRedisPort);
    }
}

mysql 컨테이너와 redis 컨테이너를 static 필드로 정의하고, static 블럭에서 이를 초기화 한다.

이렇게 초기화한 베이스 클래스를 테스트 클래스에서 상속받아 사용함으로써, 모든 테스트 클래스에서 테스트 컨테이너를 공유할 수 있게 된다.

public class CouponTest extends BaseTestContainers {
...

이 방법의 경우 모든 테스트가 하나의 데이터베이스를 사용하기 때문에 테스트 격리 측면에서는 아쉽지만, 사실 이 문제는 코드레벨에서 해결되어야 할 문제다.

또한 모든 테스트에서 하나의 컨테이너를 공유함으로써 비용이 크게 절감되어 부담없이 사용할 수 있다는 장점이 크다.

2. 메모리 및 docker

testcontainer 는 도커를 사용해 컨테이너를 띄우게 된다.
이로인해 호스트 컴퓨터에 docker 에 대한 의존이 생기게 된다는 점을 주의해야 한다.

또한 아무리 경량 컨테이너라도 DB와 같이 무거운 프로세스들이 뜨게 된다면 메모리에도 영향을 줄 수 있으니 이점을 고려해야 한다.

3. 애플리케이션 초기화

테스트 컨테이너는 랜덤한 포트를 바인딩해 동작하게 된다.

스프링 부트 환경에서는 모든 빈이 등록될때 초기화 작업을 하게 된다.

예를들어 HikariCP, Redisson와 같은 빈들은 빈 등록후 초기화 작업때 애플리케이션 설정 정보를 가지고 데이터베이스에 연결을 수행하게 된다.

하지만 만약 non-static 으로 컨테이너를 띄우게 된다면 컨테이너는 스프링부트 애플리케이션 초기화 시점에서는 띄워져있지 않고 스프링부트 애플리케이션이 모두 뜬 이후에 올라오게 된다.

이로인해 데이터베이스가 필요한 JDBC, JPA와 같은 서비스들이 초기화를 하지 못하고 애플리케이션이 종료되버리게 된다.

이런 문제를 해결하기 위해선 애플리케이션 초기화에 필요한 컨테이너들은 static 으로 등록하고 @DynamicPropertySource 와 같은 어노테이션을 활용해 static 으로 뜬 데이터베이스의 정보로 애플리케이션 설정 정보를 덮어써야 한다.

한마디로 스프링부트의 동작 과정을 잘 알고 있어야 한다.

mysql - application.yml 설정

mysql 같은 경우, application.yml 을 사용해 테스트 컨테이너를 간단하게 설정할 수 있다.

# application.yml
spring:
  datasource:
    driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
    url: jdbc:tc:mysql:8.0:///jshop_text
    username: root
    password: 1234

위와같이 driverContainerDatabaseDriver 로 설정하고, urltc 를 사용해 테스트 드라이버를 사용할것을 명시해주게 되면, 자동으로 테스트컨테이너를 로드하고 해당 컨테이너로 JDBC, JPA 설정이 잡이게 된다.

정리

테스트 컨테이너는 테스트시 필요한 외부 의존성을 컨테이너로 띄워 해결해주는 프레임워크다.

도커에 대한 의존, 테스트 시간이 길어진다는 단점이 있지만 테스트 환경을 따로 만들어 주지 않아도 된다는 강력한 장점이 있다.

테스트 컨테이너를 띄울때 각 방법의 특징과 스프링의 동작에 대해 이해를 하고 있어야 원활하게 사용할 수 있다.

테스트마다 격리가 필요하다면 테스트마다 컨테이너를 띄울 수 있고, 시간을 줄여야 한다면 싱글톤으로 컨테이너를 띄워 모든 테스트에서 공유하며 사용할 수 있다.

profile
김재현입니다.

0개의 댓글

관련 채용 정보