본문 바로가기
코비의 개발일지

컨트롤러 단위 테스트시 모든 요청에 대한 공통 속성 적용하기

by 규난 2023. 4. 10.
728x90

이번에 사내에서 Spring REST Doc을 이용하여 API 문서화 작업을 진행 중

컨트롤러 테스트 코드에 공통적으로 적용되는 속성에 대한 코드가 반복적으로 나타나는 부분을 리팩토링 한 내용을 공유할까 합니다.

 

밑의 코드는 테스트 코드 작성 시 코드마다 반복되는 부분을 나타내는 코드입니다.

(회사 api의 상세 주소는 공개할 수 없기 때문에 주소는 간략하게 작성하였습니다.)

코드를 보시면 contextPath, characterEncoding, contentType이 계속 반복되는 것을 볼 수 있습니다. 

처음에는 대수롭지 않게 넘어갔는데 테스트 코드가 점점 많아질수록 저 코드를 치는 것조차 조금씩 귀찮아지더라고요...

 

@WebMvcTest(
        controllers = CommonApiControllerTest.class,
        excludeFilters = {
                @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebSecurityConfig.class),
                @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebMvcConfig.class)
        }
)
@ExtendWith(RestDocumentationExtension.class)
public class CommonApiControllerTest {
    @Test
    public void getRemittancePurposeItems() throws Exception {
        ApiResDto<List<RemittancePurposeDto>> response = ApiResUtils.createResponse(
                RemittancePurposeDto.create(Arrays.asList(RemittancePurposeCode.values())));

        given(commonApiService.getRemittancePurposeItems())
                .willReturn(response);

        mockMvc.perform(get("/api/api1")
                        .contextPath("/api")
                        .characterEncoding(StandardCharsets.UTF_8.name())
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andDo(print())
                .andDo(document("getRemittancePurposeItems", getDocumentResponse()));
    }

    @Test
    public void getNations() throws Exception {
        ApiResDto<List<NationDto>> response = ApiResUtils.createResponse(createNationDtos("K"));

        given(commonApiService.getNations(anyString()))
                .willReturn(response);

        mockMvc.perform(get("/api/api2")
                        .contextPath("/api")
                        .characterEncoding(StandardCharsets.UTF_8.name())
                        .contentType(MediaType.APPLICATION_JSON)
                        .param("keyword", "K"))
                .andExpect(status().isOk())
                .andDo(print())
                .andDo(document("getNations", getDocumentResponse(),
                        requestParameters(
                                parameterWithName("keyword").description("키워드")
                        ))
                );
    }
}

 

리팩토링이라 하기 너무 작은 범위지만 저 반복되는 부분을 리팩토링하기로 마음먹었습니다. 

 

메소드 추출 법을 이용한 리팩토링

위에서 반복되는 코드를 리팩토링 기법 중 하나인 메소드 추출 법을 사용하여 defaultCommonRequest라고 하는 공통 속성을 적용하는 메소드로 분리하였습니다.

 

public class MockHttpServletRequestBuilderUtils {

    private static final ApplicationContext context = new AnnotationConfigApplicationContext(BeanConfig.class);
    private static final String CONTEXT_PATH = "/api";
    private static final String CHARACTER_ENCODING = "UTF-8";
    private static final String URI_TEMPLATE = "/**";

    public static MockHttpServletRequestBuilder defaultCommonRequest() {
        return MockMvcRequestBuilders
                .get(URI_TEMPLATE)
                .contextPath(CONTEXT_PATH)
                .contentType(APPLICATION_JSON)
                .characterEncoding(CHARACTER_ENCODING);
    }
}

defaultCommonRequest() 메소드에서는 MockMvcRequestBuilders.get() 메소드를 사용하였는데 post(), put(), multipart() 등 아무거나 사용하셔도 무관합니다. 메소드 파라미터에 기본적으로 적용할 uri를 넣어주시면 MockHttpServletReqestBuilder 객체를 생성하게 됩니다. 이렇게 생성된 MockHttpServletReqestBuilder 객체를 가지고 contextPath(), contentType(), characterEncoding() 메소드를 사용해서 속성을 적용하게 되면 메소드 모두 get() 메소드에서 생성한 MockHttpServletReqestBuilder 객체 자기 자신(this)을 반환하게 됩니다.

 

get 메소드
characterEncoding, contentType, contextPath 메소드

 

분리한 메소드를 테스트 코드에 적용하기

@WebMvcTest(
        controllers = CommonApiControllerTest.class,
        excludeFilters = {
                @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebSecurityConfig.class),
                @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebMvcConfig.class)
        }
)
@ExtendWith(RestDocumentationExtension.class)
public class CommonApiControllerTest {
	

	// member fields ...
    
    @BeforeEach
    public void setUp(RestDocumentationContextProvider restDocumentation) {
        mockMvc = MockMvcBuilders
                .standaloneSetup(commonApiController)
                .apply(defaultCommonMvcConfigurer(restDocumentation))
                .defaultRequest(defaultCommonRequest())
                .build();
    }
    
    @Test
    public void getRemittancePurposeItems() throws Exception {
        ApiResDto<List<RemittancePurposeDto>> response = ApiResUtils.createResponse(
                RemittancePurposeDto.create(Arrays.asList(RemittancePurposeCode.values())));

        given(commonApiService.getRemittancePurposeItems())
                .willReturn(response);

        mockMvc.perform(get("/api/api1")
                        .contextPath("/api")
                        .characterEncoding(StandardCharsets.UTF_8.name())
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andDo(print())
                .andDo(document("getRemittancePurposeItems", getDocumentResponse()));
    }

}

 

이렇게 만들어진 defaultCommonRequest() 메소드는 테스트 코드가 실행되기 전 실행되는 @BeforeEach 어노테이션이 적용된 setUp() 메소드에defaultRequest() 메소드 안에 파라미터로 넣어줬습니다. defaultRequest() 메소드의 파라미터는 인터페이스인 ReuqestBuilder 타입을 넣어주게끔 되어있는데 제가 만든 defaultCommonRequest() 메소드의 반환 타입은 RueqestBuilder의 구현체인 MockHttpServletRequestBulider이기 때문에 defaultRequest() 메소드의 파라미터로 넣어줄 수 있게 됩니다. (밑의 클래스 다이어그램을 참고하시기 바랍니다.)

 

@BeforeEach 어노테이션이 적용된 메소드는 @Test 어노테이션이 적용된 메소드들이 실행되기 전 무조건 먼저 실행이 되게 됩니다.

간단하게 말씀드리면 테스트 진행 시 @BeforeEach -> @Test -> @AfterEach 순으로 테스트가 진행되게 됩니다.

예를 들어 @Test 어노테이션이 적용된 메소드인 test1(), teset2() 메소드가 있으면

@BeforeEach -> test1() -> @AfterEach, @BeforeEach -> test2() -> @AfterEach 이렇게 테스트 하나하나 실행될 때마다 테스트 코드가 실행되기 전과 후에 @BeforeEach과 @AfterEach 어노테이션이 붙은 메서드가 실행됩니다. (test1(), test2() 메소드 순으로 진행하는 것처럼 작성하였지만 테스트 코드의 실행 순서는 항상 동일하다고 보장할 수 없습니다.)

 

MockHttpServletRequestBulider의 클래스 다이어 그램

 

저는 여기서 한 가지 의문점이 들었습니다. "현재 적용되어 있는 공통 속성은 http method는 get, uri는 / 경로의 모든 하위 경로를 매핑, content type도 application/json으로 적용되어 있는데 이 부분이 테스트 코드 mockMvc.perform에 파라미터로 들어가는 post(), put(), multipart() 메소드로 그리고 contentType 부분이 덮어 씌어질까??" 라는 의문점이었습니다. 이 의문점을 가지고 테스트 코드를 실행시켜본 결과 신기하게도 공통으로 적용된 속성에 제가 바꾸려는 값들만 덮어씌어져있는 것을 확인할 수 있었습니다.

 

이렇게 덮어씌어지는 이유가 궁금해서 MockMvc의 perform() 메소드를 보았습니다.

밑의 사진은 perform() 메소드의 일부인데 메소드를 자세히 보시면 defaultRquestBulider가 null이 아닐 때 instanceof를 이용하여 들어온 파라미터가 Mergeable 타입인지 비교 후 Mergeable 타입이 맞으면 들어온 파라미터인 reuquestBulider와 defaultRequestBuilder를 merge 하는 코드를 볼 수 있습니다.

 

MockMvc의 perform 메소드 일부

 

그리고 위의 제가 작성한 코드를 보시면 defaultRequest() 메소드를 호출하면서 파라미터로 MockHttpServletRequestBuilder(RequestBuilder의 구현체) 객체를 반환하는 defaultCommonReqeust() 메소드를 파라미터로 넣어주었고 defaultRequest() 메소드는 전달받은 파라미터를 defaultRequestBuilder 필드에 넣어주게 됩니다.

 

AbstractMockMvcBuilder의 defaultRequest 메소드

 

또한 MockMvc의 메소드인 perform() 메소드는 RequestBulider의 구현체인 MockHttpServletRequestBuilder 객체를 반환하는 post(), put(), multipart() 메소드를 파라미터로 받게 되는데 MockHttpServletRequestBuilder는 Mergeable을 구현하고 있기 때문에 if문 안으로 들어가 최종적으로 데이터들이 merge가 되어 제가 원하는 결과인 공통 속성은 바뀌지 않고 http method와 content type만 바뀌는 결과를 볼 수 있었습니다. merge() 메소드는 내부 로직이 복잡하지는 않지만 길기 때문에 생략하겠습니다. 

 

이번에 테스트 코드를 작성하면서 공통 속성을 적용하려고 삽질을 많이 했지만 결과적으로는 반복되는 코드를 메소드로 분리하여 리팩토링할 수 있어서 개인적으로 공부가 많이 되는 시간이었습니다. 혹시나 이보다 더 좋은 방법이 있으면 댓글로 알려주시면 감사하겠습니다!!

728x90

'코비의 개발일지' 카테고리의 다른 글

Facade Pattern 적용기  (0) 2023.08.27
스프링 부트로 메일 발송 기능 구현하기  (1) 2023.06.24
필터에서 응답 데이터 가공  (1) 2023.04.15
Spring REST Doc 적용기  (0) 2023.04.02