TestContainer를 이용한 통합테스트 환경 세팅하기

haaaalin·2024년 1월 7일
post-thumbnail

우리 프로젝트는 유닛 테스트만 작성이 되어있었고, 통합 테스트는 따로 작성하지 않아 항상 DB에 직접 값을 넣고, 직접 Postman이나 Swagger로 실행해 데이터가 잘 반환되는 지 일일이 확인하고 있었다.

하지만 이제 빠르고 정확한 통합 테스트를 진행해 보기 위해 찾다가 ,TestContainer라는 것을 발견해 한 번 적용해 보려고 한다.

멱등성 있는 테스트 환경

멱등성이란?

멱등성은, 수학에서 사용하는 용어에서 유래한 것으로 연산을 여러 번 적용하더라도 결과가 달라지지 않는 성질을 말한다. 매번 같은 데이터를 요청한다면 당연히 결과도 항상 같아서 멱등성이 보장된다.

당연히 테스트 또한 어느 환경(배포, 로컬 환경 등)에서 실행해도 같은 코드를 실행한다면 같은 결과가 출력되어야 한다.

테스트용 인메모리 H2 사용

스프링은 H2 인메모리 DB를 테스트 할 때 주로 사용하곤 한다.

H2는 이용이 매우 간단하고, 빠르게 동작하는 장점이 있지만 테스트에 이용하기엔 많은 단점이 있다.

신뢰성 감소

H2 기반의 테스트가 성공하더라도, 애플리케이션의 SQL이 실제 DB에 대해 프로덕션 환경에서 실패할 수도 있다.

실제 데이터베이스와 차이

실제 데이터베이스와 H2가 제공하는 기능이 다르다보니, 테스트 환경(인메모리 DB인 H2와 함께)과 프로덕션 환경(실제 DB와 함께) 모두 실행 가능하도록 수정이 필요할 때가 있다.

하지만 배보다 배꼽이 더 큰 문제가 발생할 수 있다. 테스트를 성공하기 위해 코드를 수정하며, 오히려 성능과 유지보수성 등이 떨어질 수 있다. 수정을 하지 않으면 일부 기능에 대한 테스트를 완전히 건너뛰어야 하는 말도 안 되는 일이 벌어진다.

예를 들면, PostgreSQL은 SET 절에서 별칭을 사용하지 않는다. H2를 이용한 테스트에서는 통과했던 쿼리가 오류가 발생할 수 있다.

@Query(value = "UPDATE Users u SET u.status = ? WHERE u.name = ?", 
  nativeQuery = true)
int updateUserSetStatusForNameNative(Integer status, String name);

프로덕션 환경에서는 아래와 같은 오류가 발생할 것이다.

Caused by: org.postgresql.util.PSQLException: ERROR: column "u" of relation "users" does not exist

하지만 위와 같은 역경도 이겨낼 수 있긴 하다. (역경은 그냥 이겨내지 말고 피하자)

  • 실제 DB에는 있고, H2에는 없는 기능을 직접 구현해 등록하면 된다. (최악)
    • 추가적인 구현 노력이 필요
    • 실제 구현한 기능 또는 DB에 있는 기능을 실행하는 것은 일단 다른 실행이다. (테스트의 의미가 사라짐)
  • H2를 사용하는 테스트를 통과하기 위해 프로덕션 코드 변경 (최악)

로컬에 실제 DB 사용

프로덕션 환경에서 PostgreSQL을 사용한다면 PostgreSQL을, MySQL을 사용한다면 MySQL을 로컬에 따로 실행해 테스트를 진행하는 방법이다.

이 방법은 개발을 같이하는 팀원마다 따로 테스트 환경을 세팅해야 한다는 불편함이 있다. (마치 도커가 없던 때에 개발 환경을 세팅해야 했던..) 이 과정에서 테스트 결과도 달라질 수 있으니 이 방법도 썩 좋은 방법은 아닌 것 같다.

Docker 사용

Docker도 마찬가지이다. 도커를 이용해 실제 DB 컨테이너를 실행시킨 후에, 테스트를 실행시키고 테스트가 끝나면 내리고… 너무나 번거롭다. 또, 실수로 컨테이너 실행을 종료시키지 않으면, 계속해서 백그라운드로 실행되어 리소스를 잡아 먹는 일이 발생한다.

또한 테스트를 위한 DB 컨테이너가 하나 늘어나, docker-compose 파일을 추가적으로 관리해야 하는 비용이 발생한다. 포트번호, DB 연결 설정 등을 바꾸게 된다면 docker-compose 파일과 코드 둘 다 변경해야 하는 번거로움 또한 존재한다.

TestContainers 사용

이 글을 쓰는 이유이다. TestContainers는 Docker 컨테이너를 외부 설정 없이 Java 언어만으로 구축할 수 있는 오픈소스 라이브러리이다.

해당 컨테이너를 이용해 멱등성 있는 테스트 환경을 구축할 수 있고, 자동으로 테스트 시작 시 DB 컨테이너를 띄워주고, 테스트가 끝나면 종료시켜주는 역할을 해주기 때문에 간편하다.

또한 container와의 통신 또한 언어 레벨에서 통제가 가능하기 때문에 번거롭지 않게 변경사항을 적용할 수 있다.

TestContainers 적용하기

의존성 추가

https://java.testcontainers.org/quickstart/junit_5_quickstart/

먼저 위를 참고해 의존성을 추가해주자.

dependencies {	
	// test
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.junit.jupiter:junit-jupiter:5.8.1'
	testImplementation 'org.testcontainers:testcontainers:1.19.0'
	testImplementation 'org.testcontainers:junit-jupiter:1.19.0'
	testImplementation "org.testcontainers:mysql:1.19.0"
}

사용하는 DBMS에 맞는 dependency를 추가해주어야 한다. (아래 링크 참고)

logback-test.xml 파일 추가

https://java.testcontainers.org/supported_docker_environment/logging_config

공식 문서에서는 logback-test.xml 파일을 만들어 추가할 것을 권장한다. 처음 할 때는 이 사실을 모르고 했다가 스크롤이 거의 무한으로 내려가는 걸 보고 놀랐다.

yaml 파일 설정

spring:

  datasource:
    driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
    url: jdbc:tc:mysql:8:///graphy?TC_INITSCRIPT=file:src/test/resources/schema.sql
    username: root
    password: root
  • datasource.url: TC_INITSCRIPT= 이 부분에 사용할 DB Script 파일 경로를 추가해주면, DB 컨테이너가 시작될 때 테이블 생성과 초기 데이터를 생성할 수 있다.
  • datasource.driver-class-name : tc 키워드를 가진 url을 JDBC 드라이버가 인식할 수 있게 넣어주는 설정

Base 추상 클래스

다른 테스트와 마찬가지로, 공통 메서드나 변수를 설정하기 위해 Base 클래스를 생성해주었다.

이렇게 Base 클래스를 설정한 이유는 다른 테스트도 이렇게 설정해서 통일성을 주기 위함도 있지만, static을 이용해 MySQLContainer 객체를 생성하면, 실행 범위가 클래스라 매 테스트마다 컨테이너를 실행하지 않을 수 있다.

@SpringBootTest
@Testcontainers
@ActiveProfiles(TestProfile.TEST)
public abstract class IntegrationTest {

    protected MockMvc mvc;
    protected ObjectMapper objectMapper = buildObjectMapper();

    static final String MYSQL_IMAGE = "mysql:8";
    @Container
    static final MySQLContainer MY_SQL_CONTAINER = new MySQLContainer(MYSQL_IMAGE);

    public MockMvc buildMockMvc(WebApplicationContext context) {
        return MockMvcBuilders.webAppContextSetup(context)
                .build();
    }

    private ObjectMapper buildObjectMapper() {
        final ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.registerModule(new JavaTimeModule());
        return objectMapper;
    }
}
  • @Testcontainers : 여기서는 테스트 컨테이너를 이용
  • @ActiveProfiles("TEST") : test profile을 이용하겠다고 선언 application-test.yml 파일은 제대로 된 통합 테스트 결과를 위해 실제 개발 환경과 똑같은 yml 파일의 내용을 포함한다. (jwt 토큰 값, 외부 API 토큰 값 등)
  • @SpringBootTest: 실제 애플리케이션과 같이 ApplicationContext를 로드(설정된 빈을 모두 로드)

아래와 같이 상속해서 사용하면 된다.

class ProjectIntegrationTest extends IntegrationTest {

현재 우리 프로젝트 테스트 환경은 다음과 같다.

  • repository 단위 테스트 → application-unit.yml 파일을 active 시켜 H2를 이용 MySQL에서만 실행되는 코드는 통합테스트로 진행할 계획이다. (유닛 테스트에서 testcontainers를 사용하게 되면 테스트가 너무 무거워지는 우려)
  • service 단위 테스트 → mock을 사용
  • controller 단위 테스트 → WebMvcTest 사용
  • 통합 테스트 → testcontainers를 이용해 DB 컨테이너 띄워 진행

마무리하며

사실 통합테스트는 실행 속도가 느린 것이 우려되었고, CI에 테스트 코드를 실행하는 스크립트가 포함되어 있다보니, CI 실행이 너무 느려질 것 같아 아예 추가하고 있지 않고 있었다. 또, 아무래도 도메인이 적고, 통합테스트의 필요성을 느낄 만한 기능이나 코드가 추가되지 않았었기 때문도 컸다.

하지만 이제 외부 서비스를 사용하는 것도 추가될 예정이고, 새로운 기능을 추가 개발하면서 도메인도 슬슬 늘어날 거라 통합테스트가 필요하다는 생각이 들어 이번에 세팅하게 되었다.

테스트 코드는 실무에서 어떻게 작성하는 지 레퍼런스가 잘 보이지 않아, 이렇게 작성하는 게 맞는 건지 어떻게 해야 “진짜” 테스트 코드인지 혼란스럽지만, 차근차근 공부해보자.

profile
한 걸음 한 걸음 쌓아가자😎

0개의 댓글