나의 첫 스프링 프로젝트에 TDD(Test Driven Development, 테스트 주도 개발)을 도입하고자 한다.
TDD는 현재 매우 인기있는 개발방법론이다. 말 그대로 테스트를 먼저 작성하고, 구현 코드를 작성하는 방식을 말한다. 테스트 코드는 Clean Code, Refactoring 등의 명저에서 매우 많이 언급되고 중요시한다.
TDD를 처음 접했을 때는 개발 시간만 늘어나는 번거로운 작업이라 생각했다.
Spring에서 테스트 코드를 작성하는 방법을 전혀 몰랐기 때문이었다.
테스트 라이브러리에 무엇이 있는지, 어떤 메서드를 가지며 어떻게 사용하는 지 전혀 몰랐기 때문에 테스트하는데에만 시간이 너무 많이 소요되었다.
하지만 TDD는 엄청난 장점이 있기 때문에 해보고싶은데 할 줄 몰라서 안하는 것은 후회될 일이라고 생각했다.
TDD를 도입하기위해서는 적절한 레퍼런스가 필요했으나 TDD를 자세히 다뤄주는 무료강의는 찾기 어려웠다.
그러던 중 망나니개발자님의 블로그라는 엄청난 레퍼런스를 찾았고, 쉽게 설명해주신 덕분에 어느정도 감을 잡았다. (Respect~!!!!)
아직 Domain 하나만 개발했지만 TDD를 처음 해보면서 어떤 장점이 있었는 지 몇 가지 얘기해보고자 한다.
@Test
public void 요청에null값이있어서게시글추가실패() {
//given
BoardRequest boardRequest = BoardRequest.builder()
.content("hello").writer("nimoh").category("free").build();
//when
final BoardException result = assertThrows(BoardException.class, ()->boardService.save(boardRequest));
//then
assertThat(result.getErrorResult()).isEqualTo(BoardErrorResult.REQUEST_VALUE_INVALID);
}
@Test
public void 게시글추가성공() {
//given
doReturn(board()).when(boardRepository).save(ArgumentMatchers.any(Board.class));
//when
BoardDetailResponse result = boardService.save(boardRequest());
//then
assertThat(result.getTitle()).isEqualTo("test");
}
위 코드는 개발중인 애플리케이션의 테스트 코드 중 일부분이다.
이렇게 테스트코드를 먼저 작성하면 게시글을 추가하는 서비스 메서드를 작성할 때, 요청에 null값이 있어서 게시글 추가에 실패하여 예외처리하는 경우와 게시글 추가에 성공했을 떄 DTO 객체를 반환하는 경우 두 가지의 기능만 구현하면 그 서비스 메서드의 구현은 마무리되는 것이다.
리팩터링이 매우 수월하다.
지금껏 자바스크립트로 테스트 없이 개발했을 때 리팩터링하는데에 꽤나 애를 먹었다. 마틴 파울러의 Refactoring2 책에서도 비슷한 얘기가 언급되어있는데, 테스트 코드 없이 리팩터링하는 것은 매우 위험한 것이다. 생각없이 리팩터링하다보니 Git을 reset하는 경우도 종종 있었다. 테스트 코드를 작성해보니 실제 구현 코드를 아무리 막 리팩터링해도 믿는 구석(?)이 있으니 실수를 발견하는데에 시간이 매우 절약되었다.
재밌다.
Spring의 경우 백엔드 작업이 주요 작업이다. 프론트엔드는 구현한 코드가 어떻게 출력되는 지 바로 확인할 수 있기 때문에 시각적인 즐거움이 있다. 백엔드는 검은화면에 하얀 글자밖에 없다. JUnit5 등의 테스트 라이브러리를 사용해서 테스트 코드를 작성하면 테스트 할 때마다 실패하면 빨간색, 성공하면 초록색으로 성공여부가 표시된다. 테스트를 작성하고 실행시킬 때 그 긴장감과 성공했을 때의 짜릿함은 백엔드 작업에 뺼 수 없는 재미이다.
위에서 참조한 망나니개발자님은 Repository -> Service -> Controller 순으로 테스트주도개발 하는 것을 추천한다. Repository는 구현할 메서드가 없는 통합테스트에 가깝기 때문에 테스트하기 쉽다. 또 Repository가 안정되면 나머지 계층의 테스트도 안정적으로 작성할 수 있다. 나 역시 Repository부터 테스트하며 개발하는 것이 더 안정적이고 빠르다고 생각한다.
TDD로 개발하면 컴파일 에러가 많이 발생한다. 이유는 당연하다. 코드 구현보다 테스트를 먼저하기 때문에 실제로 존재하지 않는 코드를 참조해야하기 때문이다. 처음부터 성공하는 테스트는 TDD가 아니다. 먼저 테스트 실패가 발생하고 실패한 테스트를 성공시키기 위해 기능을 추가하고 코드를 수정하는 것이 테스트 주도 개발(TDD)이다.
현재 JPA를 사용해서 DB에 접근하고 있다.
효과적으로 JPA 레퍼지토리를 테스트하기 위해서는 @DataJpaTest
어노테이션을 테스트 클래스에 추가해줘야한다. @DataJpaTest
은 기본적으로 H2 DATABASE를 기본으로 한다. 사용하는 다른 DB(MySQL 등)가 AutoConfigureTestDatabase를 NONE으로 설정해주어 H2 DATABASE가 기본으로 설정되지 않도록 변경해줄 수 있다.
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class BoardRepositoryTest {
..Test Code
}
또, @DataJpaTest는 DB Transaction을 자동 롤백해주므로 실제 DB에 값이 저장되지는 않는다.
테스트 코드는 간단한 예시를 들겠다.
@Test
public void 게시글작성하기() {
//given
final Board board = Board.builder()
.id(1L)
.title("test")
.content("12345")
.writer("nimoh")
.category("free")
.regDate(new Date())
.build();
//when
Board result = boardRepository.save(board);
//then
assertThat(result).isNotNull();
}
게시글을 INSERT하기 위한 boardRepository 객체의 save함수를 테스트하는 테스트 메서드이다.
💡 테스트코드를 given 과 when, then으로 나누어 작성하면 더 직관적으로 생각하고 구현할 수 있어서 좋다. 많이 사용되는 방법이다.
given 에서 임의의 Entity를 생성한다.
when 에서 테스트할 boardRepository의 save메서드를 호출하고, 그 인자는 given에서 생성한 board 객체이다. 해당 메서드가 정상적으로 작동했다면 DB에 board 객체를 잘 저장하고 그 객체를 반환해줄 것이다.
then에서 반환된 객체가 Null이 아닌 경우 테스트는 통과한다.
@Test
public void 게시글추가성공() {
//given
doReturn(board()).when(boardRepository).save(ArgumentMatchers.any(Board.class));
//when
BoardDetailResponse result = boardService.save(boardRequest());
//then
assertThat(result.getTitle()).isEqualTo("test");
}
앞서 TDD 장점에서 참조한 코드이다.
서비스 계층을 개발할 때에는 나는 Mockito 라이브러리에서 제공하는 doReturn
, doThrow
메서드를 사용하여 Mock 객체의 메서드를 Stub처리한다.
👉 Stub이란
가짜 객체에 메시지를 송신하고, 그에 따른 응답을 지정해주는 것을 말한다.
Mockito에서는 다음과 같은 stub 메서드를 지원한다.
- doReturn(리턴타입).when(의존객체).stub할 메서드() : Mock 객체가 특정한 값을 반환
- doNothing() : Mock 객체가 아무 것도 반환하지 않음(void)
- doThrow(): Mock 객체가 예외 발생시킴
먼저 given 파트를 풀어서 설명해보겠다. doReturn()
에는 어떤 Mock 객체의 메서드의 결과값을 지정한다. 위의 코드의 경우 board()
가 결과값으로 반환되는데, board()
메서드는 Board 생성 후 객체를 반환하는 메서드이다.
when()
메서드는 Mock 객체(boardRepository
)를 지정하고, 그 뒤에 그 객체의 stub할 메서드(save()
)를 작성한다.
그 후 stub한 메서드를 실행해주고, stub 결과인 board()의 반환값이 result 변수에 참조된다. board()는 내가 임의로 만든 BoardDetailResponse 객체이므로 임의로 정한 title값과 비교해준다.
Controller는 HTTP 요청에 대한 응답이 로직의 주를 이루기 때문에 테스트를 위해서는 HTTP 요청이 필요하다. 예전에는 Postman 프로그램을 통해 테스트(?)를 해줬으나 꽤나 번거로운 일이었다. 다행히도 MockMvc 객체를 사용하면 HTTP요청을 대신해준다.
@ExtendWith(MockitoExtension.class)
public class BoardControllerTest {
private MockMvc mockMvc;
private Gson gson;
@InjectMocks
private BoardController boardController;
@Mock
private BoardServiceImpl boardService;
@BeforeEach
public void init() {
gson = new Gson();
mockMvc = MockMvcBuilders.standaloneSetup(boardController).setControllerAdvice(new GlobalExceptionHandler()).build();
}
MockMvc를 선언하고 @Autowired
로 자동 주입해줄 수 있지만, 직접 생성해주는 것을 선택했다.
다음은 MockMvc를 통해 Controller를 테스트하는 메서드 중 하나이다.
@Test
public void 게시글하나조회성공() throws Exception{
//given
final String url = "/api/v1/board/1";
doReturn(boardDetailResponse()).when(boardService).findById(1L);
//when
final ResultActions resultActions = mockMvc.perform(
MockMvcRequestBuilders.get(url)
.header("header_name","123")
);
//then
resultActions.andExpect(status().isOk());
}
우선 doReturn()
메서드를 통해 Mock객체의 메서드를 Stub해준다 mockMvc.perform()을 하면 HTTP요청을 생성해준다. header, param 등 다양한 체이닝메서드들이 있으므로 요청 시 필요한 HTTP 헤더나 바디를 입력해주면된다. 이 요청은 resultAction에 참조되고, 결과값을 예측하는 방식으로 테스트를 할 수 있다.
위에서 예시한 코드들은 매우 일부분이며 각각의 계층에서 어떤 흐름으로 무엇을 테스트 해야하는 지 매우 간단하게 적었다. 테스트 코드를 사용하는 다른 방법도 매우 많다. Service라도 상황에 따라 다른 Mock, Stub이 필요할 수도 있고 다른 결과값을 예측할 수도 있다. 실제로 본인의 코드를 TDD로 개발해보면 어느정도 감이 잡힐 것이다.