[Java] 단위 테스트 작성하기 - Controller

Erin Lee·2024년 11월 5일
0

Project

목록 보기
7/7

문제 발생

프로젝트를 하면서 단위 테스트를 짜야지 하고 짰던 코드가 단위 테스트가 아니였다는 것을 알았다.
단순히 Controller, Service, Repository 단위로 짜면 되는게 아닌가라고 생각했던 나..

피드백 해주셨던 분이 단위 테스트도 있으면 좋겠다는 말에 내가 짠 게 단위 테스트가 아닌 것을 깨닫고 알아봤다.


내가 처음에 짠 코드 😰

@WebMvcTest(BoardController.class)
@MockBean(JpaMetamodelMappingContext.class)
public class BoardControllerTest {

    @MockBean
    BoardService boardService;

    @Autowired
    MockMvc mockMvc;

    @Autowired
    ObjectMapper objectMapper;

    BoardRequestDto boardRequestDto;
    BoardResponseDto boardResponseDto;

    @Test
    public void createBoard_Success() throws Exception {
        boardRequestDto = BoardRequestDto.builder()
                .title("test board title")
                .content("test board content")
                .build();
        boardResponseDto = new BoardResponseDto(1L, boardRequestDto.getTitle()
                , boardRequestDto.getContent(), LocalDateTime.now());

        given(boardService.createBoard(any(BoardRequestDto.class))).willReturn(boardResponseDto);

        mockMvc.perform(post("/board")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(boardRequestDto)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.status").value("success"))
                .andExpect(jsonPath("$.data.id").value(boardResponseDto.getId()))
                .andExpect(jsonPath("$.data.title").value(boardResponseDto.getTitle()))
                .andExpect(jsonPath("$.data.content").value(boardResponseDto.getContent()))
                .andDo(print());
    }  

위는 기존에 짰던 Controller의 게시물 생성 기능을 테스트하는 함수이다.

필요한 RequestDto 객체를 만들어 Service 함수에 대한 설정을 해주고 MockMvc를 사용하여 어떻게 응답 메세지로 전달이 되는지까지 적혀있다.

사용된 어노테이션을 간단하게 정리해보자면,,

  • @WebMvcTest
    어노테이션은 스프링의 MVC 계층을 테스트하기위해 컨트롤러와 관련된 빈만 로드하는 역할을 한다. 그래서 BoardController와 BoardService 간의 연결을 해준다.
  • MockMvc
    Spring MVC 애플리케이션의 웹 계층을 테스트하기 위해 제공되는 모의 객체로, 실제 HTTP 서버를 실행하지 않고도 요청을 모의로 보내고 응답을 검증할 수 있게 해준다.
  • @MockBean
    스프링 컨텍스트에 Mock 객체를 주입하여 실제 어플리케이션에서의 의존성 주입과 동일하게 Mock 객체를 다른 스프링 빈의 의존성으로 설정해준다.

-> 스프링 컨텍스트를 로드하여 실행되면서 스프링의 여러 빈 관리와 MVC 설정이 포함된 것을 확인할 수 있다.
그래서 단위 테스트 보다는 스프링 MVC 계층의 통합 테스트에 더 가까운 코드라고 할 수 있다.

그럼 이제 내가 기존에 짰던 코드를 완전 독립적으로 테스트할 수 있는 코드로 만들어보자.


내가 원하는 단위 테스트 코드는?

내가 원하는 단위 테스트는 개별 클래스나 메서드를 외부 의존성 없이 고립시켜 테스트하는 것이다.
Controller, Service, Repository 서로 의존하는 것이 아닌 독립적으로 각자의 클래스가 가진 메서드들이 정상적으로 작동하는지 확인할 수 있는 테스트 코드.

그러려면..


1. 의존 관계가 연결된 빈이 아닌 모의 객체 갖고 오기

컨트롤러 테스트에는 BoardController와 BoardService가 필요하다.
이때 사용될 수 있는 것으로는 객체를 모킹하는 @Mock과 @InjectMocks가 있다.

@Mock
특정 클래스의 가짜(Mock) 객체를 생성한다.
해당 클래스의 실제 인스턴스를 생성하는 것이 아닌 메서드 호출 시 원하는 동작을 설정할 수 있을 정도의 가짜 객체를 만들어준다.

@InjectMocks
실제 객체를 생성하고 @Mock 객체들을 주입하여 사용할 수 있게 해준다.
테스트하려는 대상의 객체에 필요한 의존성을 자동으로 주입하여 객체를 초기화해준다.

그렇다면 테스트 대상인 BoardController 클래스에는 @InjectMocks 어노테이션을 달아주고 BoardService 객체에는 @Mock 어노테이션을 달아주어 테스트 시 사용하면 된다.

@Mock
BoardService boardService;

@InjectMocks
BoardController boardController;

2. HTTP 요청 관련 테스트가 아닌 컨트롤러 메서드 자체에 집중하기

테스트하려고 하는 BoardController 클래스의 createBoard 함수는 아래와 같다.

  @PostMapping
    public ResponseDto<BoardResponseDto> createBoard(@Valid @RequestBody BoardRequestDto boardRequestDto) {
        BoardResponseDto boardResponseDto = boardService.createBoard(boardRequestDto);
        return ResponseDto.success(boardResponseDto);
    }

그렇다면 컨트롤러의 createBoard 메서드는 BoardRequestDto 객체를 받고 BoardService의 createBoard 함수에 넘겨주고 받은 BoardResponseDto를 잘 받아서 넘겨주면 된다.

이에 필요한 것은 BoardRquestDto 객체와 가짜로라도 사용될 BoardService의 createBoard 함수일 것이다.

그래서 아래와 같이 BoardControllerTest 클래스에 추가해 주었다.

BoardRequestDto boardRequestDto;
BoardResponseDto boardResponseDto;

@BeforeEach
public void setUp() {
    boardResponseDto = new BoardResponseDto(1L, "test title", "test content", LocalDateTime.now());
}
  • BoardRequestDto 는 함수별로 다르게 값이 들어가서 선언만 해주었다.
  • BoardResponseDto 는 지정된 값만 갖고 있을 것이기 때문에 @BeforeEach를 통해서 미리 만들어주었다.


이제 createBoard 메서드를 테스트하는 함수를 짜주자.

@Test
public void createBoard_Success() {
  /*given*/
  boardRequestDto = new BoardRequestDto("test title", "test content");
  when(boardService.createBoard(any(BoardRequestDto.class))).thenReturn(boardResponseDto);
  /*when*/
  ResponseDto<BoardResponseDto> boardResponse = boardController.createBoard(boardRequestDto);
  /*then*/
  assertEquals("success", boardResponse.getStatus());
  assertEquals(boardRequestDto.getTitle(), boardResponse.getData().getTitle());
  assertEquals(boardRequestDto.getContent(), boardResponse.getData().getContent());
}  
  • 성공 메서드이기 때문에 BoardRequestDto에는 정상적인 값을 갖고 있어야한다.
  • createBoard 함수 내에는 BoardService 클래스의 createBoard 메서드가 호출되기 때문에 호출될 때 정상적인 boardResponseDto 값을 리턴하도록 상황을 지정해준다.
  • 테스트 케이스인 BoardController 클래스의 createBoard 함수에 정상적인 boardRequestDto 값을 넣어준다.
  • 정상적인 boardRequestDto 가 들어왔을 때 BoardController 클래스의 createBoard가 정상적인 값을 반환하는지 확인한다.


3. 모의 객체를 사용하려면?

BoardController 클래스의 createBoard 함수를 테스트하는 함수는 다 짜졌다.
근데 앞에서 우리가 @Mock과 @InjectMocks를 통해서 모의 객체를 사용한다고 하였다.
그러면 이 객체는 어떻게 초기화가 되어 사용될 수 있는 것일까?

그 역할을 해주는 것이 @ExtendWith(MockitoExtension.class) 이다.

@ExtendWith(MockitoExtension.class)

@ExtendWith
Junit5 부터 제공되는 어노테이션으로 Junit4에서 사용되던 @RunWith의 업그레이드 버전이다.
기존 @RunWith는 하나의 Runner만 사용이 가능하여 복잡한 테스트 시나리오를 적용시에 어려움이 있었다. 하지만 Junit5에 @ExtendWith 어노테이션이 나오게 되면서 여러 Extension들을 지정이 가능하게 되면서 유연하고 다양한 기능을 확장할 수 있게 되었다.

내가 개발한 프로젝트는 Junit5이기 때문에 @ExtendWith를 사용할 것이고 그중 Mockito와 관련된 기능을 확장하여 테스트하기 위해선 MockitoExtension.class를 넣어주면 된다.

그래서 다시 어떻게 초기화가 되어서 사용되는 것이냐면,
✔️ @ExtendWith(MockitoExtension.class)를 사용하면 @Mock, @InjectMocks 등과 같이 Mockito 어노테이션이 설정된 필드가 자동으로 초기화된다.

✔️ Mockito의 테스트 생명 주기도 관리해준다.
테스트 실행 후 사용되지 않는 스터빙을 자동으로 검출하고 InnecessaryStubbingException을 발생시켜 불필요한 스터빙을 제거하도록 유도한다.

그래서 BoardControllerTest 클래스에 @ExtendWith(MockitoExtension.class)를 달아줬다.

@ExtendWith(MockitoExtension.class)
public class BoardControllerTest { 

❓Junit4에서는 어떻게 Mockito 관련 객체들을 초기화시켜줬을까?

Junit4의 @RunWith에는 MockitoExtension.class를 사용해 확장할 수 없다.
그래서 MockitoAnnotations.openMocks(this) 함수를 사용해줬다.

MockitoAnnotations.openMocks(this); 함수는 Mockito 어노테이션 기반의 Mock 객체들을 초기화해주는 메서드이다.
테스트가 실행됨과 동시에 초기회되어야하니 @BeforeEach 함수에 넣어주면 된다.

@BefordEach
public void setUp() {
  MockitoAnnotations.openMocks(this);
}  

이제 BoardController 테스트 코드는 다 짜졌다.

BoardControllerTest 총 코드

@ExtendWith(MockitoExtension.class)
public class BoardControllerTest {
    @Mock
    BoardService boardService;

    @InjectMocks
    BoardController boardController;

    BoardRequestDto boardRequestDto;
    BoardResponseDto boardResponseDto;

    @BeforeEach
    public void setUp() {
        boardResponseDto = new BoardResponseDto(1L, "test title", "test content", LocalDateTime.now());
    }

    @Test
    public void createBoard_Success() {
        boardRequestDto = new BoardRequestDto("test title", "test content");

        when(boardService.createBoard(any(BoardRequestDto.class))).thenReturn(boardResponseDto);

        ResponseDto<BoardResponseDto> boardResponse = boardController.createBoard(boardRequestDto);

        assertEquals("success", boardResponse.getStatus());
        assertEquals(boardRequestDto.getTitle(), boardResponse.getData().getTitle());
        assertEquals(boardRequestDto.getContent(), boardResponse.getData().getContent());
   }
}


마치며

테스트 코드를 제대로 짜본 적이 없어 이번 기회에 하나씩 훑어보는 시간을 가졌는데
정말 다양한 테스트 케이스들이 많다보니 아직 지금 짠 코드도 정확하게 짠 것인지는 확신이 서지 않는다.
그래도 이번 기회에 내가 테스트 코드에서 반영하고자 하는 방향대로 짜려고 했던 경험과 공부해가며 얻은 내용들이 정말 많았고 그것만으로도 좋은 경험이 될 것이다.

profile
내가 설명할 수 있어야 비로소 내가 아는 것이다

0개의 댓글