앞서 게시글에서 JUnit과 Mockito를 활용하기 위한 이론을 공부했다면 이제 실제 코드에 적용시켜 볼 시간이다.
우선 Controller와 Service 두 계층에 대해서 단위테스트를 진행할 예정이다.
#1. Dependency 설정
사용할 JUnit과 AssertJ, Mockito 모두 spring-boot-starter-test에 포함되어 있으므로 혹시 build.gradle에 해당 항목이 없다면 추가해주면 된다.
dependencies {
...
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
#2. 컨트롤러 단위 테스트
@WebMvcTest
public class BoardControllerTest {
@MockBean
private BoardService boardService;
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
@DisplayName("GET 게시글 리스트 조회 로직 확인")
public void GETBoardListTest() throws Exception {
// given
List<BoardDto> boardList = Arrays
.asList(new BoardDto(1L, "title", "writer", "content", LocalDateTime.now(), LocalDateTime.now()));
Header<List<BoardDto>> header = Header.OK(boardList);
when(boardService.getBoardList(anyInt(), anyInt())).thenReturn(header);
// when
mockMvc.perform(get("/board/list").param("page", "1").param("size", "1"))
// then
.andExpect(status().isOk())
.andExpect(jsonPath("$.data[0].title").value("title"));
}
@Test
@DisplayName("GET 게시글 조회 테스트")
public void GETBoardDetailTest() throws Exception {
// given
BoardDto boardDto = new BoardDto(1L, "title", "writer", "content", LocalDateTime.now(), LocalDateTime.now());
Header<BoardDto> header = Header.OK(boardDto);
when(boardService.getPost(anyLong())).thenReturn(header);
// when
mockMvc.perform(get("/board/post/1"))
// then
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.title").value("title"))
.andExpect(jsonPath("$.data.writer").value("writer"))
.andExpect(jsonPath("$.data.content").value("content"));
}
@Test
@DisplayName("POST 게시글 작성 테스트")
public void POSTBoardWriteTest() throws Exception {
// given
BoardDto boardDto = new BoardDto(null, "title", "writer", "content", LocalDateTime.now(), LocalDateTime.now());
Header<String> response = Header.OK("게시글이 성공적으로 작성되었습니다.");
when(boardService.savePost(Mockito.any(BoardDto.class))).thenReturn(response);
String boardDtoJson = objectMapper.writeValueAsString(boardDto);
// when
mockMvc.perform(post("/board/post")
.contentType(MediaType.APPLICATION_JSON)
.content(boardDtoJson))
// then
.andExpect(status().isOk())
.andExpect(jsonPath("$.data").value("게시글이 성공적으로 작성되었습니다."));
}
@Test
@DisplayName("PUT 게시글 수정 테스트")
public void PUTBoardUpdateTest() throws Exception {
// given
BoardDto boardDto = new BoardDto(1L, "newTitle", "writer", "newContent", LocalDateTime.now(), LocalDateTime.now());
Header<String> response = Header.OK("게시글이 성공적으로 작성되었습니다.");
when(boardService.savePost(Mockito.any(BoardDto.class))).thenReturn(response);
String boardDtoJson = objectMapper.writeValueAsString(boardDto);
// when
mockMvc.perform(put("/board/post/edit/1")
.contentType(MediaType.APPLICATION_JSON)
.content(boardDtoJson))
// then
.andExpect(status().isOk())
.andExpect(jsonPath("$.data").value("게시글이 성공적으로 작성되었습니다."));
}
@Test
@DisplayName("DELETE 게시글 삭제 테스트")
public void testDelete() throws Exception {
// given
Header<String> response = Header.OK("게시글이 성공적으로 삭제되었습니다.");
when(boardService.deletePost(anyLong())).thenReturn(response);
// when
mockMvc.perform(delete("/board/post/1"))
// then
.andExpect(status().isOk())
.andExpect(jsonPath("$.data").value("게시글이 성공적으로 삭제되었습니다."));
}
@Test
@DisplayName("게시글 검색 테스트")
public void testSearch() throws Exception {
// given
List<BoardDto> searchResults = Arrays.asList(new BoardDto(1L, "searchTitle", "searchWriter", "searchContent", LocalDateTime.now(), LocalDateTime.now()));
Header<List<BoardDto>> header = Header.OK(searchResults);
when(boardService.searchPosts(Mockito.any(Search.class))).thenReturn(header);
// when
mockMvc.perform(get("/board/search")
.param("type", "title")
.param("keyword", "search")
.param("page", "1")
.param("size", "10"))
// then
.andExpect(status().isOk())
.andExpect(jsonPath("$.data[0].title").value("searchTitle"))
.andExpect(jsonPath("$.data[0].writer").value("searchWriter"))
.andExpect(jsonPath("$.data[0].content").value("searchContent"));
}
}
@RestController
@AllArgsConstructor
@RequestMapping("/board")
public class BoardController {
private final BoardService boardService;
컨트롤러의 단위 테스를 위해서는 Mockito를 이용하여 다른 계층과의 의존관계를 단절시켜 주어야하는데 현재 컨트롤러가 의존하고 있는 객체는 BoardService 객체다.
따라서 이를 본딴 Mock 객체를 만들어서 BoardService 객체에 의존하지 않도록 해야한다.
@WebMvcTest
public class BoardControllerTest {
@MockBean
private BoardService boardService;
이렇게 간단하게 BoardService 클래스를 @MockBean 어노테이션을 달아주면 Mock 객체를 생성하고 필드 주입해줄 수 있는데 @MockBean 외에도 @Mock 어노테이션으로도 Mock 객체를 만들어 줄 수 있다.
그렇다면 @MockBean과 @Mock에는 어떤 차이가 있는걸까? 추가로 @InjectMocks까지 같이 살펴보려 한다.
@Mock와 @MockBean은 Mock 객체를 생성한다는 점에서는 같지만 @Mock는 실제 객체를 모방한 텅빈 가짜 객체를 생성하는 것으로 빈으로 관리되지 않고 따라서 Spring Context와도 무관하게 Mockito 단위테스트에서 사용된다.
그에 반해 @MockBean은 Spring Context 내에서 모킹된 빈을 주입하는 방식으로 컨트롤러를 테스트하기 위한 어노테이션인 @WebMvcTest을 사용할 경우 Spring이 컨트롤러와 관련된 컴포넌트만 로드하고 나머지 빈들은 로드하지 않지만 @MockBean을 사용하면 Spring Context에서 BoardService와 같은 실제 서비스 빈을 모킹된 객체로 대체해주므로 해당 빈을 테스트에서 사용할 수 있게 된다.
즉 컨트롤러 테스트에서 @MockBean을 사용하는 이유는 Spring MVC Context가 필요하기 때문이다. 컨트롤러는 일반적으로 HTTP 요청을 보내고 그 응답을 검증하는 계층이고 그것을 테스트하기 위해서는 실제로 컨트롤러를 호출하고 Spring의 요청-응답 흐름을 재현해야 하는데 이 때 서비스의 동작을 모킹하여 컨트롤러의 요청처리만을 테스트할 수 있다
만약 @Mock을 사용한다면 Spring의 요청-응답 흐름을 다루지 못하고, 순수하게 서비스나 로직을 테스트하는 용도로만 사용할 수 있기 때문에 컨트롤러에서는 사용이 부적합한 것이다.
추가로 @InjectMocks는 단순히 Mock 객체를 '생성'하는 @Mock과 다르게 대상 객체에 Mock 객체를 자동으로 주입해준다. 이때 주입되는 객체는 @Mock으로 생성된 모의 객체들이며, 보통 테스트하고자 하는 클래스가 내부 메서드를 호출하고 있는 경우 자기 자신을 @InjectMocks로 Mock 객체화 하고 생성된 다른 Mock 객체들을 자동으로 주입받아 테스트에서 사용하는 데 쓰인다.
다시 돌아와서 코드를 살펴보면
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
이런 생소한 클래스들이 우리를 반기는데 MockMvc는 Spring MVC의 동작을 가상으로 테스트할 때 사용하는 도구로 실제 서버를 띄우지 않고도 HTTP 요청을 보내고 그에 대한 응답을 확인 할 수 있다. When 단계에서 MockMvc의 perform() 메서드에 HTTP 요청을 매개변수로 해서 호출하며 이를 통해 요청-응답 흐름을 테스트할 수 있게 되는 것이다.
ObjectMapper는 JSON데이터를 객체로 변환하거나 반대로 객체를 JSON 문자열로 변환하는 데 사용한다. Jackson 라이브러리를 기반으로 하며 RESTController에서는 JSON 형태로 요청-응답을 다루기 때문에 테스트 중에도 자바객체를 JSON으로 직렬화하거나 JSON을 자바 객체로 역직렬화 할 필요가 있는데 그 작업을 담당하는 클래스다.
이제 테스트를 위한 사전 작업이 끝났으니 각각의 메서드를 테스트만 하면 된다.
@Test
@DisplayName("GET 게시글 리스트 조회 로직 확인")
public void GETBoardListTest() throws Exception {
// given
List<BoardDto> boardList = Arrays
.asList(new BoardDto(1L, "title", "writer", "content", LocalDateTime.now(), LocalDateTime.now()));
Header<List<BoardDto>> header = Header.OK(boardList);
when(boardService.getBoardList(anyInt(), anyInt())).thenReturn(header);
// when
mockMvc.perform(get("/board/list").param("page", "1").param("size", "1"))
// then
.andExpect(status().isOk())
.andExpect(jsonPath("$.data[0].title").value("title"));
}
각각의 테스트는 given, when, then 단계로 이루어져 있고 given은 앞선 게시글에서 설명했듯 테스트 대상에게 상태를 만들어주는 단계이다. BoardList와 Header에 값을 설정해주고 when 메서드에서 의존관계에 있는 객체의 메서드(boardService.getBoardList())에 어떠한 파라미터가 삽입되더라도 항상 지정된 값(header)가 반환되게 설정을 해줌으로써 컨트롤러가 의존하고 있는 객체(BoardService)에 대한 의존성을 단절시켰다.
When 단계에서는 앞서 설명한 MockMvc를 이용해 HTTP 요청을 모킹하고
Then 단계에서 제대로 응답이 도착했고 안의 데이터가 기대한 값과 같은지 확인하는 것으로 메서드 테스트가 마무리 된다.
#3. 서비스 계층 단위 테스트
@ExtendWith(MockitoExtension.class)
public class BoardServiceTest {
@Mock
private BoardRepository boardRepository;
@InjectMocks
private BoardService boardService;
@Test
@DisplayName("게시글 리스트 조회 테스트")
void getBoardListTest() {
// given
List<Board> boardList = Arrays.asList(
new Board(1L, "title", "writer", "content"),
new Board(2L, "title2", "writer2", "content2")
);
Page<Board> page = new PageImpl<>(boardList);
when(boardRepository.findAll(Mockito.any(PageRequest.class))).thenReturn(page);
when(boardRepository.count()).thenReturn(2L);
// when
Header<List<BoardDto>> result = boardService.getBoardList(1, 2);
// then
assertEquals(2, result.getData().size());
assertEquals("title", result.getData().get(0).getTitle());
assertEquals("writer", result.getData().get(0).getWriter());
assertEquals("content", result.getData().get(0).getContent());
}
@Test
@DisplayName("게시글 상세 조회 테스트")
void getPostTest() {
// Given
Board board = new Board(1L, "title", "writer", "content");
when(boardRepository.findById(1L)).thenReturn(Optional.of(board));
// When
Header<BoardDto> result = boardService.getPost(1L);
// Then
assertEquals("title", result.getData().getTitle());
assertEquals("writer", result.getData().getWriter());
assertEquals("content", result.getData().getContent());
}
@Test
@DisplayName("게시글 작성 테스트")
void savePostTest() {
// Given
BoardDto boardDto = new BoardDto(null, "title", "writer", "content", LocalDateTime.now(), LocalDateTime.now());
when(boardRepository.save(Mockito.any(Board.class))).thenReturn(boardDto.toEntity());
// When
Header<String> result = boardService.savePost(boardDto);
// Then
assertEquals("게시글이 성공적으로 작성되었습니다.", result.getData());
}
@Test
@DisplayName("게시글 삭제 테스트")
void deletePostTest() {
// Given
Long postId = 1L;
// When
Header<String> result = boardService.deletePost(postId);
// Then
assertEquals("게시글이 성공적으로 삭제되었습니다.", result.getData());
}
@Test
@DisplayName("게시글 제목으로 검색 테스트")
void searchPostsByTitleTest() {
// Given
String searchTitle = "searchTitle";
Search search = new Search("title", searchTitle, 1, 10);
List<Board> mockBoards = List.of(new Board(1L, searchTitle, "Content", "Writer"));
Page<Board> mockPage = new PageImpl<>(mockBoards);
when(boardRepository.findByTitleContaining(
eq(searchTitle),
Mockito.any(PageRequest.class)
)).thenReturn(mockPage);
when(boardRepository.countByTitleContaining(Mockito.anyString())).thenReturn(1L);
// When
Header<List<BoardDto>> response = boardService.searchPosts(search);
// Then
Header<List<BoardDto>> expected = Header.OK(mockBoards.stream().map(BoardDto::fromEntity).toList(), new Pagination(1, 10));
assertEquals(expected.getData(), response.getData());
assertEquals(expected.getPagination(), response.getPagination());
}
@Test
@DisplayName("게시글 내용으로 검색 테스트")
void searchPostsByContentTest() {
// Given
String searchContent = "searchContent";
Search search = new Search("content", searchContent, 1, 10);
List<Board> mockBoards = List.of(new Board(1L, "Title", searchContent, "Writer"));
Page<Board> mockPage = new PageImpl<>(mockBoards);
when(boardRepository.findByContentContaining(
eq(searchContent),
Mockito.any(PageRequest.class)
)).thenReturn(mockPage);
when(boardRepository.countByContentContaining(Mockito.anyString())).thenReturn(1L);
Header<List<BoardDto>> response = boardService.searchPosts(search);
// Then
Header<List<BoardDto>> expected = Header.OK(mockBoards.stream().map(BoardDto::fromEntity).toList(), new Pagination(1, 10));
assertEquals(expected.getData(), response.getData());
assertEquals(expected.getPagination(), response.getPagination());
}
@Test
@DisplayName("게시글 작성자로 검색 테스트")
void searchPostsByWriterTest() {
// Given
String searchWriter = "searchWriter";
Search search = new Search("writer", searchWriter, 1, 10);
List<Board> mockBoards = List.of(new Board(1L, "Title", "Content", searchWriter));
Page<Board> mockPage = new PageImpl<>(mockBoards);
when(boardRepository.findByWriterContaining(
eq(searchWriter),
Mockito.any(PageRequest.class)
)).thenReturn(mockPage);
when(boardRepository.countByWriterContaining(Mockito.anyString())).thenReturn(1L);
// When
Header<List<BoardDto>> response = boardService.searchPosts(search);
// Then
Header<List<BoardDto>> expected = Header.OK(mockBoards.stream().map(BoardDto::fromEntity).toList(), new Pagination(1, 10));
assertEquals(expected.getData(), response.getData());
assertEquals(expected.getPagination(), response.getPagination());
}
}
@ExtendWith(MockitoExtension.class)
public class BoardServiceTest {
@Mock
private BoardRepository boardRepository;
@InjectMocks
private BoardService boardService;
이번엔 테스트를 위해서 @ExtendWith(MockitoExtension.class)어노테이션을 사용하고 있는데 단위테스트에서 Mockito 프레임워크를 사용해서 의존성을 모킹하는 데 사용된다.
즉, 실제 Spring Context를 띄우는 것은 시간이 오래 걸리기 때문에 빠른 단위 테스트를 위해 외부 의존성 없이 특정 클래스의 비즈니스 로직만을 테스트 할 때 사용된다.
그리고 앞서 설명한 @Mock와 @InjectMocks를 통해서 Mock 객체를 생성하고 있다.
@Test
@DisplayName("게시글 리스트 조회 테스트")
void getBoardListTest() {
// given
List<Board> boardList = Arrays.asList(
new Board(1L, "title", "writer", "content"),
new Board(2L, "title2", "writer2", "content2")
);
Page<Board> page = new PageImpl<>(boardList);
when(boardRepository.findAll(Mockito.any(PageRequest.class))).thenReturn(page);
when(boardRepository.count()).thenReturn(2L);
// when
Header<List<BoardDto>> result = boardService.getBoardList(1, 2);
// then
assertEquals(2, result.getData().size());
assertEquals("title", result.getData().get(0).getTitle());
assertEquals("writer", result.getData().get(0).getWriter());
assertEquals("content", result.getData().get(0).getContent());
}
메서드 테스트 코드의 구조는 컨트롤러와 크게 다르지 않은 given, when, then 단계로 이루어져 있고 given 단계에서는 when 메서드를 통해 의존한 객체의 메서드를 호출했을 때 반환받을 값을 고정적으로 설정하고 when 단계에서 boardService의 메서드를 호출하는 것으로 테스트가 진행된다. given에서는 호출된 메서드가 반환한 값(given단계에서 설정한 값)과 기대한 값이 일치하는지 AssertJ의 assertEquals 메서드를 통해 확인하는 것으로 테스트가 마무리 된다.
어쩌다보니 글이 굉장히 길어졌다. 잘 모르는 부분에 대해 공부를 병행하며 코드를 작성하느라 정신이 없던 상태였기 때문에 글을 쓰면서라도 다시 한번 공부하는 생각으로 정리해가며 글을 쓰다보니 그렇게 된 것 같다. 그래도 덕분에 앞으로 테스트 코드를 작성함에 있어서 제대로 방향을 갖고 해 나갈 수 있게 된 것 같아 보람있는 시간이었다.
'Develop > Java & Spring' 카테고리의 다른 글
[Spring Boot] 게시판 프로젝트 v2.0 - JWT를 이용한 회원가입 및 로그인 구현 #2 Member 패키지 (0) | 2025.03.26 |
---|---|
[Spring Boot] 게시판 프로젝트 v2.0 - JWT를 이용한 회원가입 및 로그인 구현 #1 세팅 (0) | 2025.03.26 |
[Spring Boot] 게시판 프로젝트 외전 #1 JUnit과 Mockito를 이용한 테스트 - 이론 (0) | 2025.03.25 |
[Spring Boot] 게시판 프로젝트 #4 Service, Controller 구현 (0) | 2025.03.25 |
[Spring Boot] 게시판 프로젝트 #3 DTO, Repository 구현 (0) | 2025.03.25 |