[Springboot] TestContainer로 격리된 테스트 환경 조성하기 + @DirtiesContext 없이 테스트 독립성 보장하기

winluck·2024년 8월 18일
0

Springboot

목록 보기
15/18

현 프로젝트에는 Mocktio 기반의 단위 테스트와 mySQL DB에 실제로 접근하는 통합 테스트가 섞여 있다.

CI 파이프라인에 테스트 성공 여부를 담아서 프로젝트의 신뢰성을 높이고자 하는데, DB에 의존하는 통합 테스트를 CI 파이프라인에 그대로 띄우기엔 추후 의존성(Redis 등)이 더해져 코드가 난잡해질까봐 우려스러웠다.

테스트 컨테이너를 도입하여 개발 환경과 별개로 독립된 테스트 환경을 조성해보자!

build.gradle

// test container
testImplementation 'org.testcontainers:testcontainers:1.19.3'
testImplementation 'org.testcontainers:junit-jupiter:1.19.3'
testImplementation 'org.testcontainers:mysql'
testImplementation 'org.springframework.boot:spring-boot-starter-test'

테스트 컨테이너 관련 의존성을 추가한다.

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

우리 프로젝트는 테스트코드 실행 시 자동으로 profile을 "test"로 지정해두었기에, 관련된 설정을 추가하였다. 또한 일부 기능에 대한 부하 테스트를 load 패키지 내에 작성해두었는데, CI 파이프라인에서 큰 의미가 없기에 제외하였다.

application-test.yml

spring:
  datasource:
    driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
    url: jdbc:tc:mysql:8.0:///
  jpa:
    hibernate:
      ddl-auto: create-drop

test profile을 위한 의존성을 추가하여 커밋해두었다.

spring boot가 Testcontainers의 ContainerDatabaseDriver를 사용하게 함으로써 Docker 컨테이너에서 데이터베이스를 자동으로 실행할 수 있게 된다. (실제 로컬에서 통합테스트 코드를 실행하면 도커 컨테이너가 생성되었다가 소멸하는 것을 확인할 수 있다.)

jdbc:tc:mysql:8.0:/// 옵션을 통해 mySQL 8.0 기반의 컨테이너를 생성하여 애플리케이션의 테스트 코드가 이 DB에 접근할 수 있도록 한다. 참고로 3개의 슬래시는 기본 DB임을 의미한다. (이름을 따로 붙여도 된다.)

실제 Github Actions의 CI 파이프라인에서 ./gradlew test 를 실행하면 이 의존성을 바탕으로 테스트코드가 실행될 것이다.

이제 통합테스트들이 테스트 컨테이너에서 테스트를 실행할 수 있도록 전용 어노테이션을 만들자.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@ActiveProfiles("test")
@Testcontainers
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public @interface TCDataJpaTest {
}

이 어노테이션이 붙은 모든 통합 테스트는 테스트 컨테이너 + test 프로필 기반으로 DataJpaTest를 실행하게 될 것이며, @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) 옵션을 통해 default인 h2가 아니라 테스트 컨테이너를 통해 테스트를 실행하도록 설정된다.

@TCDataJpaTest
class EventServiceSearchHintsTest  {
    @Autowired
    private EventMetadataRepository emRepo;
    @Autowired
    private EventFrameRepository efRepo;
}

참고로 통합테스트 중 일부가 @Sql 어노테이션을 통해 데이터를 주입받은 후 테스트에 돌입하는 것 때문에 테스트 간 충돌로 인한 실패가 끊임없이 발생하였다. @Transcational 어노테이션을 붙여도 해결되지 않았다.

이를 막기 위해 @DirtiesContext를 도입하여, 각 테스트가 끝날 때마다 Springboot의 context 자체를 초기화하여 급한 불을 끄듯 문제를 해결하였다.

@Sql(value = "classpath:sql/EventParticipationInfoRepositoryTest.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS)
@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_CLASS)
@TCDataJpaTest
class EventParticipationInfoRepositoryTest {
    @Autowired
    EventParticipationInfoRepository epiRepository;
}

CI.yml

name: CI

on:
  push:
    branches: [ "dev" ]
  pull_request:
    branches: [ "dev" ]
  workflow_dispatch:

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@v4

      - name: Test with Gradle
        run: ./gradlew test

      - name: Build with Gradle
        run: ./gradlew build -x test

      - name: Docker Login
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_PASSWORD }}

      - name: Build Docker Image
        run: docker build --platform linux/amd64 -t ${{ secrets.DOCKERHUB_USERNAME }}/orange .

      - name: Push Docker Image
        run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/orange

이제 dev 브랜치로 향하는 PR은 테스트코드 실행을 거치게 될 것이다.

Github Actions로 가보자!

테스트 코드는 통과하지만 무려 2분 20초나 소요되고 있다. 빌드가 5초 전후 걸린다는 점을 감안했을 때 매우 성능이 미흡하다고 볼 수 있다.

그렇다고 @Sql 어노테이션을 없애고 애플리케이션 단에서 DB 테이블에 직접 Entity 객체를 생성하여 데이터를 삽입하는 것은 너무나도 번거로운 일이다.

테스트 코드 실패 로그를 뜯어보면 이 문제의 원인은 기본적으로 외래키 제약에 있다.

org.springframework.jdbc.datasource.init.ScriptStatementFailedException: Failed to execute SQL script statement #2 of class path resource [sql/CustomDrawEventWinningInfoRepositoryImplTest.sql]: INSERT INTO event_metadata(event_type, event_frame_id, event_id) VALUES (1, 1, 'HD_240808_001')

	at org.springframework.jdbc.datasource.init.ScriptUtils.executeSqlScript(ScriptUtils.java:282)
	at org.springframework.jdbc.datasource.init.ResourceDatabasePopulator.populate(ResourceDatabasePopulator.java:254)
	at org.springframework.jdbc.datasource.init.DatabasePopulatorUtils.execute(DatabasePopulatorUtils.java:54)
	at org.springframework.jdbc.datasource.init.ResourceDatabasePopulator.execute(ResourceDatabasePopulator.java:269)
	at org.springframework.test.context.jdbc.SqlScriptsTestExecutionListener.lambda$executeSqlScripts$9(SqlScriptsTestExecutionListener.java:362)
	at org.springframework.transaction.support.TransactionOperations.lambda$executeWithoutResult$0(TransactionOperations.java:68)
	at org.springframework.transaction.support.TransactionTemplate.execute(TransactionTemplate.java:140)
	at org.springframework.transaction.support.TransactionOperations.executeWithoutResult(TransactionOperations.java:67)
	at org.springframework.test.context.jdbc.SqlScriptsTestExecutionListener.executeSqlScripts(SqlScriptsTestExecutionListener.java:362)
	at org.springframework.test.context.jdbc.SqlScriptsTestExecutionListener.lambda$executeSqlScripts$4(SqlScriptsTestExecutionListener.java:275)
	at java.base/java.lang.Iterable.forEach(Iterable.java:75)
	at org.springframework.test.context.jdbc.SqlScriptsTestExecutionListener.executeSqlScripts(SqlScriptsTestExecutionListener.java:275)
	at org.springframework.test.context.jdbc.SqlScriptsTestExecutionListener.executeClassLevelSqlScripts(SqlScriptsTestExecutionListener.java:201)
	at org.springframework.test.context.jdbc.SqlScriptsTestExecutionListener.beforeTestClass(SqlScriptsTestExecutionListener.java:145)
	at org.springframework.test.context.TestContextManager.beforeTestClass(TestContextManager.java:220)
	at org.springframework.test.context.junit.jupiter.SpringExtension.beforeAll(SpringExtension.java:133)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
Caused by: java.sql.SQLIntegrityConstraintViolationException: Cannot add or update a child row: a foreign key constraint fails (test.event_metadata, CONSTRAINT FKm6p8gh3tahf7aepb6cc01ox81 FOREIGN KEY (event_frame_id) REFERENCES event_frame (id))
	at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:118)
	at com.mysql.cj.jdbc.exceptions.SQLExceptionsMapping.translateException(SQLExceptionsMapping.java:122)
	at com.mysql.cj.jdbc.StatementImpl.executeInternal(StatementImpl.java:770)
	at com.mysql.cj.jdbc.StatementImpl.execute(StatementImpl.java:653)
	at com.zaxxer.hikari.pool.ProxyStatement.execute(ProxyStatement.java:94)
	at com.zaxxer.hikari.pool.HikariProxyStatement.execute(HikariProxyStatement.java)
	at org.springframework.jdbc.datasource.init.ScriptUtils.executeSqlScript(ScriptUtils.java:261)

이는 각 테스트 클래스마다 @Sql을 통해 삽입되는 데이터끼리 서로 충돌하기 때문이다.

그렇다면 .sql 파일의 데이터끼리 서로 충돌하지 않도록 입력하는 데이터를 조절하면 해결될까?

.sql 파일이 한두개일 때면 모를까 실제 서비스처럼 수십개까지 늘어날 때도 과연 그 전략이 유효할까?

인터넷을 찾아보니 우아한테크코스와 외국 커뮤니티 등에서 비슷한 고민이 많았다.

"테스트 환경에서 요구되는 초기화"는 테스트 클래스별 순서와 상관없이 DB의 상태를 매 클래스마다 최초 상태로 제공하여 결과를 일관적으로 보장하는 것이다. 다른 context의 초기화는 굳이 필요하지 않다.

매 테스트 클래스가 종료될 때마다 DB의 상태만 초기화하는 로직을 삽입하면 어떨까?

먼저 추상 테스트 클래스 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");
    }
}

모든 테이블의 데이터를 제거하고 AUTO_INCREMENT를 1로 초기화하였다.
이제 이 클래스를 상속받은 통합 테스트 클래스는 자신이 가진 모든 테스트 메서드가 종료되면 clearDatabase()를 통해 DB를 초기화하게 될 것이다.

다음 클래스는 초기화된 DB를 마주하므로 각 테스트 클래스별로 격리된 테스트 환경을 제공할 수 있게 된다!

로컬에서 테스트가 통과되었으니, Github Actions로 가보자.

@DirtiesContext를 뜯어냄으로써 테스트 속도를 2분 중반대에서 1분 초반대까지 약 50% 가량 개선하는 데 성공했다. 통합 테스트 수가 많아질수록 이 차이는 더 벌어질 것이다.

다만 현재의 clearDatabase() 메서드가 mySQL만을 기반으로 하기에 추후 Redis가 끼어들거나 아예 다른 데이터베이스로 교체될 경우 필연적으로 초기화 코드의 변경이 요구될 것이다.

Reference

https://stackoverflow.com/questions/48714118/reset-database-after-each-test-on-spring-without-using-dirtiescontext
https://blog.gtiwari333.com/2021/07/making-integration-tests-faster-without.html#google_vignette
https://newwisdom.tistory.com/95
https://devlopsquare.tistory.com/227

profile
Discover Tomorrow

0개의 댓글