-
우당탕탕 테스트 격리 트러블 슈팅기카테고리 없음 2022. 8. 1. 13:14
0. 문제 상황
HashtagServiceTest(서비스의 테스트) 코드를 짜던 도중이었습니다.
전체 테스트들을 실행할 시, HashtagServiceTest가 랜덤 확률로 통과하거나 실패하는 상황이 반복되었습니다. 대략 5번 테스트를 실행할 시 1번 꼴로 테스트가 실패했습니다.
막상 HashtagServiceTest만 돌렸을 땐 테스트가 문제없이 통과했습니다.
1. 문제 해결
랜덤한 확률로 테스트가 실패할 때 고려해야 할 부분은 다음과 같습니다.
- 테스트 메소드의 로직이 잘못되어 순서에 따라 다른 테스트 코드가 영향을 받고 있을 수 있다
- 테스트 환경이 잘못되어 온전한 초기화가 이뤄지지 않고 있다
둘다 결국 테스트 격리의 문제입니다.
이중 저는 2번, 테스트 환경의 문제라고 판단했습니다.
- 먼저 실행되는 인수테스트(HashtagAcceptanceTest)는 항상 성공했고,
- 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
1) 1차 문제 해결
스프링 깃허브에서 해당 문제와 관련된 이슈를 찾아봤습니다.
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는 간편하지만 테스트가 느립니다. 테스트 메소드 별로 컨텍스트를 다시 띄워야 하기 때문에 많은 시간이 소요됩니다.
고민 끝에, 인수테스트에서 @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 주의하기
@Transactional 어노테이션과 JPA를 사용하는 ServiceTest 사이에 호환성에 문제가 있기 때문에
해당 초기화 방식을 권장하지 않는다고 합니다.
이에 ServiceTest에서도
@Transactional 어노테이션 대신,
@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 에러에 대해서
ServiceTest에 한정해
@Transactional 어노테이션과 @Sql 어노테이션을 함께 사용하니
다시 테스트가 문제없이 돌아갔습니다...
@SpringBootTest @Sql( scripts = {"classpath:truncate.sql"}, executionPhase = BEFORE_TEST_METHOD) @Transactional class HashtagServiceTest {...}
4. 2차 문제 해결 및 논의사항
ServiceTest 에 한정했지만, @Transactional ,@Sql 어노테이션을 함께 사용한다는 것은
- 두 번의 초기화가 이뤄진다.
- 서비스클래스의 메소드에 걸린 @Transactional 이 무시된다
는 뜻입니다.
현재는 해당 사진의 메소드 속 트랜잭션을 검증할 수 없습니다.
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 헌치