-
Notifications
You must be signed in to change notification settings - Fork 0
[테스트 컨테이너와 상태 초기화를 통한 통합 테스트 개선기]
팀 어썸오렌지의 백엔드 서버는 다양한 요구사항을 소화하기 위해 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을 컨테이너에서 제공받아 사용할 수 있습니다. 테스트가 종료되면 모든 컨테이너는 자동으로 정리되기에 항상 매 테스트마다 오염되지 않은 상태에서 테스트를 실행할 수 있게 됩니다.
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 기준으로 테스트코드가 실행되도록 설정했습니다.
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 컨테이너에 자동으로 연결됩니다.
- 실제 통합 테스트 실행 시 testcontainer라는 이름으로 도커 컨테이너가 생성되며, 테스트가 끝나면 종료됩니다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@ActiveProfiles("test")
@Testcontainers
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public @interface TCDataJpaTest {
}
-
한편, 통합 테스트마다
@Testcontainers
등 여러 어노테이션이 중복되고 있어 커스텀 어노테이션@TCDataJpaTest
을 제작하여 각 통합 테스트 클래스마다 붙여주었습니다. -
이렇게 테스트 컨테이너 기반의 테스트 코드 실행 환경을 조성하여 필요한 의존성을 도커 컨테이너 기반으로 공급할 수 있게 되었고, 테스트 환경 역시 각자의 개발 환경과 독립시키는 데 성공했습니다.
- 앞서 언급드렸다시피 개별적인 테스트 클래스 단위로 테스트 실행 시 성공하나, "./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 어노테이션으로 추가해 테스트 종료 후 실행하도록 만들어도 해결되지 않았습니다.
@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 파이프라인에 테스트코드 실행 과정을 추가하니 문제 없이 통합 테스트가 통과했습니다.
-
정상적으로 테스트 컨테이너 기반으로 통합 테스트를 수행하고 무결성 제약 문제도 해결되었지만, CI 과정에서 테스트 실행 시간이 2분 40초 가량 소요되고 있었습니다. 바로 다음 task인 gradle 빌드가 5초 걸린다는 것을 감안했을 때 실행 시간이 너무 비효율적이었습니다.
-
이는
@DirtiesContext
로 ApplicationContext를 초기화하는 데 소모되는 시간이 각 클래스마다 필연적으로 존재하기 때문입니다. -
테스트 과정에서 발생한 에러의 진원지는 DB table인데 초기화 단위는 Spring ApplicationContext인, 마치 "닭 잡는 칼로 소 잡는" 상황이므로 @DirtiesContext 없이 테스트 실행 결과의 멱등성을 보장하면서도 실행 시간을 줄일 수 있는 방안에 대해 고민했습니다.
- 통합 테스트 클래스마다 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
는 제거하였습니다.
-
CI 파이프라인에서의 실행 시간을 모니터링한 결과 2분 40초에서 1분 전후로 테스트 실행시간이 감소한 것을 확인할 수 있었습니다.
-
매 통합 테스트 클래스마다 Spring ApplicationContext가 아닌 DB Table의 상태만 초기화함으로써 약 60%의 성능 개선에 성공한 것입니다.
-
통합 테스트의 수가 점점 늘어날 수록 실행 시간의 차이는 더 크게 벌어질 것입니다.
- 테스트 컨테이너 도입을 통해 통합 테스트가 필요로 하는 외부 의존성을 Docker 컨테이너로 제공함으로써 각자의 로컬 환경과 분리된 독립적이고 일관성 있는 테스트 환경을 구축했습니다.
- 통합 테스트 간 DB Table 상태 공유로 인한 간헐적인 테스트 실패 문제를 해결하기 위해
@DirtiesContext
로 클래스마다 Spring ApplicationContext를 초기화하여 테스트 간 독립성을 확보했습니다. - 이후
@DirtiesContext
의 느린 속도라는 한계를 극복하기 위해, DB Table의 상태만을 초기화하는 추상 테스트 클래스를"상속"받게 만들어 클래스 단위에서 문제를 해결하고자 했습니다. - 이를 통해 테스트 실행 결과의 멱등성을 유지하면서도 실행 시간을 2분 40초에서 1분으로 줄이며 약 60%의 성능 개선을 이뤄냈습니다.