Home [Spring] 스프링 부트 각종 테스트 방법 (2) - Controller
Post
Cancel

[Spring] 스프링 부트 각종 테스트 방법 (2) - Controller

1편 - service

2편 - controller

3편 - repository

Controller 테스트

스프링 웹 앱은 기본적으로 컨트롤러 없이도 비즈니스 로직이 동작하도록 설계되면 좋습니다. 그렇다고 컨트롤러를 만들지 말라는 뜻은 아닙니다. 컨트롤러는 그저 외부의 요청을 잘 받고 응답을 잘 주는 역할을 수행해야 한다는 뜻입니다.

그래서 컨트롤러에는 비즈니스 로직이 잘 포함되지 않습니다. 여기서 드는 의문이 있는데, 그러면 굳이 Controller를 테스트할 필요가 있을까요?

그럼에도 Controller를 테스트 해야하는 이유들이 있습니다.

  • 테스트를 작성해서 요청과 응답의 form을 보장해줄 수 있습니다. 그래서 리팩토링으로부터 안전하고, Spring Rest Docs도 사용할 수 있습니다. api 폼이 보장되어있으니, Spring Rest Docs를 통해 API를 사용하는 입장에서도 신뢰할 수 있습니다.
  • 비즈니스 로직이 없더라도 service로 데이터가 잘 들어가고, 잘 받는지 테스트해야합니다. 비록 Controller에는 비즈니스 로직이 거의 없지만, service나 repository에 데이터를 잘 전달하고 잘 받는지는 테스트를 해보아야합니다. 은근히 이 포인트에서 버그가 자주 발생합니다.
  • 통합 테스트로 전환하기 쉬워집니다. 종종 중요한 비즈니스 로직은 통합 테스트가 필요합니다. 통합 테스트는 Controller에서 mockmvc를 사용하는 방식으로 구현될텐데, Controller 테스트를 미리 만들어두었다면 쉽게 전환할 수 있습니다.

컨트롤러는 보통 Service에 종속적입니다. 그래서 Mock이 필요합니다. Java에서는 테스트할 때 Mock을 쉽게 할 수 있도록 Mockito라는 라이브러리를 사용합니다. Spring Boot에서는 아래와 같이 spring-boot-starter-test를 추가했다면 Mockito를 사용할 수 있습니다. 자세한 사용법은 이전 Service 테스트 글에 작성해두었으니 참고해주세요.

Controller 단위 테스트

통합 테스트는 이 글에서 다루지 않습니다. 단위 테스트를 통해 Controller를 테스트 하는 방법을 보여드릴게요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
@WebMvcTest(TimeTableController.class)
class TimeTableControllerTest extends AbstractAuthControllerTest {

    @MockBean
    private TimeTableService timeTableService;

    @MockBean
    private AuthenticationTokenProvider jwtProvider;

    @Autowired
    private MockMvc mvc;

    @Autowired
    private ObjectMapper objectMapper;


    private final LocalTime start = LocalTime.of(10, 0);
    private final LocalTime end = LocalTime.of(13, 0);
    private List<LectureDto> testLectures;


    @BeforeEach
    void setup() {
        testLectures = List.of(
                new LectureDto("name", "professor", "place", List.of(
                        new LectureTimeDto(start, end, DayOfWeek.MONDAY),
                        new LectureTimeDto(start, end, DayOfWeek.THURSDAY)
                ))
        );

        SecurityContextHolder.getContext()
                .setAuthentication(new JwtAuthentication(userId, UserRole.USER));
    }


    @Test
    @DisplayName("내 시간표 조회")
    void list() throws Exception {
        // given
        given(timeTableService.list(1L, "test")).willReturn(testLectures);

        // when & then
        mvc.perform(get("/timetable")
                        .param("name", "test"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$[0].name").value("name"))
                .andExpect(jsonPath("$[0].professor").value("professor"))
                .andExpect(jsonPath("$[0].place").value("place"))
                .andExpect(jsonPath("$[0].times[0].start").value("10:00:00"))
                .andExpect(jsonPath("$[0].times[0].end").value("13:00:00"))
                .andExpect(jsonPath("$[0].times[0].week").value(DayOfWeek.MONDAY.name()))
    }

    @Test
    @DisplayName("시간표 생성")
    void create() throws Exception {
        // given
        TimeTableRequestDto dto = new TimeTableRequestDto("test", testLectures);
        given(timeTableService.create(any(), any())).willReturn(3L);

        // when
        mvc.perform(post("/timetable")
                        .with(csrf())
                        .content(objectMapper.writeValueAsBytes(dto))
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(3L));
    }
}

위는 예시 코드입니다. 기본적으로 테스트는 @WebMvcTest를 사용해서 테스트합니다. 이 annotation은 MVC와 관련된 빈들만 로드해서 @SpringBootTest에 비해서 가볍고 빠릅니다. 주의할 점은 MVC 관련 빈들만 불러오므로 JPA나 Service들은 사용할 수 없습니다. 이런 것들은 Mocking해서 사용해야합니다.

@WebMvcTest(TimeTableController.class)에서 TimeTableController.class를 annotation에 명시해주었습니다. 이렇게 직접 명시하지 않아도 테스트는 정상동작하지만, 그러면 모든 controller를 빈에 등록하므로 가볍게 테스트하려면 직접 명시해주시는 편이 좋겠죠?

Service Mocking

서비스는 Mocking해서 사용해야하므로 @MockBean를 사용해서 필드를 선언해주었습니다. 이 annotation은 mocking 객체를 만들고, 그 객체를 빈에도 등록시켜줍니다. 그래서 의존성 주입할 때 자동으로 이 빈이 사용됩니다. 저희가 사용할 Controller가 빈에 등록될 때 service가 필요한데, 그 service를 빈에 등록된 mocking 객체로 대체해서 사용합니다.

물론 @Autowired를 사용할 때도 @MockBean로 등록된 객체가 주입됩니다. 같은 타입을 여러 개를 사용할 경우에는 빈과 마찬가지로 @Qualifier를 사용할 수도 있습니다.

ObjectMapper

ObjectMapper는 Post 요청을 보낼 때 Request Body를 넣어주기 위해 사용됩니다. Post뿐만 아니라 Patch, Delete 등에서도 body를 넣을 때 사용될 수 있습니다. 필요하다면 받은 응답을 Object로 변환할 때도 사용할 수 있습니다.

여기서는 Autowired를 통해 주입받아서 사용하고 있습니다. 직접 만들지 않고 주입받아서 사용하는 이유는 ObjectMapper가 앱에서 커스터마이징될 수도 있기 때문입니다.

예를 들어 앱 전반에서 사용하기 위해 빈에 “yyyy-MM-dd HH:mm:ss” 포맷인 LocalDateTime을 serialize, deserialize하도록 하는 objectMapper를 등록해두었다고 해봅시다. 이 상황에서 테스트할 때 objectMapper를 직접 만들어 사용하면 그런 serializer/deserializer들을 사용하지 않으니 테스트에 문제가 생길 수 있습니다.

BeforeEach

@BeforeEach는 각각 테스트가 실행되기 직전에 실행될 함수를 지정합니다. 다른 테스트에서 공통으로 사용되는 부분을 @BeforeEach에 넣어두었습니다.

전역 변수들도 직접 초기화하기 보다는 @BeforeEach에 넣어두는 편이 좋습니다. 테스트의 독립성을 보장하기 위해서입니다. 만약 최초 1회만 초기화되고 모든 테스트에서 같은 객체를 공유해서 사용하면 테스트의 순서에 따라 결과가 달라질 수도 있습니다.

그런데 위에서는 LocalTime을 직접 초기화했죠? 이래도 괜찮은 이유는 LocalTime이 불변 객체이기 때문입니다. 애초에 LocalTime은 불변 객체이므로 내부 상태가 변할 일이 없습니다.

perform

perform을 통해 가상으로 요청을 보내보고 결과를 검증합니다. get().param() 이런건 직관적이어서 쉽게 이해할 수 있을 겁니다.

jsonPath는 요청 결과를 검증할 때 많이 사용됩니다. 응답값이 json형태일 때 사용할 수 있어요. 이 jsonPath 문법에 관한 내용은 Github에 잘 설명되어있으니 참고해보시면 좋을 것 같습니다.

andExpect를 통해 모든 인자들을 꼼꼼하게 테스트해야 나중에 버그를 쉽게 발견할 수 있습니다.

Get외 perform

Get은 쉽게 테스트했지만, 그 외의 http method들은 보통 인자로 json body를 받곤 합니다. 그래서 objectMapper를 사용해야합니다.

직접 json을 text로 만들어서 하드코딩해도 동작하지만, 길어지면 나중에 알아보기 힘들고 수정하기도 번거롭습니다. 그래서 objectMapper를 통해 Dto -> json 텍스트를 만들어 사용하시길 추천드립니다.

특이하게 post에는 .with(csrf())이 붙어있습니다. Spring Security를 사용하신다면 기본적으로 csrf 체크를 합니다. 그래서 테스트할 때 Get요청외 다른 요청은 403이 뜹니다.

이를 해결하기 위해서 테스트 Config를 따로 만들어서 csrf를 끄거나, csrf token을 넣어주는 csrf() 를 요청시 붙여야합니다. 그래야 403없이 정상적인 테스트를 할 수 있습니다.

SpringSecurity Mocking

Spring Security를 통한 유저 인증이 포함된 경우에는 SecurityContextHolder.getContext().setAuthentication()를 통해 Authentication도 mock해야합니다. 그리고 TokenProvider도 빈에 등록되지 않으므로 모킹해서 사용해야합니다.

1
2
3
4
5
6
7
8
9
@MockBean
private AuthenticationTokenProvider jwtProvider;

@BeforeEach
void setup() {
    // JwtAuthentication는 제 커스텀 Authentication입니다. 이건 여러분들만의 Authentication으로 바꿔서 사용해주세요.
    SecurityContextHolder.getContext()
            .setAuthentication(new JwtAuthentication(userId, UserRole.USER));
}

물론 위처럼 할 필요 없이 @WithMockUser를 사용할 수도 있습니다. 저는 커스텀 Authentication을 만들어 사용했고, userId도 지정해줘야 하는 부분이 있어서 위와 같이 사용했습니다. @WithMockUser를 사용하려면 UsernamePasswordAuthenticationToken를 사용해야합니다.

This post is licensed under CC BY 4.0 by the author.

[Spring] 스프링 부트 각종 테스트 방법 (1) - Service

[Spring] 시간표 기능 ERD 구상하기