Skip to content

[테스트 컨테이너와 상태 초기화를 통한 통합 테스트 개선기]

SEUNGUN CHAE edited this page Aug 28, 2024 · 1 revision

개요

팀 어썸오렌지의 백엔드 서버는 다양한 요구사항을 소화하기 위해 JPA Repository에 네이티브 쿼리 메서드를 작성해야 했습니다.

이들은 JPA에서 기본적으로 제공되던 findById, existsById 등의 메서드가 아니기 때문에 실제 호출 전에는 메서드의 유효성을 담보할 수 없었습니다.

@Repository
public interface EventParticipationInfoRepository extends JpaRepository<EventParticipationInfo, Long> {
    @Query(value = "SELECT event_user_id as eventUserId, COUNT(event_user_id) as count " +
            "FROM event_participation_info " +
            "WHERE draw_event_id = :eventRawId " +
            "GROUP BY event_user_id", nativeQuery = true)
    List<EventParticipateCountDto> countPerEventUserByEventId(Long eventRawId);

    @Query(value = "SELECT info.date as date FROM event_participation_info info " +
            "JOIN event_user e ON info.event_user_id =  e.id " +
            "WHERE e.user_id = :eventUserId " +
            "AND info.draw_event_id = :drawEventId", nativeQuery = true)
    List<EventParticipationDateDto> findByEventUserId(@Param("eventUserId") String eventUserId, @Param("drawEventId") Long drawEventId);

    boolean existsByEventUserAndDrawEventAndDateBetween(EventUser eventUser, DrawEvent drawEvent, @Param("from") Instant from, @Param("to") Instant to);
}

Mockito 기반의 단위 테스트는 근본적으로 DB 등의 외부 시스템과 상호작용하고 있지 않기 때문에 실제 환경에서 메서드가 의도대로 결과를 반환하는지는 보장할 수 없었습니다.

따라서 실제 DB 바탕의 통합 테스트로 해당 기능을 검증해야 했습니다.

팀 어썸오렌지는 테스트코드의 실행 결과를 Github Actions의 CI 파이프라인과 연계하여 서비스의 안정성을 높이는 것을 목표로 설정했고, 이 과정에서 다양한 문제를 맞닥뜨리게 되었습니다.

1. 통합 테스트가 실행될 환경에서는 DB 관련 의존성이 제공되지 않기에 테스트가 실패한다.

2. 내 노트북에선 통합 테스트가 실패하는데 팀원 노트북에선 성공하는 경우가 있다.

3. 개별적인 통합 테스트는 성공하나 "./gradlew test"로 전체 테스트를 실행할 경우 간헐적으로 1~3개의 테스트가 실패한다.

특히 2-3번의 경우엔 테스트 자체의 신뢰성을 송두리째 뒤흔드는 문제였기에 반드시 해결해야 했습니다.

팀 어썸오렌지는 1-2번 문제 해결을 위해 테스트 컨테이너를 도입했고, 3번 문제 해결을 위해 @DirtiesContext와 DB 초기화가 포함된 추상 테스트 클래스를 활용했습니다.

테스트 컨테이너 도입

  • "테스트 컨테이너"란, Springboot에서 데이터베이스나 메시지 큐 등 여러 애플리케이션와 상호작용하는 외부 시스템과의 통합 테스트를 수행해야 할 때 필요한 Docker 컨테이너를 쉽게 사용할 수 있게 돕는 오픈소스 라이브러리입니다.
  • 기본적으로 테스트 코드나 yml에서 관련 의존성을 정의하면 이를 Docker를 통해 해당 의존성에 대한 컨테이너가 실행되고, 테스트 코드는 실행된 컨테이너에 접근하여 필요한 작업을 수행합니다.
  • 예를 들어, 테스트 중 데이터베이스 연결을 위한 JDBC URL을 컨테이너에서 제공받아 사용할 수 있습니다. 테스트가 종료되면 모든 컨테이너는 자동으로 정리되기에 항상 매 테스트마다 오염되지 않은 상태에서 테스트를 실행할 수 있게 됩니다.

build.gradle

dependencies {
	// test container
	testImplementation 'org.testcontainers:testcontainers:1.19.3'
	testImplementation 'org.testcontainers:junit-jupiter:1.19.3'
	testImplementation 'org.testcontainers:mysql'
}

test {
	systemProperty 'spring.profiles.active', 'test'
	exclude '**/load/**'
}

구체적으로, 팀 어썸오렌지는 "test" 라는 Profile을 정의하였고, 테스트코드 실행 시 항상 "test" Profile 기준으로 테스트코드가 실행되도록 설정했습니다.

application-test.yml

spring:
  datasource:
    driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
    url: jdbc:tc:mysql:8.0:///
  jpa:
    hibernate:
      ddl-auto: create-drop
  • 또한 실제 테스트 컨테이너과 관련된 설정을 yml에 추가하였습니다. 현재 저희는 mySQL 8.0을 활용하고 있기에 동일한 환경으로 설정해주었습니다.
  • 이제 테스트 환경에서 mySQL DB의 Docker 컨테이너에 자동으로 연결됩니다.
image
  • 실제 통합 테스트 실행 시 testcontainer라는 이름으로 도커 컨테이너가 생성되며, 테스트가 끝나면 종료됩니다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@ActiveProfiles("test")
@Testcontainers
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public @interface TCDataJpaTest {
}
  • 한편, 통합 테스트마다 @Testcontainers 등 여러 어노테이션이 중복되고 있어 커스텀 어노테이션 @TCDataJpaTest을 제작하여 각 통합 테스트 클래스마다 붙여주었습니다.

  • 이렇게 테스트 컨테이너 기반의 테스트 코드 실행 환경을 조성하여 필요한 의존성을 도커 컨테이너 기반으로 공급할 수 있게 되었고, 테스트 환경 역시 각자의 개발 환경과 독립시키는 데 성공했습니다.

간헐적인 테스트 실패

image

  • 앞서 언급드렸다시피 개별적인 테스트 클래스 단위로 테스트 실행 시 성공하나, "./gradlew test" 로 전체 테스트를 실행하면 일부 통합 테스트가 간헐적으로 실패하고 있었습니다.
@Sql(value="classpath:sql/CustomDrawEventWinningInfoRepositoryImplTest.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS)
class CustomDrawEventWinningInfoRepositoryImplTest
  • 실패하는 3개 클래스는 모두 @Sql 어노테이션을 통해 쿼리를 실행한 후 테스트를 시작하며, 세 쿼리 파일 안에 동일한 INSERT문이 사용된다는 공통점이 있었습니다.
org.springframework.jdbc.datasource.init.ScriptStatementFailedException at ScriptUtils.java:282
        Caused by: org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException at DbException.java:520
  • 에러 로그 확인 결과 무결성 제약 관련 에러가 발생하고 있었습니다. (무결성 제약이란, "테이블에 부적절한 데이터가 입력되는 것을 방지하기 위해 테이블을 설계할 때 각 컬럼에 대해서 정의한 여러 가지 규칙"을 의미합니다.)

  • @Sql에 들어가는 쿼리 파일 안의 INSERT 쿼리에는 일부 엔티티에 대한 id 값이 명시적으로 지정되어 있지 않기 때문에, JPA Entity에 설정해둔 auto_increment 형태로 id가 부여되고 있었습니다.

  • 이들이 각각 실행될 때는 당연히 auto_increment가 1부터 시작하기에 상관이 없었으나, 명령어를 통해 연속적으로 실행될 경우 앞 테스트 클래스에서 @Sql을 통해 실행된 쿼리가 다음 테스트 클래스가 사용할 테이블에 영향을 주게 되어 무결성 제약을 위반하게 되는 것이었습니다.

  • @Transcational 어노테이션을 붙여보거나, 테이블의 내용을 TRUNCATE로 비우는 쿼리 파일을 3개 클래스에 @Sql 어노테이션으로 추가해 테스트 종료 후 실행하도록 만들어도 해결되지 않았습니다.

@DirtiesContext 적용

@Sql(value = "classpath:sql/CommentRepositoryTest.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS)
@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_CLASS)
@TCDataJpaTest
class CommentRepositoryTest {
class CommentRepositoryTest extends IntegrationDataJpaTest {
    @Autowired
    CommentRepository commentRepository;
  • 문제 해결을 위해 먼저 각 통합 테스트에 클래스 단위로 @DirtiesContext 어노테이션을 붙여, 테스트 클래스가 종료된 후 Spring의 ApplicationContext를 폐기하여 새로운 Context를 재생성했습니다. (ApplicationContext란, Spring 애플리케이션에서 사용되는 객체(빈)들을 관리하며 필요한 의존성을 자동으로 주입해주는 핵심 인터페이스입니다. 스프링 테스트에서는 같은 Context를 사용하는 테스트가 존재할 때, 기존의 Context를 재활용하고 있습니다. )

  • yml 파일의 spring.jpa.hibernate.ddl-auto를 "create-drop"으로 설정해놨기 때문에, ApplicationContext가 초기화될 때마다 DB와 연결하며 새로 테이블을 생성하므로 무결성 제약 위반 에러가 더 이상 발생하지 않는 것을 확인할 수 있었습니다.

  • CI 파이프라인에 테스트코드 실행 과정을 추가하니 문제 없이 통합 테스트가 통과했습니다.

한계

image
  • 정상적으로 테스트 컨테이너 기반으로 통합 테스트를 수행하고 무결성 제약 문제도 해결되었지만, CI 과정에서 테스트 실행 시간이 2분 40초 가량 소요되고 있었습니다. 바로 다음 task인 gradle 빌드가 5초 걸린다는 것을 감안했을 때 실행 시간이 너무 비효율적이었습니다.

  • 이는 @DirtiesContext로 ApplicationContext를 초기화하는 데 소모되는 시간이 각 클래스마다 필연적으로 존재하기 때문입니다.

  • 테스트 과정에서 발생한 에러의 진원지는 DB table인데 초기화 단위는 Spring ApplicationContext인, 마치 "닭 잡는 칼로 소 잡는" 상황이므로 @DirtiesContext 없이 테스트 실행 결과의 멱등성을 보장하면서도 실행 시간을 줄일 수 있는 방안에 대해 고민했습니다.

클래스 단위의 DB 초기화 전략 도입

  • 통합 테스트 클래스마다 DB table의 상태를 초기화하여 일관된 테이블 환경을 보장하기 위한 추상 테스트 클래스 "IntegrationDataJpaTest"를 도입했습니다.
@TCDataJpaTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public abstract class IntegrationDataJpaTest {

    @Autowired
    JdbcTemplate jdbcTemplate;

    // 이 클래스를 상속받는 모든 통합 테스트 클래스는 테스트가 끝나면 모든 테이블의 데이터를 삭제한다.
    @AfterAll
    void clearDatabase(){
        jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0");
        List<String> tableNameList = jdbcTemplate.queryForList("SHOW TABLES", String.class); // 모든 테이블 이름을 가져온다.
        for(String tableName : tableNameList) {
            jdbcTemplate.execute("TRUNCATE TABLE " + tableName); // 모든 테이블의 데이터를 삭제한다.
            jdbcTemplate.execute("ALTER TABLE " + tableName + " AUTO_INCREMENT = 1"); // AUTO_INCREMENT 초기화
        }
        jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1");
    }
}
  • 테스트 클래스 내부에 존재하는 테스트가 모두 실행된 후 @AfterAll을 통해 clearDatabase()가 수행되도록 하여, JdbcTemplate의 TRUNCATE TABLE 쿼리로 모든 테이블의 데이터를 비우고 AUTO_INCREMENT를 1로 초기화했습니다.

  • 참고로 mySQL은 TRUNCATE TABLE만으로도 AUTO_INCREMENT를 1로 초기화할 수 있기 때문에 ALTER문은 생략해도 무방합니다. (postgreSQL은 그렇지 않습니다.)

  • 만약 AUTO_INCREMENT를 1로 초기화하는 과정이 없다면 PK가 마지막으로 삭제된 레코드의 id 값을 기준으로 증가하기에 무결성 에러가 재발할 수 있습니다.

@Sql(value = "classpath:sql/CommentRepositoryTest.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS)
class CommentRepositoryTest extends IntegrationDataJpaTest {
    @Autowired
    CommentRepository commentRepository;
  • 이렇게 DB Table의 상태까지만 초기화하는 추상 테스트 클래스를 상속받게 만들고, 기존의 @DirtiesContext는 제거하였습니다.
image
  • CI 파이프라인에서의 실행 시간을 모니터링한 결과 2분 40초에서 1분 전후로 테스트 실행시간이 감소한 것을 확인할 수 있었습니다.

  • 매 통합 테스트 클래스마다 Spring ApplicationContext가 아닌 DB Table의 상태만 초기화함으로써 약 60%의 성능 개선에 성공한 것입니다.

  • 통합 테스트의 수가 점점 늘어날 수록 실행 시간의 차이는 더 크게 벌어질 것입니다.

결론

  • 테스트 컨테이너 도입을 통해 통합 테스트가 필요로 하는 외부 의존성을 Docker 컨테이너로 제공함으로써 각자의 로컬 환경과 분리된 독립적이고 일관성 있는 테스트 환경을 구축했습니다.
  • 통합 테스트 간 DB Table 상태 공유로 인한 간헐적인 테스트 실패 문제를 해결하기 위해 @DirtiesContext로 클래스마다 Spring ApplicationContext를 초기화하여 테스트 간 독립성을 확보했습니다.
  • 이후 @DirtiesContext의 느린 속도라는 한계를 극복하기 위해, DB Table의 상태만을 초기화하는 추상 테스트 클래스를"상속"받게 만들어 클래스 단위에서 문제를 해결하고자 했습니다.
  • 이를 통해 테스트 실행 결과의 멱등성을 유지하면서도 실행 시간을 2분 40초에서 1분으로 줄이며 약 60%의 성능 개선을 이뤄냈습니다.

관련 Pull Request