카카오 테크 캠퍼스 3단계 축팅의 인기피드를 개발하고 테스트를 하고 있는 도중 아래와 같은 이슈를 만났습니다.
Redis에서 인기 게시물을 조회하는 기능 테스트시 Fail이 발생하였습니다.
단위테스트를 수행할 경우에는 성공하는 데, "Run all Tests"로 모든 단위테스트를 한번에 돌릴 때는 실패할 때도 있고, 성공할 때도 있습니다.
먼저 상황을 분석해보겠습니다.
상황 분석
teardown.sql을 사용해 테스트 시에 게시물 300개를 테이블에 저장하고 있습니다.
Run all Tests시 해당 ControllerTest에서 Fail이 발생하였습니다.
@Sql("classpath:db/teardown.sql")
@AutoConfigureMockMvc
@SpringBootTest(webEnvironment = WebEnvironment.MOCK)
public class PostControllerTest {
@Autowired
private ObjectMapper objectMapper;
@Autowired
private MockMvc mockMvc;
@Autowired
private PostRepository postRepository;
@Autowired
private RedisTemplate redisTemplate;
@DisplayName("인기 피드를 조회 - 정상 파라미터")
@Test
void findAllPopularPost_Test() throws Exception {
ResultActions resultActions = mockMvc.perform(
get("/api/popular-post")
.param("level3", "4")
.param("level2", "3")
.param("level1", "3")
.contentType(MediaType.APPLICATION_JSON));
String responseBody = resultActions.andReturn().getResponse().getContentAsString();
System.out.println("findAllPopularPost_Test : " + responseBody);
resultActions.andExpect(status().isOk());
resultActions.andExpect(jsonPath("$.success").value(true));
resultActions.andExpect(jsonPath("$.response").exists());
}
...
}
Test가 FAIL 시
EX - 1)
{1=[157, 251, 263], 2=[33, 71, 91], 3=[8, 10, 11, 19]}
SortedSet에서 key가 POPULAR_POST인 크기 : 3
조회하는 등수 : 157
POST : []
오류 로그
Request processing failed; nested exception is java.lang.IndexOutOfBoundsException: Index 0 out of bounds for length 0
org.springframework.web.util.NestedServletException: Request processing failed; nested exception is java.lang.IndexOutOfBoundsException: Index 0 out of bounds for length 0
EX - 2)
{1=[140, 247, 257], 2=[46, 67, 88], 3=[4, 6, 21, 23]}
SortedSet에서 key가 POPULAR_POST인 크기 : 34
조회하는 등수 : 140
POST : []
테스트가 실패할 경우에는 Redis의 해당 key에 size가 300개가 나와야하는 데 300개가 나오지 않았습니다.
하지만, Redis에 게시물 300개를 저장하는 메서드는 정상 동작하는 것을 아래의 단위테스트를 통해 확인하였습니다.
@SpringBootTest
@Sql(value = "classpath:db/teardown.sql")
class SaveTemporaryPopularPostListUsecaseTest {
@Autowired
private SaveTemporaryPopularPostListUsecase saveTemporaryPopularPostListUsecase;
@Autowired
private PostRepository postRepository;
@Autowired
private RedisTemplate redisTemplate;
@Test
void execute() {
List<Post> posts = postRepository.findAll();
saveTemporaryPopularPostListUsecase.execute();
Long size = redisTemplate.opsForZSet().size(RedisKey.POPULAR_POST_KEY.getKey());
org.assertj.core.api.Assertions.assertThat(size).isEqualTo(300);
}
}
따라서, 아래와 같은 가설을 세워보았습니다.
가설
- Run All Tests시 각 단위테스트 실행 순서가 바뀌는 것인가??
- 각 단위테스트 간에 연관성이 있지 않을까, 즉 테스트의 격리가 제대로 되지 않은 것일까?
검증
- Run All Tests시 각 단위테스트의 실행 순서는 항상 같지 않다고 한다.
- PostControlletTest는 SaveTemporaryPopularPostListUsecaseTest에 의존한다.
왜 연관성이 있을까?? 왜냐하면, SaveTemporaryPopularPostListUsecaseTest는 Redis에 게시물들을 저장하고 있기 때문이고 PostControllerTest는 Redis에 있는 게시물들을 조회하고 있기 때문이다.
어느순간 해당 단위테스트마저 Fail이 나와버렸습니다.
@DisplayName("인기 피드를 조회 - 정상 파라미터")
@Test
void findAllPopularPost_Test() throws Exception {
ResultActions resultActions = mockMvc.perform(
get("/api/popular-post")
.param("level3", "4")
.param("level2", "3")
.param("level1", "3")
.contentType(MediaType.APPLICATION_JSON));
String responseBody = resultActions.andReturn().getResponse().getContentAsString();
System.out.println("findAllPopularPost_Test : " + responseBody);
resultActions.andExpect(status().isOk());
resultActions.andExpect(jsonPath("$.success").value(true));
resultActions.andExpect(jsonPath("$.response").exists());
}
직접 확인해보기 위해서, Redis안에 저장되어있는 데이터를 확인해보니 저장된 데이터가 없었습니다.
즉, Redis의 데이터가 삭제된 것이었습니다.
왜 삭제가 된 것일까요?
답은 제 코드안에 있었습니다.
@Component
@RequiredArgsConstructor
public class SaveTemporaryPopularPostListUsecase {
private final PostRepository postRepository;
private final RedisTemplate redisTemplate;
private final int POPULARITY_SIZE = 300;
/**
* MySQL에서 인기도가 높은 상위 300개의 게시물을 가져와 이를 Redis에 자장한다.
*
* @author : hwangdaesun
*/
@Transactional
public void execute(){
List<GetIncompletePopularPostDTO> top300Posts = getTop300Posts();
deletePopularPostsCache();
setPopularPostsCache(top300Posts);
}
private List<GetIncompletePopularPostDTO> getTop300Posts(){
return postRepository.findTop300ByOrderByPopularityDesc(PageRequest.of(0, POPULARITY_SIZE));
}
private void deletePopularPostsCache(){
redisTemplate.delete(RedisKey.POPULAR_POST_KEY.getKey());
}
private void setPopularPostsCache(List<GetIncompletePopularPostDTO> top300Posts){
top300Posts.forEach(this::setPopularPostCache);
}
private void setPopularPostCache(GetIncompletePopularPostDTO dto) {
ZSetOperations zSetOperations = redisTemplate.opsForZSet();
double score = dto.getPopularity().doubleValue();
zSetOperations.add(RedisKey.POPULAR_POST_KEY.getKey(), dto, score);
}
}
위 SaveTemporaryPopularPostListUsecase를 SchedulePostPopularity에서 스케줄링하고 있습니다.
@Component
@RequiredArgsConstructor
public class SchedulePostPopularity {
private final UpdatePostPopularityUsecase updatePostPopularityUsecase;
@Scheduled(initialDelayString = "${schedules.popularPost.initialDelay}", fixedDelayString = "${schedules.popularPost.fixedDelay}")
public void execute() {
updatePostPopularityUsecase.execute();
}
}
schedules:
popularPost:
fixedDelay: 36000000
initialDelay: 0
즉, initialDelay가 0이기 때문에 Run all tests시 SaveTemporaryPopularPostListUsecaseTest가 먼저 실행되면 Redis에 데이터가 저장되었기 때문에 테스트가 성공하는 것이고 findAllPopularPost_Test가 먼저 실행되면 테스트가 실패하는 것이다.
또한, 테스트가 끝난 후에 Redis의 데이터는 삭제되지 않는다.
따라서, 테스트의 독립성을 보장하려면 findAllPopularPost_Test가 실행되기 전에는 Redis에 데이터가 세팅되어 있어야하고 테스트가 끝난 이후에는 Redis의 데이터가 삭제되어야한다.
해결
문제가 발생한 테스트 코드에 아래와 같은 코드를 추가하였습니다.
@BeforeEach
void init_start(){
Set<String> keys = redisTemplate.keys("*");
if (keys != null && !keys.isEmpty()) {
redisTemplate.delete(keys);
}
}
@AfterEach
void init_end(){
Set<String> keys = redisTemplate.keys("*");
if (keys != null && !keys.isEmpty()) {
redisTemplate.delete(keys);
}
}
https://github.com/Step3-kakao-tech-campus/Team14_BE/pull/92
jakekwiee 멘토님.. 덕분에 원인을 파악할 수 있었습니다!
너무 감사합니다!!
이번 이슈를 통해서 단위 테스트의 "단위"의 의미 즉, 테스트 격리에대한 중요성을 알게되었습니다.
'Project Trouble Shooting > [축팅] 축제 소개팅 어플리케이션 - 카카오 테크 캠퍼스 1기' 카테고리의 다른 글
Transaction을 고려한 CheckedException 예외 처리 (3) | 2023.11.12 |
---|---|
ThreadLocalRandom의 설계의도와 스레드의 관계 (1) | 2023.11.01 |
ComposeMethod을 적용해 리팩터링 해보자 (1) | 2023.10.13 |
Instant 클래스 도입에 관한 고찰 (0) | 2023.10.02 |
이력 유형 데이터 모델링을 어떻게 해야할까?? (0) | 2023.09.14 |