[리팩토링] 통합테스트와 단위테스트를 동시에 수행시 발생하는 테스트 메서드의 독립성 문제

  • 프로젝트 수행중
    • 통합 테스트와 단위 테스트 간의 데이터베이스의 충돌이 발생하는 일이 생겼다.
  • @Sql 을 이용하여
    • 통합테스트에서 별도 테스트 데이터를 생성한 이후에
    • @Rollback 을 시도하였지만
    • 별도로 초기화가 수행되지 않는 것 같다..
    • 이유는 솔직히 잘 모르겠다… 솔직히 파고들면 이건 끝도 없을거같아 일단 패스함..
  • 일단 테스트 후에 데이터베이스를 비우는 행위 자체는 일단 가능은 하다.
    • Spring Boot 에서는
    • @DirtiesContext 어노테이션을 사용해 테스트 후 Spring Application Context 를 재 로드 할 수 있다.
    • 테스트후에 사용된 데이터는 초기 상태로 돌아가게 되는 것이다.
    • @SpringBootTest 의 경우 컨텍스트를 로드하게 이전에는 발생하지 않던 문제가 발생하는 것이 아닐까 생각된다.
    • 일단은..
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)

테스트 코드에 성능에 문제가 있지 않을까라는 생각은 든다.

하지만 일단 당장 통합테스트의 구현을 위해 추가해본다.

하지만.. 이후에도 실패했다.

  • 컨텍스트로드는 컨텍스트 로드이지 DB 초기화의 역할은 수행하지 않는다..
    • 혹시나 싶어서 기대했건만..
    • 진짜 AfterEach 로 일일이 제어해야하는건가?..
  • 탈모올것같다.

일단, @Rollback의 경우 트랜잭션의 영향을 받는다.

  • 하지만 비동기적인 테스트 수행의 경우 각각 독립적인 트랜잭션을 수행중이기에, 해당 @Rollback 어노테이션은 정상적으로 동작하지 않을 수 있다고한다.
  • 사용하는 것이 의미가 없는 것이다.

@SpringBootTest에서 롤백이 되지 않는 이유

[Spring] @SpringBootTest의 테스트 격리시키기(TestExecutionListener), @Transactional로 롤백되지 않는 이유
이번에 넥스트스텝 ATDD 강의를 듣게 되었습니다. 과제 중에 @SpringBootTest를 사용하는 테스트들을 격리시키는 부분이 있었는데, 제가 사용했던 방법을 공유하도록 하겠습니다. 1. SpringBootTest가 @Transactional로 롤백되지 않는 이유 [ SpringBootTest에서 트랜잭션 롤백되지 않는 이유 ] 테스트에서의 트랜잭션 롤백 @DataJpaTest를 사용하면 영속성 계층을 편리하게 테스트 할 수 있다. 각각의 테스트 메소드가 끝나면 테이블은 비워지면서 모든 테스트가 격리된다. 그 이유는 @DataJpaTest 어노테이션 안에 @Transactional이 있어서 테스트가 끝나면 트랜잭션을 롤백시키기 때문이다. @Target(ElementType.TYPE) @Retentio..
https://mangkyu.tistory.com/264
  • 테스트 수행후에 테이블을 아예 truncate 해버리면 어떨까 라는 생각이 들었다.
  • 그래서
#custom table schema
customed.table.schema = item-browser
@Value("${customed.table.schema}")
    private String tableSchema;
@AfterEach
    void cleanUpAfterEachTest() {
        String selectAllTableName = MessageFormat.format("SELECT Concat('TRUNCATE TABLE ', TABLE_NAME, ';') " +
                                                             "FROM INFORMATION_SCHEMA.TABLES " +
                                                             "WHERE table_schema = '{0}' " +
                                                             "AND table_type = 'BASE TABLE' ", tableSchema);
        List<String> truncateTableSqls = jdbcTemplate.queryForList(selectAllTableName, String.class);
        truncateTableSqls.forEach(jdbcTemplate::execute);
    }
  • 테이블 메타데이터에서 모든 테이블을 검색한 뒤
    • 모든 테이블에 대한 truncate 를 수행하는 쿼리를 조합한다.
    • 그 후에 모든 쿼리를 수행시켜버리는것이다
  • 물론 이 방법도 만약 spring.profile 이 변경되는 경우

진짜 매우 심각한

  • 위험이 발생한다
  • 그렇기에 이러한 방어 어노테이션을 삽입했다
@SpringBootTest
@IfProfileValue(name = "spring.profiles.active", value = "local")
public class OrderDeleteIntegrationTest {
  • 프로파일이 local 로 활성화된 경우에만 해당 통합테스트가 돌도록 수행했다.

Message.format 으로 SQL 을 만드는 경우

  • 쿼리의 작은 따옴표와 중괄호가 충돌할 수 있다고 한다.
  • 그래서 작은 따옴표를 일단 이스케이핑 처리를 해야한다고한다.
  • 이스케이핑 처리를 하면 일단 쿼리가 너무 더러워 질거같아

String.format 으로 변경했다.

String selectAllTableName = String.format("SELECT Concat('TRUNCATE TABLE ', TABLE_NAME, ';') " +
                                                      "FROM INFORMATION_SCHEMA.TABLES " +
                                                      "WHERE table_schema = '%s' " +
                                                      "AND table_type = 'BASE TABLE' ", tableSchema);

그리고 truncate 를 수행하는 경우 제약조건에 영향을 받을 수 있기에

@AfterEach
    void cleanUpAfterEachTest() {
        String selectAllTableName = String.format("SELECT Concat('TRUNCATE TABLE ', TABLE_NAME, ';') " +
                                                      "FROM INFORMATION_SCHEMA.TABLES " +
                                                      "WHERE table_schema = '%s' " +
                                                      "AND table_type = 'BASE TABLE' ", tableSchema);
        jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0;");
        List<String> truncateTableSqls = jdbcTemplate.queryForList(selectAllTableName, String.class);
        truncateTableSqls.forEach(jdbcTemplate::execute);
        jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1;");
    }
  • 최종적으로 이러한 코드가 되었다.

일단

  • 통합테스트 단건 자체는 통과했다
  • 이후에 단위테스트와 동시에 진행해보자

  • 모든 테스트를 통과했다.

하지만 모든 테스트에 이러한 코드를 추가하는 바보같은 행동을 지속적으로 할 순없다.

이후 해당 메서드는 별도 클래스로 분리후에 별도 어노테이션으로 주입될 수 있도록 되어야 할 것 같다.


11.26 추가

  • 스레드의 성공 외의 실패의 케이스도 수정을 했다.
@Test
    @Sql(scripts = {"classpath:/sql/member/insert_member.sql", "classpath:/sql/shippinginfo/insert_shipping_info.sql", "classpath:sql/product" +
        "/insert_product.sql", "classpath:sql/order/insert_order_product.sql",
        "classpath:sql" + "/order/insert_order.sql"})
    @DisplayName("5개의 스레드로 동시에 주문 취소를 수행하는 경우, 1개의 스레드는 성공하고 나머지는 실패해야 합니다.")
    public void When_5ThreadsDeleteSameOrder_Expect_OnlyOneSuccessAnd() throws InterruptedException {
        // given - @Sql
        int threadCount = 5;
        int expectedSuccessCount = 1;
        int expectedFailCount = threadCount - expectedSuccessCount;
        
        ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
        AtomicInteger successCount = new AtomicInteger(0);
        AtomicInteger failCount = new AtomicInteger();
        List<Future<?>> futures = new ArrayList<>();
        
        // when
        for (int i = 0; i < threadCount; i++) {
            futures.add(executorService.submit(() -> {
                orderService.removeOrder(1L);
                successCount.incrementAndGet();
            }));
        }
        
        executorService.shutdown();
        boolean didAllThreadsTerminate = executorService.awaitTermination(1, MINUTES);
        
        // then
        // 스레드가 모두 1분안에 수행된 경우에만 성공
        assertThat(didAllThreadsTerminate).isTrue();
        // 하나만 성공하는지 확인
        assertThat(successCount.get()).isEqualTo(expectedSuccessCount);
        // 스레드의 개수와 future 가 동일한지 확인
        assertThat(futures.size()).isEqualTo(threadCount);
        
        // 실패한 테스트의 경우 CustomIllegalStateException 이 발생하므로, 이를 통해 실패한 테스트를 확인
        futures.stream()
               .filter(Future::isDone)
               .forEach(future -> {
                   try {
                       future.get();
                   } catch (InterruptedException | ExecutionException e) {
                       // CustomIllegalStateException 이 발생해야함 - 주문이 존재하지 않음
                       assertThatCode(() -> {
                           throw e.getCause();
                       }).isInstanceOf(CustomIllegalStateException.class)
                         .hasMessage(ErrorCode.ORDER_NOT_FOUND.getMessage());
                       
                       // 실패 카운트 증대
                       failCount.getAndIncrement();
                   }
               });
        
        // 실패카운트가 4개인지 확인
        assertThat(failCount.get()).isEqualTo(expectedFailCount);
    }
  • 찾아보니 future를 활용하는 방식이 잘못되었었다.
    • Future::IsCancelled 는 해당 메서드의 실패에 대한 내용이 아니라
      • 아예 Future 에 성공이나 예외를 담지 못한 케이스는 isCancelled() 가 true 를 반환하고 있던 것이다.
    • IsDone 으로 일단 성공과 예외 케이스에 대해 필터링을 하고
      • 해당 케이스에서 예외가 발생하면
      • InterruptedException 혹은 ExecutionException 에 감싸서 예외가 던져진다고 한다.
        • 일단 커스텀 예외는 e.getCause() 를 통하여 꺼내서 확인이 가능하기에,
        • 해당 예외의 인스턴스와 메시지를 체크하는 방식으로 예외 체크를 진행했다.
  • 이후에 실패 카운트의 증대 부분은
    • 람다 안에서 별도로 카운트를 진행하기에
      • 원자성을 지키기 위해 AtomicInteger 로 설정하였다..


Uploaded by N2T