계층 별로 테스트를 진행할 때, Repository나 Service는 @DataJpaTest나 @SpringBootTest 어노테이션의 경우엔 실제 Bean 객체를 사용하는 것이기 때문에 별 문제없이 테스트를 진행할 수 있다.
하지만 Controller는 조금 다르다. Controller는 사용자의 요청을 전달받는 계층이기 때문에 말 그대로, 데이터를 잘 받을 수 있는지를 테스트하는 영역이다.
그렇다면 다른 계층과 Controller의 테스트 방식이 어떻게 다른지, 어떤 기술을 사용하는지 한번 알아보자.
@WebMvcTest
@WebMvcTest는 Controller를 테스트할 때 가장 많이 사용하는 어노테이션이다.
아래는 Spring Framework 공식 홈페이지에 나와있는 @WebMvcTest에 대한 설명이다.
@WebMvcTest은 Spring Boot에서 제공해주는 어노테이션으로 Spring MVC에서 여러 Controller들의 단위 테스트를 위해 사용된다.
WebMvcTest 를 사용한다면 해당 클래스에서 웹 계층에 발생하는 요청과 응답을 테스트할 수 있다.
이 설명에서 웹 계층에 주목해야한다. 말 그대로 웹 계층에서만, 이 말은 곧 웹과 관련된 의존성만 가져오겠다는 것이다.
그럼 의존성을 가져온다는 뜻은 @Autowired같은 어노테이션으로 DI(의존성 주입)를 할 수 있다는 뜻인데, 왜 Controller 계층에서 사용되는 Service나 Repository에선 빈을 찾을 수 없다는 예외가 발생할까?
그 이유는 공식문서에 똑 뿌러지게 안된다고 명시되어 있다.
이 주석을 사용하면 전체 자동 구성이 비활성화되고 대신 MVC 테스트와 관련된 구성만 적용됩니다.
(예: @Controller, @ControllerAdvice, @JsonComponent, Converter/GenericConverter, Filter, WebMvcConfigurer 및 HandlerMethodArgumentResolver Bean은 적용되지만 @Component, @Service 또는 @Repository Bean은 적용되지 않음))
그러면 Controller에 요청을 보내면 거기서 사용하는 Service는..? Controller에선 대부분 Service를 의존하고 있는데..?
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/member")
public class MemberController {
private final MemberService memberService;
...
}
그래서 Mock 객체를 사용하는 것이다.
결국 Mock 객체란 메모리에 상주해있지 않는, Spring 컨테이너 내부에 존재하지 않는 Bean 객체이지만 마치 존재하는 것 처럼 연기를 하는 객체인 것이다.
Mock 사용 방법은 @MockitoBean 어노테이션을 붙이면 된다.
@WebMvcTest(controllers = BoardController.class)
class BoardControllerTest {
@MockitoBean
BoardService boardService;
...
}
위의 코드는 BoardService를 Mock 객체로 사용하는 것이다.
다음은 저 Mock 객체가 어떤 동작을 할 지 선언해줘야 한다.
@WebMvcTest(controllers = BoardController.class)
class BoardControllerTest {
@MockitoBean
BoardService boardService;
...
@Test
@DisplayName("게시글을 작성한다.")
void writeBoard() throws Exception {
// given
when(boardService.writeBoard(any())).thenReturn(1L);
...
}
...
}
when(boardService.writeBoard(any())).thenReturn(1L);
위의 코드는 writeBoard(any()) 메서드를 어떤 값을 리턴할 지 임의로 선언한 것이다. 이 것을 스터빙(Stubbing) 이라고 한다.
실제 BoardService의 writeBoard() 메서드는 아래와 같다.
@Override
@Transactional
public Long writeBoard(BoardWriteServiceRequest request) {
Member member = memberRepository.findMemberByEmail(request.getEmail())
.orElseThrow(() -> new RestApiException(USER_NOT_FOUND));
return boardRepository.save(request.toBoard(member)).getId();
}
참고로 any()는 주로 Dto 객체를 파라미터로 전달받을 때 사용한다. 물론 다른 타입을 받는 방법도 있다.
이정도면 대략적인 Controller 계층의 테스트 코드 설명은 끝난 것 같고...
다음은 본격적으로 이 블로그 글을 작성하게 된 계기이자, 문제를 해결하는 과정이다.
Problem
위의 로그를 보면 난 200 코드가 리턴될 것을 예상했지만 401이 응답되었다고 한다.
401이란 Unauthorized 코드로 인증되지 않은 사용자를 의미힌다.
보통 Spring Security가 적용된 프로젝트에서 이런 예외가 자주 발생한다. 물론 내 코드에도 Spring Security가 적용되어 있다.
한번 해결해 봅시다..!
Solution1
일단 왜 이러한 문제가 발생했을까??
바로 Spring Security 때문이다.
그럼 Spring Security의 필터가 401 예외를 던지는 이유는 뭘까?
사용자의 인증・인가에 실패한 것이다.
그래서 Spring Framwork에선 감사하게도 인증・인가용 객체를 따로 생성할 수 있는 기능을 제공해준다.
@WithMockUser
@WithMockUser는 Spring Security를 사용중인 프로젝트의 Controller를 테스트할 때, 사용자 정보를 자동으로 설정하고, 해당 정보를 바탕으로 테스트할 수 있도록 해준다.
문제는 "특정 사용자로서 어떻게 가장 쉽게 테스트를 실행할 수 있는가?"입니다.대답은 @WithMockUser를 사용하는 것입니다.
다음 테스트는 사용자 이름이 "user", 비밀번호가 "password", 역할이 "ROLE_USER"인 사용자로 실행됩니다.
사용자 객체를 모의하기 때문에 user라는 사용자 이름을 가진 사용자가 존재할 필요는 없습니다.
SecurityContext에 채워지는 인증은 UsernamePasswordAuthenticationToken 유형입니다.
인증의 주체는 Spring Security의 User 개체입니다.
사용자의 사용자 이름은 user입니다.
사용자는 비밀번호의 비밀번호를 가지고 있습니다.
ROLE_USER라는 단일 GrantedAuthority가 사용됩니다.
위의 글은 공식문서에 나와있는 내용이다.
위의 글에서 내가 생각하는 가장 중요한 내용은 아래 문장이라고 생각한다.
SecurityContext 에 채워지는 인증은 UsernamePasswordAuthenticationToken 유형입니다.
이 문장을 쉽게 설명하면 내가 @WithMockUser 어노테이션을 테스트코드 메서드에 다는 순간 SecurityContext 에 유저 정보가 채워진다는 뜻이다.
물론 익명 사용자로 테스트하기 위한 @WithAnonymousUser, 직접 테스트 코드 내부에서 유저의 정보를 설정하기 위한 @WithUserDetails 도 사용 가능하다.
아래의 코드는 @WithMockUser를 적용한 코드이다.
@Slf4j
@ActiveProfiles("test")
@WithMockUser("testUser")
@WebMvcTest(controllers = BoardController.class)
class BoardControllerTest {
@MockitoBean
BoardService boardService;
@Autowired
MockMvc mockMvc;
@Autowired
ObjectMapper objectMapper;
@Test
@DisplayName("게시글을 작성한다.")
void writeBoard() throws Exception {
// given
when(boardService.writeBoard(any())).thenReturn(1L);
BoardWriteControllerRequest request = BoardWriteControllerRequest.builder()
.title("게시글 제목")
.content("게시글 내용")
.build();
// when & then
mockMvc.perform(
post("/api/v1/board/write")
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON).with(csrf()))
.andDo(print())
.andExpect(status().isOk());
}
...
}
테스트가 정상적으로 통과되었다.
'Spring' 카테고리의 다른 글
[Spring] Spring 프로젝트 SonarQube 연동 (0) | 2025.01.09 |
---|---|
[Spring] Spring Batch를 사용한 대량 데이터 저장 (2) (0) | 2024.12.22 |
[Spring] Spring Batch를 사용한 대량 데이터 저장 (1) (3) | 2024.12.21 |
[Spring] Spring Boot + Web Socket(STOMP) (2) (1) | 2024.09.26 |
[Spring] Spring Boot + Web Socket(STOMP) (1) (0) | 2024.09.26 |