ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 우당탕탕 테스트 격리 트러블 슈팅기
    카테고리 없음 2022. 8. 1. 13:14

    0. 문제 상황

    HashtagServiceTest(서비스의 테스트) 코드를 짜던 도중이었습니다.

    전체 테스트들을 실행할 시, HashtagServiceTest가 랜덤 확률로 통과하거나 실패하는 상황이 반복되었습니다. 대략 5번 테스트를 실행할 시 1번 꼴로 테스트가 실패했습니다.

    막상 HashtagServiceTest만 돌렸을 땐 테스트가 문제없이 통과했습니다.

     

    랜덤 가챠 테스트...

    1. 문제 해결

    랜덤한 확률로 테스트가 실패할 때 고려해야 할 부분은 다음과 같습니다.

    1. 테스트 메소드의 로직이 잘못되어 순서에 따라 다른 테스트 코드가 영향을 받고 있을 수 있다
    2. 테스트 환경이 잘못되어 온전한 초기화가 이뤄지지 않고 있다

    둘다 결국 테스트 격리의 문제입니다.

    이중 저는 2번, 테스트 환경의 문제라고 판단했습니다.

    1. 먼저 실행되는 인수테스트(HashtagAcceptanceTest)는 항상 성공했고,
    2. HashtagServiceTest만 돌릴 시 테스트들이 항상 통과했기 때문입니다.

    1-1. 인수테스트 초기화 방식 변경 (@DirtiesContext@Sql)

    @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
    @DirtiesContext(classMode = ClassMode.BEFORE_EACH_TEST_METHOD)
    public class AcceptanceTest {...}
    

    우선, HashtagAcceptanceTest 초기화를 위해 사용한 @DirtiesContext 어노테이션에 대해 찾아봤습니다.

    해당 어노테이션 사용 시 테스트 격리가 이뤄지지 않을 때가 있다는 글을 보게 되었습니다.

    Beware of @DirtiesContext - Codecleaner

     

    Beware of @DirtiesContext - Codecleaner

    Closing and recreating the application context is costly. Imagine a big project with a lot of classes and tests where we are using @DirtiesContext a lot. This

    www.codecleaner.org

     

    1) 1차 문제 해결

    스프링 깃허브에서 해당 문제와 관련된 이슈를 찾아봤습니다.

    @DirtiesContext does not destroy all cached singleton beans [SPR-8857] · Issue #13499 · spring-projects/spring-framework

     

    @DirtiesContext does not destroy all cached singleton beans [SPR-8857] · Issue #13499 · spring-projects/spring-framework

    Saurabh Chandra opened SPR-8857 and commented Data from one test having data-source as H2 (in-memory database) seems to live on for the next test class. This persists even after using @DirtiesConte...

    github.com

    After a long digging, we just found, that if a method or class is annotated with @DirtiesContext , it doesn't clean up all the objects. It seems, that the "old" application context (or part of it) remains in the memory and can not be cleared up by the GC.

    l had a similar issue using HSQLDB with Spring 4.1 (using Boot).

    Between tests (with @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) enabled) I noticed that the data was cleaned - because I used @Transactional in my tests) but that HSQLDB autoincrement numbering was not reset.

    This had to do that HSQLDB was not shutdown between tests.

    After adding ;shutdown=true to my jdbc url everything was working as expected.


    @DirtiesContext (HashtagAcceptanceTest 에서 사용)와 @Transactional (HashtagServiceTest에서 사용)을 사용한 테스트들을 동시에 돌릴 시 문제가 있을 수 있다는 코멘트가 있었습니다.

     

    BEFORE application.yml

    spring:
      datasource: //이 부분 삭제
        url: jdbc:h2:mem:db?serverTimezone=Asia/Seoul;MODE=MYSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
        driver-class-name: org.h2.Driver
        username: sa
      jpa:
      	...

    AFTER application.yml

    spring:
    	//삭제
      jpa:
        hibernate:
          ...

     

    테스트용 application.yml에서 아예 dataSource 할당하는 부분을 삭제했더니 정상적으로 테스트가 돌아갔습니다!

    (Thanks To 오찌!)

     

    2) 2차 문제 해결 : @DirtiesContext 삭제

    그런데 @DirtiesContext는 간편하지만 테스트가 느립니다. 테스트 메소드 별로 컨텍스트를 다시 띄워야 하기 때문에 많은 시간이 소요됩니다.

    인수테스트 클래스 하나를 돌리는 데에 무려 19초

     

    인수테스트에서 테스트 격리하기

     

    인수테스트에서 테스트 격리하기

    tecoble.techcourse.co.kr

     

    고민 끝에, 인수테스트에서 @Sql 어노테이션 및 truncate.sql문을 통해 초기화를 진행하도록 변경했습니다.

    @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
    @Sql(
            scripts = {"classpath:truncate.sql"},
            executionPhase = BEFORE_TEST_METHOD)
    public class AcceptanceTest {...}
    
    public class HashtagAcceptanceTest extends AcceptanceTest {...}
    

     

    테스트용 application.yml 역시 기존의 DB를 명시하는 형태로 롤백했습니다.

    spring:
      datasource:
        url: jdbc:h2:mem:db?serverTimezone=Asia/Seoul;MODE=MYSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
        driver-class-name: org.h2.Driver
        username: sa
      jpa:
      	...

     

    1-2. 서비스 테스트 (통합테스트) 초기화 방식 변경

     

    BEFORE(HashtagServiceTest)

    @SpringBootTest
    @Transactional
    class HashtagServiceTest {...}
    

     

    서비스테스트 격리를 위해 사용한 @Transactional 어노테이션에 대해 찾아봤습니다.

    다음과 같은 글을 찾을 수 있었습니다.

    JPA 사용시 테스트 코드에서 @Transactional 주의하기

     

    JPA 사용시 테스트 코드에서 @Transactional 주의하기

    서비스 레이어()에 대해 테스트를 한다면 보통 DB…

    tecoble.techcourse.co.kr

    @Transactional 어노테이션과 JPA를 사용하는 ServiceTest 사이에 호환성에 문제가 있기 때문에

    해당 초기화 방식을 권장하지 않는다고 합니다.

     

    이에 ServiceTest에서도

    @Transactional 어노테이션 대신,

    @Sql 어노테이션 및 truncate.sql을 사용해 초기화하도록 변경했습니다.

    truncate.sql

    

    AFTER(HashtagServiceTest)

    @SpringBootTest
    @Sql(
            scripts = {"classpath:truncate.sql"},//sql 설정
            executionPhase = BEFORE_TEST_METHOD)
    class HashtagServiceTest {...}

    (+ 추가)

    현재 truncate.sql이 DB 스키마 변경에 의존적 부분을  Entity

    3. 2차 문제 - LazyInitializationException

    ServiceTest에서 @Transactional 어노테이션을 빼니 LazyInitializationException 에러가 터졌습니다.

    could not initialize proxy [com.wooteco.sokdak.hashtag.domain.Hashtag#3] - no Session
    org.hibernate.LazyInitializationException: could not initialize proxy [com.wooteco.sokdak.hashtag.domain.Hashtag#3] - no Session
    	...
    

     

    원인은 postHashtag 연결 엔티티에서 Hashtag, Post를 저장할 때

    Lazy 로딩으로 가져온다는 데에 있었습니다.

     

    서비스 메소드를 호출하고, 트랜잭션이 닫힌 후, 다시 Lazy 로딩을 걸어 값을 가져오려고 하니

    DB와 연결된 Connection이 없어서 생기는 오류였습니다.

     

    [Hibernate / JPA ] LazyLoading | No Session 에러에 대해서

     

    [Hibernate / JPA ] LazyLoading | No Session 에러에 대해서

    "No Session 에러는 개발자의 실수!"  보통 하이버네이트/JPA 기반의 프로젝트에서 주로 발생하는 에러는 No Session에러이다. 해당 에러는 영속성(편하게 말하자면 영혼을 가진, 데이터베이스와 바인

    sticky32.tistory.com

     

    ServiceTest에 한정해

    @Transactional 어노테이션과 @Sql 어노테이션을 함께 사용하니

    다시 테스트가 문제없이 돌아갔습니다...

    @SpringBootTest
    @Sql(
            scripts = {"classpath:truncate.sql"},
            executionPhase = BEFORE_TEST_METHOD)
    @Transactional
    class HashtagServiceTest {...}
    

     

    4. 2차 문제 해결 및 논의사항

    ServiceTest 에 한정했지만, @Transactional ,@Sql 어노테이션을 함께 사용한다는 것은

    1. 두 번의 초기화가 이뤄진다.
    2. 서비스클래스의 메소드에 걸린 @Transactional 이 무시된다

    는 뜻입니다.

    HashtagService 클래스의 deleteAllByPostId 메소드. 트랜잭션이 걸려있다.

    현재는 해당 사진의 메소드 속 트랜잭션을 검증할 수 없습니다.

     

    4-1. 해결 방법 1 : fetch join

    정석적인 방법은 Lazy loading으로 들어오는 값을 리턴하는 경우, 쿼리를 fetch join으로 바꿔주는 것입니다. 그러면 더 이상 Lazy loading이 필요하지 않게 되면서 정상적으로 테스트 코드가 통과하게 됩니다.

    public interface PostHashtagRepository extends JpaRepository<PostHashtag, Long> {
    
        @Query("SELECT ph from PostHashtag ph JOIN FETCH ph.hashtag h JOIN FETCH ph.post p WHERE  p.id = :id")
        List<PostHashtag> findAllByPostId(Long id);
    

    하지만 이를 위해서는 SpringDataJPA의 이점을 일부 포기해야 합니다.

    관련 Repository 메소드에 모두 fetch join을 걸어주는 쿼리를 설정하게 됩니다.

     

    4-2. 해결 방법 2 : @EntityGraph

    이러한 불편함을 해결하기 위해 @EntityGraph 어노테이션을 사용할 수도 있습니다.

    public interface PostHashtagRepository extends JpaRepository<PostHashtag, Long> {
    
        @EntityGraph(attributePaths = {"post", "hashtag"})
        List<PostHashtag> findAllByPostId(Long id);
    

     

     

    다만 EntityGraph 적용 시 Outer Join이 되는 문제가 있습니다.

     

    4-3. 과제

    일대 다 매핑에서 위 두 방법을 적용 시 페이지네이션을 사용할 수 없습니다. 이를 고려한 접근이 필요합니다.

     

    created by 헌치

Designed by Tistory.