코드스테이트에서 내준 솔로프로젝트가 TodoList 만들기였다. 프론트 엔드 공부하면서 처음 자바스크립트로 막 이리저리 쩔쩔매며 투두리스트를 만들었는데 그때 어떤 분 강의에서 LocalStorage를 이용하는게 아닌 본인이 만든 서버를 띄우고 그 서버와 통신하는 모습을 보여줬었는데 나도 그것을 해보고 싶어서 열심히 만들었다.
워낙 간단한 CRUD 기능만 넣었어서 만드는데는 큰 시간이 들지 않았다. 빨리 끝냈기에 시간도 남아있고 더 발전시켜보고 싶은 마음이 들어서 먼저 내가 만든 프론트쪽 코드를 좀 손보고 지금은 이 프로젝트를 이대로 끝내지 말고 더 다른 것들을 적용하고 싶어서 테스트 케이스와 API문서화 코드를 작성했다.
Spring REST Docs
Spring REST Docs 는 스프링에서 제공해주는 API 문서화를 자동으로 생성 가능하게 하는 라이브러리이다. 사실 개발자가 손수 API문서를 만들다 보면 요즘은 애자일 방법론의 XP나 스크럼 같은 것들을 많이 하는 스타트업도 많은데 계속해서 업데이트 하기가 쉽지 않고 누락하는 경우가 많을 것이다.
그래서 프론트엔드나 백엔드 서버와 통신하는 여타 부서들과 협업하기 위해서는 API문서화를 잘 해두어야하는데 그부분을 Spring REST Docs가 해줄 수 있다.
- 장점
- Spring REST Docs는 테스트 케이스가 성공해야지만 API가 문서화가 가능하다.
- 비지니스 로직에 해당하는 코드를 더럽히지 않고 분리되어서 API문서화를 위한 애노테이션과 코드들이 들어간다.
- 스프링 자체에서 제공하는 라이브러리인만큼 신뢰성이 있다.
- 단점
- 장점이면서도 때로는 API문서화를 위해서 테스트케이스를 작성해야하고 그것이 성공해야한다는 것에 시간적 비용이 들어간다.
- Swagger 라는 다른 라이브러리에서는 자동으로 생성된 API문서 내에서 UI를 통해 API 통신 테스트가 가능하다. 하지만 Spring REST Docs는 불가능하다. 따로 서버에 요청을 보내 직접 확인하는 수밖에 없다.
테스트 케이스를 작성하는 번거로움이 있지만 앱을 개발하는 데에 있어서 테스트 케이스를 작성하는 것은 매우 중요하기에 오히려 이런 제약을 둠으로써 더 좋은 효과를 가져다 준다 생각한다.
테스트 하고자 하는 코드 (컨트롤러)
@RestController
public class TodoController {
private final TodoService todoService;
private final TodoMapper mapper;
public TodoController(TodoService todoService, TodoMapper mapper) {
this.todoService = todoService;
this.mapper = mapper;
}
@PostMapping("/")
public ResponseEntity todoPost(@RequestBody TodoPostDto todoPostDto) {
// 비지니스 로직
Todos savedTodo = todoService.createTodo(mapper.todoPostDtoToTodos(todoPostDto));
return new ResponseEntity(mapper.todosToTodoResponseDto(savedTodo), HttpStatus.CREATED);
}
@Transactional(readOnly = true)
@GetMapping("/{todo-id}")
public ResponseEntity todoGet(@PathVariable("todo-id") @Positive long todoId) {
Todos findTodo = todoService.findTodo(todoId);
return new ResponseEntity(mapper.todosToTodoResponseDto(findTodo), HttpStatus.OK);
}
@Transactional(readOnly = true)
@GetMapping("/")
public ResponseEntity todosGet() {
return new ResponseEntity(todoService.findAllTodos(), HttpStatus.OK);
}
@PatchMapping("/{todo-id}")
public ResponseEntity todoUpdate(@PathVariable("todo-id") @Positive long todoId,
@RequestBody TodoPatchDto todoPatchDto) {
Todos todo = mapper.todoPatchDtoToTodos(todoPatchDto);
todo.setId(todoId);
Todos updatedTodo = todoService.updateTodo(todo);
return new ResponseEntity(updatedTodo, HttpStatus.OK);
}
@DeleteMapping("/{todo-id}")
public ResponseEntity todoDelete(@PathVariable("todo-id") long todoId) {
todoService.deleteTodo(todoId);
return ResponseEntity.noContent().build();
}
@DeleteMapping("/")
public ResponseEntity todosDelete() {
todoService.deleteAllTodos();
return ResponseEntity.noContent().build();
}
}
위의 코드는 간단한 CRUD 가 가능한 Todo List 컨트롤러 코드이다.
todoPost(”/”)
- 바디에 JSON 으로 title, todoOrder (Todo 우선순위), completed 를 담아 전달 받으면 해당 내용을 TodoService 를 통해서 데이터베이스에 저장하고 저장된 Todo 리스트를 다시 리턴해준다.
todoGet(”/{todo-id}”)
- URL 패스로 전달 받은 todo-id 로 TodoService를 통해 JPA 기술로 데이터베이스에서 해당 아이디의 데이터를 불러와 엔티티에 맵핑하고 해당 내용을 다시 ResponseDto로 변경해서 리턴해준다.
todosGet(”/”)
- 모든 Todo 리스트를 불러오는 메소드 (사실 이 메소드는 유저가 이용할 이유가 없을 뿐더러 이용하면 안된다. 나중에 스프링 시큐리티를 통해서 역할에 맞는 제한을 둬야겠다.
todoUpdate(”/{todo-id}”)
- PatchDto 를 통해서 업데이트 할 내용을 body로 받고 todo-id 로 데이터베이스에서 해당 todo를 불러와 업데이트 한 후 결과를 리턴해준다.
todoDelete(”/{todo-id}”)
- 하나의 Todo를 삭제한다.
todosDelete(”/”)
- 모든 Todo를 삭제한다.
테스트 케이스 작성
JUnit
JUnit 을 활용하여 테스트 케이스를 작성할 예정이다. 직접 눈으로 로그를 찍으면서 확인하는 것이 아닌 더욱 직관적으로 무엇을 테스트 하고자 하는지 코드로 나타낼 수 있고 또한 테스트 케이스가 실패했을 때 어떤 부분에서 잘못된 것인지 너무 친절하게 알려준다.
Mockito, MockMvc
이번 테스트에서는 Mock 데이터를 쓸 것이다. Mockito 라이브러리를 활용해서 Mock 객체를 통해 컨트롤러계층과 서비스 계층의 연결을 끊어줄 것이다. 이 이야기를 하자면 길다. 처음 듣는 사람에게는 생소한 개념으로 원래는 이러한 컨트롤러를 테스트하게 되면 컨트롤러 내부에 있는 서비스 계층의 코드들 그리고 서비스 계층 코드에 있는 데이터를 액세스하고 검색하는 로직들이 모두 실행되서 반환된다.
이렇게 되면 하나 하나의 테스트를 하는데 시간이 굉장히 오래 걸릴 뿐더러 정확한 단위테스트를 진행하기가 어렵다. 테스트 케이스에서 오류가 나는 것이 컨트롤러 단에서 나는 것인지 서비스 단에서 나는 것인지 찾는데에 많은 시간을 써야하기 때문이다.
지금은 하나의 컨트롤러지만 여러개의 컨트롤러와 서비스등이 많이 있다면 하나하나 테스트 케이스가 오래걸리는 것은 빌드를 할 때나 테스트를 확인할 때 여러모로 많은 시간을 잡아먹는다.
그래서 이러한 계층간의 연결을 끊어주기 위해 해당 객체를 비어있는 mock 객체 로 대체한다. 이러한 mock 객체 는 의존하고 있는 계층의 코드가 리턴해야되는 값을 지정 할 수 있다.
코드를 보며 설명하는 것이 더 빠르지 않을까 생각된다.
@PostMapping("/")
public ResponseEntity todoPost(@RequestBody TodoPostDto todoPostDto) {
// 비지니스 로직
Todos savedTodo = todoService.createTodo(mapper.todoPostDtoToTodos(todoPostDto));
return new ResponseEntity(mapper.todosToTodoResponseDto(savedTodo), HttpStatus.CREATED);
}
예를 들어 이러한 PostMapping 이 된 컨트롤러가 있다면 여기서 savedTodo를 얻기 위해 todoService에 있는 createTodo 메소드를 의존한다. 여기서 이 메소드가 그냥 실행되게 되면 내부 로직에 의해 데이터에 실제로 액세스를 하고 해당 되는 값을 다시 리턴해온다. 이러한 연결을 차단하기 위해 Mockito의 Mock 객체로 변환한다.
실제로 todoPost() 가 실행될 때 todoService.createTodo() 를 호출하지만 이거는 내가 준비해둔 가짜 객체로 대체해서 실행된다. 진짜 todoService.createTodo() 가 실행되는 것이 아닌 내부 로직은 텅텅 비어있고 어떤 요청이 오든 내가 지정한 리턴값 만 리턴해주는 그런 객체로 대체 할 수 있다. 그렇게 되면 서비스 계층과의 연결이 끊기고 오로지 컨트롤러의 기능만 테스트 할 수 있다.
테스트 케이스 코드
위에 설명한 내용을 코드로 작성해보았다. 아래는 todoPost() 의 테스트 케이스이다.
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.doNothing;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
class TodoControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private Gson gson;
@MockBean
private TodoService todoService;
@MockBean
private TodoMapper mapper;
@Test
void todoPost() throws Exception {
// given
TodoPostDto postTodo = new TodoPostDto("운동하기", 1, false);
TodoResponseDto response = new TodoResponseDto(1L, "운동하기", 1, false);
// ✨Mockito를 활용한 Mock 객체를 만들어주는 부분
given(todoService.createTodo(Mockito.any(Todos.class))).willReturn(new Todos());
given(mapper.todoPostDtoToTodos(Mockito.any(TodoPostDto.class))).willReturn(new Todos());
given(mapper.todosToTodoResponseDto(Mockito.any(Todos.class))).willReturn(response);
// JSON으로 변환을 돕는 gson 라이브러리
String content = gson.toJson(postTodo);
// when
ResultActions actions =
mockMvc.perform(
post("/")
.contentType(MediaType.APPLICATION_JSON)
.content(content)
.accept(MediaType.APPLICATION_JSON)
);
// then
actions
.andExpect(status().isCreated())
.andExpect(jsonPath("$.title").value(postTodo.getTitle()))
.andExpect(jsonPath("$.todoOrder").value(postTodo.getTodoOrder()))
.andExpect(jsonPath("$.completed").value(postTodo.isCompleted()));
}
기본적으로 테스트 케이스는 given, when, then 으로 작성하는 것이 좋다. 나도 처음에는 어떻게 작성할지 몰라서 생각하던 중에 배운대로 일단 주석에 given, when, then 을 작성했는데 그래도 하나하나 조금이라도 작성 할 수 있었다.
Mock 객체
Mock 객체는 BDDMockito.given 을 통해서 만들 수 있다. 굉장히 많이 쓰기 때문에 아예 static 메소드로 import 해와서 사용했다. 이 안에 mock 객체를 만들 클래스와 메소드를 넣고 해당 내용 안에 어떤 타입의 입력이 필요한지 지정해준다.
- Mockito.any
- Mockito.anyLong
- Mockito.anyBolean
- …
등등 아주 다양하게 되어있다. Mockito.any(해당 클래스) 를 지정해주고 .willReturn 체이닝을 통해서 어떤 값을 리턴할 지 정하면 된다. 사실 조금 머리를 굴리면 그 리턴값이 다음의 코드에 영향을 주지 않는 경우네는 그냥 new Todos 와 같이 데이터를 집어넣지 않은 빈 객체를 넣기도 하는데 그래도 실제로 데이터의 흐름이 어떻게 흘러가는지 보는 것도 좋기에 원래 들어오게 될 데이터등을 잘 작성했다.
MockMvc
웹 애플리케이션을 테스트 할 때 HTTP 메소드를 검증하려면 실제로 요청을 보내야 한다. 그래서 웹서버를 실행시키고 HTTP 요청을 보내는 과정을 거치게 되는데 문제는 이렇게 하게 되면 여러개의 테스트를 실행해야 하는 입장에서 굉장히 비효율적인 비용이 많이 들어가게 된다.
그래서 이러한 HTTP 요청을 손십게 생성하고 요청을 검증할 수 있도록 스프링 프레임워크에서 제공해주는 것이 바로 MockMvc 이다. MockMvc 의 가장 큰 장점은 간편함에 있다. 다른 어떤 큰 설정을 해주지 않아도 가상화한 객체로서 컨트롤러를 테스트 할 수 있게 해준다.
MockMvc 는 서블릿 컨테이너를 직접적으로 띄우지 않는다. 그렇기 때문에 빠른 속도로 불필요한 프로세스를 제거하고 테스트 할 수 있다.
// when
ResultActions actions =
mockMvc.perform(
post("/")
.contentType(MediaType.APPLICATION_JSON)
.content(content)
.accept(MediaType.APPLICATION_JSON)
);
// then
actions
.andExpect(status().isCreated())
.andExpect(jsonPath("$.title").value(postTodo.getTitle()))
.andExpect(jsonPath("$.todoOrder").value(postTodo.getTodoOrder()))
.andExpect(jsonPath("$.completed").value(postTodo.isCompleted()));
mockMvc.perform() 을 통해서 어떤 메소드를 호출할지 정하면 된다. body 로 넘길 데이터가 있다면 content를 통해서 JSON로 변경된 String 을 넣어주면 된다.
그리고 아래에 보면 거기에 메소드 체인을 통해 andExpect() 메소드로 기대하는 결과값을 테스트해 테스트의 성공, 실패 여부를 확인 할 수 있다.
- status() : HTTPStatus 가 의도한 바 대로 응답이 오는지 확인 할 수 있다.
- jsonPath() : 응답으로 오는 JSON의 결과 값을 비교하여 응답을 확인 할 수 있다.
✨ 위의 status, jsonPath는 MockMvcResultMatchers 의 스태틱 메소드로서 import 할 때 static 형태로 불러 왔음을 참고하길 바란다.
결과 확인
intelliJ 에서 해당 테스트 클래스를 실행시키면 아래의 모든 테스트 케이스들이 한번에 실행된다! 모든 파란불이 들어오길 바라는 그순간!!!!!!
여러 로그들과 함께 왼쪽편에 모든 테스트 케이스가 성공했다 !! 😀👍 아마 SpringBootTest 애노테이션이 아닌 다른 방법으로 테스트 할 시에 더 빠른 것으로 알고 있는데 일단은 배우는자로서 간편하게 실행 할 수 있어서 진행하였다.
총평
사실 API 문서화 까지 내용을 적으려 했는데 쓰다보니까 이미 컨트롤러의 post 기능에 대한 테스트 케이스 작성 이야기만 해도 꽤 길어진 것 같아서 1, 2부로 나누어 블로그 글을 올리려 한다. 다음 2탄에서는 이 테스트 케이스 위에 Spring REST Docs 를 이용하여 어떻게 API 문서화를 했는지를 남기도록 하겠다.
'개발일지' 카테고리의 다른 글
[솔로 프로젝트] TodoList Test 작성 및 API 문서화 하기 2탄 / API문서화편 (0) | 2023.06.10 |
---|---|
[KPT] Section 4을 마치며 하는 회고 feat.코드스테이츠 백엔드 (1) | 2023.06.08 |
[리팩토링] 투두리스트 다크모드 코드 개선하기 feat. react (1) | 2023.06.06 |
[프로젝트일지 - 에러 해결] JPA 연관관계 맵핑 문제 오류 feat.클래스 이름 바꿀 때는 조심 또 조심! (0) | 2023.05.27 |
[TIL] Cloud 배포에 대해서 feat. 코드스테이츠, AWS (0) | 2023.05.25 |