
지금까지는 API를 만들고 나면 Postman을 켜고, 버튼을 누르고, DB를 조회하며 "눈"으로 검증했다. 하지만 다양한 프로젝트를 진행할수록 이 과정은 지루해지고 번거로웠다. (물론 Postman으로 검증하지 않는것은 아닙니다)
특히 이번 프로젝트에서는 TDD의 철학을 조금이라도 녹여내고 싶었은 마음이 있었다.
테스트 코드 강의를 수강하며( 박우빈 - Practical Testing: 실용적인 테스트 가이드) 테스트는 단순한 버그 찾기가 아니라 "코드를 변경했을 때 기존 기능이 안전하다는 것을 보장해주는 안전장치"라는 말에 공감을 할 수 있었기 때문이다.
강사님이 말씀하신 테스트 코드란 아래와 같다.
올바른 테스트 코드는
- 자동화 테스트로 비교적 빠른 시간 안에 버그를 발견할 수 있고, 수동 테스트에 드는 비용을 크게 절약할 수 있다
- 소프트웨어의 빠른 변화를 지원한다
- 팀원들의 집단 지성을 팀 차원의 이익으로 승격시킨다
- 가까이 보면 느리지만, 멀리 보면 가장 빠르다
- 팀 차원, 소프트웨어 주기 관점에서 봤을 경우.
그리고 테스트 코드를 관리하는 비용을 추가로 들이지 않으려면, 테스트 코드를 "잘" 짜야 한다는것이 핵심이라고 생각한다.
이번 포스팅은 비즈니스 로직의 핵심인 Service Layer 단위 테스트를 작성한 과정을 정리해보려한다.
먼저 WordService의 테스트 코드이다. 단순히 단어 3개를 DB에서 조회하는 과정이고, 실제 DB를 띄우지 않고, 오직 자바 코드로만 빠르게 검증하는 단위 테스트 방식을 택했다.
@ExtendWith(MockitoExtension.class)
class WordServiceTest {
@InjectMocks // 가짜 객체들을 주입받을 실제 테스트 대상
private WordService wordService;
@Mock // 가짜(Mock) 협력 객체
private WordRepository wordRepository;
@Test
@DisplayName("랜덤 단어 3개를 가져온다")
void getRandomWords() {
// given
List<Word> mockWords = Arrays.asList(
Word.from("Apple", "사과", "I eat apple"),
Word.from("Banana", "바나나", "I eat banana"),
Word.from("Cherry", "체리", "I eat cherry")
);
given(wordRepository.findRandomWords(3)).willReturn(mockWords);
// when
List<WordResponse> result = wordService.getRandomWords();
// then
assertThat(result).hasSize(3);
assertThat(result.get(0).word()).isEqualTo("Apple");
// 검증: 리포지토리가 진짜로 1번 호출되었는가?
verify(wordRepository, times(1)).findRandomWords(3);
}
}
테스트 코드를 보면 낯선 어노테이션들이 등장하는데, 마치 스프링을 처음 학습할때가 떠오른다.
"왜 이것을 썼는지" 이유를 정리해 보았다.
@SpringBootTest vs @ExtendWith(MockitoExtension.class)@SpringBootTest를 쓸까 고민했지만, 서비스 레이어 테스트의 목적은 "비즈니스 로직 검증"이지, DB 연결이나 스프링 컨텍스트 로딩이 아니라고 생각하였다.@SpringBootTest: 스프링의 모든 빈을 다 띄운다. 무겁고 느림.@ExtendWith(MockitoExtension.class): JUnit5에서 Mockito 기능을 사용하기 위한 설정. 스프링을 띄우지 않고 가짜 객체만 사용하여 밀리초 단위로 빠르게 실행된다.@Mock과 @InjectMocks@Mock: 껍데기만 있는 가짜 객체를 만들어 실제 DB에 접근하지 않는다.@InjectMocks: 테스트 대상인 wordService를 생성하고, 그 안에 위에서 만든 @Mock 객체들을 주입해준다.이 어노테이션들 덕분에 DB 상태와 상관없이 서비스 로직만 순수하게 테스트할 수 있었다.
테스트 코드 내부를 보면 Mockito.when() 대신 given()을 사용하였고, 이는 BDDMockito 라이브러리이다.
왜 Mockito.when() 대신 BDDMockito.given()인가?
'테스트는 문서다' 라는 기본 개념을 지키기 위해 가독성을 중요하다고 생각하여Given-When-Then 패턴을 적용하는데, 이때 기본 Mockito 문법은 이 흐름을 깨버린다.
// given 섹션인데 함수 이름이 when 이라서 헷갈림
when(wordRepository.findRandomWords(3)).thenReturn(mockWords);
// 주석(given)과 코드가 일치함 -> 가독성 향상
given(wordRepository.findRandomWords(3)).willReturn(mockWords);
결국 기능은 같지만, 테스트 시나리오가 하나의 문서처럼 작성되어 좋은 가독성을 위해 BDDMockito를 선택하였다.
마지막으로 검증 단계(then)에서는 JUnit의 기본 assertEquals 대신 AssertJ의 assertThat을 사용하였다.
추가로 verify(wordRepository, times(1))를 통해, 서비스 로직이 리포지토리를 정확히 한 번 호출했는지 행위까지 검증하며 테스트의 완성도를 높일 수 있었다.
이번 서비스 레이어 테스트를 작성하며 느낀 점은 "테스트는 문서다"라는 것이다. @DisplayName으로 테스트의 의도를 분명히 밝히고, Given-When-Then 구조로 흐름을 잡고, 코드 자체가 하나의 명세서로서 기능할 수 있게 되었다.
하나의 테스트 메서드에는 하나의 목적만 가져야 되는 규칙도 되게 인상깊었는데, 이는 추후 포스팅에서 다룰 예정이다.