fallback pattern test code 작성하기

dasd412·2022년 12월 1일
0

MSA 프로젝트

목록 보기
7/25

테스트 코드를 작성한 이유

회로차단기, 폴백 시도하려고 mvn clean package -> dockerfile build -> docker-compose up -> 포스트맨 테스트를 해봤다.
여기서 dockerfile build가 4분 정도 걸리더라. 왤케 느려... 나중에 원인 찾아봐야 할듯.

그래서 매번 테스트할 때마다 기다리기 짜증나서 일지 서비스 내에 단위 테스트를 넣었다. 다른 원격 자원 호출은 Mocking하면 쉽게 해결된다.

테스트 코드 기준으로, 4개 전부를 테스팅하는데 2초도 안걸렸다. 테스트는 가볍고 빠를 수록 좋다!

회로 차단기와 폴백 테스트하기

서비스 레이어 코드

@Service
public class SaveDiaryService {

    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    private final DiaryRepository diaryRepository;

    private final FindWriterFeignClient findWriterFeignClient;

    public SaveDiaryService(DiaryRepository diaryRepository, FindWriterFeignClient findWriterFeignClient) {
        this.diaryRepository = diaryRepository;
        this.findWriterFeignClient = findWriterFeignClient;
    }

    public Long postDiaryWithEntities(SecurityDiaryPostRequestDTO dto) throws TimeoutException {
        logger.info("call writer micro service for finding writer id. correlation id :{}", UserContextHolder.getContext().getCorrelationId());
        Long writerId = findWriterFeignClient.findWriterById(dto.getWriterId());

        logger.info("saving diary... correlation id :{}", UserContextHolder.getContext().getCorrelationId());
        DiabetesDiary diary = new DiabetesDiary(writerId, dto.getFastingPlasmaGlucose(), dto.getRemark());
        diaryRepository.save(diary);

        return diary.getId();
    }

}

원격 자원 호출이 2번 발생하는 것을 확인할 수 있다.

findWriterFeignClient.findWriterById(dto.getWriterId());는 다른 마이크로 서비스를 호출한다.

diaryRepository.save(diary);는 DB를 호출한다.

두 지점 모두 네트워크 지연 등의 장애가 발생하면 서비스에 악영향을 줄 수 있는 지점이다.

마이크로 서비스 호출 코드

@FeignClient("writer-service")
public interface FindWriterFeignClient {

    @CircuitBreaker(name="writerService")
    @RequestMapping(method= RequestMethod.GET,value="/writer/{writerId}",consumes = "application/json")
    Long findWriterById(@PathVariable("writerId")Long writerId) throws TimeoutException;

}

컨트롤러 레이어 코드

@RestController
@RequestMapping("/api/diary/user/diabetes-diary")
public class SecurityDiaryRestController {

    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    private final SaveDiaryService saveDiaryService;

    public SecurityDiaryRestController(SaveDiaryService saveDiaryService) {
        this.saveDiaryService = saveDiaryService;
    }

    @PostMapping
    @RateLimiter(name = "diaryService")
    @CircuitBreaker(name = "diaryService", fallbackMethod = "fallBackPostDiary")
    public ApiResult<?> postDiary(@RequestBody @Valid SecurityDiaryPostRequestDTO dto) throws TimeoutException {
        logger.debug("SecurityDiaryRestController correlation id in posting diary:{}", UserContextHolder.getContext().getCorrelationId());

        Long diaryId = saveDiaryService.postDiaryWithEntities(dto);

        return ApiResult.OK(new SecurityDiaryPostResponseDTO(diaryId));
    }

    @SuppressWarnings("unused")
    private ApiResult<?> fallBackPostDiary(SecurityDiaryPostRequestDTO dto, Throwable throwable) {
        logger.error("failed to call outer component in posting Diary. correlation id :{} , exception : {}", UserContextHolder.getContext().getCorrelationId(), throwable.getClass());
        return ApiResult.ERROR(throwable.getClass().getName(), HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

ApiResult는 ResponseEntity와 비슷하게 만든 공통 규약용 클래스일 뿐이다. ResponseEntity로 적절히 대체해도 무방하다.

테스트 코드

package com.dasd412.api.diaryservice.controller;


import com.dasd412.api.diaryservice.DiaryServiceApplication;
import com.dasd412.api.diaryservice.controller.dto.SecurityDiaryPostRequestDTO;
import com.dasd412.api.diaryservice.domain.diary.DiabetesDiary;
import com.dasd412.api.diaryservice.domain.diary.DiaryRepository;

import com.dasd412.api.diaryservice.service.client.FindWriterFeignClient;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
import org.junit.runner.RunWith;

import org.springframework.beans.factory.annotation.Autowired;

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import java.util.concurrent.TimeoutException;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@RunWith(SpringRunner.class)
@SpringBootTest(classes = DiaryServiceApplication.class)
@Execution(ExecutionMode.SAME_THREAD)
public class SaveDiaryControllerTest {

    @Autowired
    private WebApplicationContext context;


    @MockBean
    private DiaryRepository diaryRepository;

    @Autowired
    private CircuitBreakerRegistry circuitBreakerRegistry;

    @MockBean
    private FindWriterFeignClient findWriterFeignClient;

    private MockMvc mockMvc;

    private SecurityDiaryPostRequestDTO dto;

    private final String url = "/api/diary/user/diabetes-diary";

    @Before
    public void setUpDTO() {
        mockMvc = MockMvcBuilders
                .webAppContextSetup(context)
                .build();

        dto = new SecurityDiaryPostRequestDTO(1L, 100, "TEST");
    }

    @After
    public void clean() {
        diaryRepository.deleteAll();
    }

    @Test
    public void testSaveDiaryWhenCircuitBreakClosedAndMicroServiceCallTimeOut() throws Exception {
        //given
        circuitBreakerRegistry.circuitBreaker("diaryService")
                .transitionToClosedState();

        //when
        when(findWriterFeignClient.findWriterById(1L)).thenThrow(new TimeoutException());
        mockMvc.perform(post(url)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(new ObjectMapper().writeValueAsString(dto)))
                .andExpect(jsonPath("$.success").value("false"))
                .andExpect(jsonPath("$.error.message").exists())
                .andExpect(jsonPath("$.error.status").value("500"));

        //then
        assertThat(diaryRepository.findAll().size()).isEqualTo(0);
    }

    @Test
    public void testSaveDiaryWhenCircuitBreakClosedAndDataBaseTimeOut() throws Exception {
        //given
        circuitBreakerRegistry.circuitBreaker("diaryService")
                .transitionToClosedState();
        given(findWriterFeignClient.findWriterById(1L)).willReturn(1L);
        given(diaryRepository.save(any(DiabetesDiary.class))).willAnswer(invocation -> {
            throw new TimeoutException();
        });

        //when
        mockMvc.perform(post(url)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(new ObjectMapper().writeValueAsString(dto)))
                .andExpect(jsonPath("$.success").value("false"))
                .andExpect(jsonPath("$.error.message").exists())
                .andExpect(jsonPath("$.error.status").value("500"));

        //then
        assertThat(diaryRepository.findAll().size()).isEqualTo(0);
    }

    @Test
    public void testSaveDiaryWhenCircuitBreakOpen() throws Exception {
        //given
        circuitBreakerRegistry.circuitBreaker("diaryService")
                .transitionToOpenState();

        given(findWriterFeignClient.findWriterById(1L)).willReturn(1L);
        given(diaryRepository.save(any(DiabetesDiary.class))).willAnswer(invocation -> {
            throw new TimeoutException();
        });

        //when
        mockMvc.perform(post(url)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(new ObjectMapper().writeValueAsString(dto)))
                .andExpect(jsonPath("$.success").value("false"))
                .andExpect(jsonPath("$.error.message").exists())
                .andExpect(jsonPath("$.error.status").value("500"));

        //then
        assertThat(diaryRepository.findAll().size()).isEqualTo(0);
    }

    @Test
    public void testSaveDiaryWhenCircuitBreakClosedAndAllServiceAvailable() throws Exception {
        //given
        circuitBreakerRegistry.circuitBreaker("diaryService")
                .transitionToClosedState();

        given(findWriterFeignClient.findWriterById(1L)).willReturn(1L);

        //when
        when(diaryRepository.save(any(DiabetesDiary.class))).thenReturn(new DiabetesDiary());

        mockMvc.perform(post(url)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(new ObjectMapper().writeValueAsString(dto)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.success").value("true"));

        //then
        verify(diaryRepository).save(any());
    }

}

로그 예시

testSaveDiaryWhenCircuitBreakClosedAndDataBaseTimeOut의 경우의 로그이다.

2022-12-02 11:36:53.231  INFO 2560 --- [           main] o.s.b.t.m.w.SpringBootMockServletContext : Initializing Spring TestDispatcherServlet ''
2022-12-02 11:36:53.231  INFO 2560 --- [           main] o.s.t.web.servlet.TestDispatcherServlet  : Initializing Servlet ''
2022-12-02 11:36:53.247  INFO 2560 --- [           main] o.s.t.web.servlet.TestDispatcherServlet  : Completed initialization in 16 ms
2022-12-02 11:36:53.394 DEBUG 2560 --- [           main] c.d.a.d.c.SecurityDiaryRestController    : SecurityDiaryRestController correlation id in posting diary:
2022-12-02 11:36:53.394  INFO 2560 --- [           main] c.d.a.d.service.SaveDiaryService         : call writer micro service for finding writer id. correlation id :
2022-12-02 11:36:53.409  INFO 2560 --- [           main] c.d.a.d.service.SaveDiaryService         : saving diary... correlation id :
2022-12-02 11:36:53.409 ERROR 2560 --- [           main] c.d.a.d.c.SecurityDiaryRestController    : failed to call outer component in posting Diary. correlation id : , exception : class java.util.concurrent.TimeoutException

코드 설명

https://ynovytskyy.medium.com/unit-testing-circuit-breaker-8ed2c9098e11
링크를 참고하면 회로 차단기 패턴에 대해 자세히 설명이 나와 있다.

그런데 폴백 패턴의 경우 서비스 레이어에서 테스트하면 실행이 안되더라. 그래서 컨트롤러 레이어로 폴백과 회로 차단기를 같이 올린 후, 테스트했더니 폴백 패턴 실행이 확인되었다.

회로 차단기 활성 여부 or 마이크로 서비스 정상 호출 여부 or DB 정상 호출 여부에 대해 케이스를 나눠서 테스트했다.

보충 설명

  1. 회로 차단기 패턴의 경우 open이 연결이 끊어진 상태이고 closed가 정상적으로 연결이 된 상태이다.

참고 링크

https://jojoldu.tistory.com/226

https://junroot.github.io/programming/AssertJ-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0-(2)/


profile
아키텍쳐 설계와 테스트 코드에 관심이 많음.

0개의 댓글