준비 - 실행 - 검증
given-when-then
given-when-then 패턴은 테스트 코드를 준비-실행-검증 세 단계로 나누는 패턴을 말한다. 예시 코드는 다음과 같다.
given에서 테스트를 준비하고, when에서 검증하고자 하는 메서드를 호출하고, then에서 호출 결과를 검증한다. 너무나 간단하기 때문에 더 설명할 것도 없다.
사실 테스트 코드를 작성하기 전에 더 중요한 것은 어떻게 테스트하기 좋은 메서드를 작성하느냐 인 것 같다. 엔터프라이즈급 서비스는 비즈니스 로직이 굉장히 복잡할 수 있고, 메서드의 복잡도가 증가할 확률이 높을 것이다. 그때 테스트하기 좋은 코드로 잘 짜는 능력이 필요할 것이라고 생각한다.
그럼 테스트하기 좋은 코드(메서드)는 뭔데?
조심스럽게 내(초짜) 생각을 말해보자면,
- 제어할 수 있는 값에 의존하는 메서드
- 의존성이 낮고 하나의 책임만 가지는 메서드
1번은 생각보다 의도해서 작성하기 쉽다. 메서드가 주어진 변수만을 이용하여 동작을 수행하도록 작성한다면 이를 테스트할 때도 개발자가 쉽게 상황을 가정하여 변수를 넘겨줄 수 있으므로 테스트하기 용이한 메서드가 된다고 생각한다.
2번은 생각보다 어렵다. SRP의 원칙을 따르자면 당연한 이야기 같지만 개발을 하다 보면 그렇지 못할 때가 많다. 개발은 목표가 있고 지켜야 하는 기한이 있기 때문에 모든 것이 완벽할 수는 없다고 생각한다. 분명 완벽한 코드를 짜기에는 어려움이 있으므로 의식하면서 개발을 진행하기만 해도 충분하다는 생각을 가지고 있다. 물론 이후에 리펙토링을 진행하는 것은 필수이다.
Mockito의 사용
단위 테스트를 하고자 한다면 Mockito를 주로 사용한다. Mockito를 통해 Mock 객체를 만들고 이를 주입받아서 단위 테스트를 수행하는 전략을 사용한다.
@InjectMocks
MemberService memberService;
@Mock
MemberRepository memberRepository
위 코드에서는 MemberRepository라는 Mock 객체를 만들었고 이를 MemberService에 주입했다(자동으로 이루어짐). 아마도 MemberSerivce가 내부적으로 MemberRepository에 의존하고 있기 때문에 MemberService의 단위 테스트를 위해서 MemberRepository의 의존성을 Mock 객체로 주입한 것이라고 짐작할 수 있다.
MemberService...
MemberInfo getMemberInfo(Long id) {
Optional<Member> optionalMember = memberRepository.findById(id);
if(optionalMember.isEmpty()) {
throw new BusinessException(ErrorCode.NOT_FOUND_MEMBER);
}
Member member = optionalMember.get();
return MemberInfo.of(member);
}
MemberSerivce에 있는 getMemberInfo()를 테스트하고자 한다. 이 메서드는 memberRepository.findById()에 의존하고 있는데 우리가 Mock 객체를 주입해서 이미 의존성은 해결된 상태이다. 문제는 Mock 객체이기 때문에 메서드를 호출해도 별다른 동작을 할 수 없다. 따라서 테스트를 작성할 때 우리가 memberRepository.findById()의 동작을 정의해줘야 한다.
@Test
void getMemberInfoTest() {
//given
final int MEMBER_ID = 1L;
Member simpleMember = Member.getSimpleMember();
MemberInfo info = MemberInfo.of(simpleMember);
Mockito.when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(simpleMember));
}
먼저 테스트의 준비 단계인 given 단계를 작성해보았다. Mockito의 when을 사용하면 특정 메서드 호출 시점의 동작을 설정할 수 있다. 나는 memberRepository.findById(MEMBER_ID)가 호출되면 simpleMember가 담긴 Optional 객체를 반환하도록 설정했다.
BDDMockito의 등장
그런데 Mockito.when이라는 키워드가 조금 어색하지 않은가? 사실 given 단계에서 어울리는 키워드는 아니라고 보인다. 그래서 BDDMockito의 개념이 나오는데 BDDMockito는 사실 Mockito를 상속한 거의 동일한 프레임워크이다. 대신 given-when-then 패턴에 맞게 메서드들의 이름을 수정한 프레임워크라고 볼 수 있다.
@Test
void getMemberInfoTest() {
//given
final int MEMBER_ID = 1L;
Member simpleMember = Member.getSimpleMember();
MemberInfo info = MemberInfo.of(simpleMember);
BDDMockito.given(memberRepository.findById(MEMBER_ID)).willReturn(Optional.of(simpleMember));
}
따라서 BDDMockito의 메서드를 이용하면 위처럼 조금 더 가독성 있는 코드로 변경할 수 있다.
남은 테스트 코드 작성
이제 남은 테스트 코드를 작성해보자.
@Test
void getMemberInfoTest() {
//given
final int MEMBER_ID = 1L;
Member simpleMember = Member.getSimpleMember();
MemberInfo info = MemberInfo.of(simpleMember);
BDDMockito.given(memberRepository.findById(MEMBER_ID)).willReturn(Optional.of(simpleMember));
//when
MemberInfo result = memberService.getMemberInfo(MEMBER_ID);
//then
Assertions.assertEquals(info, result);
}
음, 이제 모든 테스트 코드를 다 작성한 것 같다. 하지만 사실 "전부 다" 작성한 것은 아니다.
MemberSerivce의 getMemberInfo를 다시 살펴보자. 해당 코드를 다시 살펴보면 우리 테스트 코드로는 findById의 결과가 OptionalMember.isEmpty() 일 때 실행되는 예외처리 구문을 테스트해보지 못한다. 즉 코드 커버리지를 100% 채울 수 없다는 의미이다.
코드 커버리지는 여러 측정 기준이 있지만 "구문" 측정 방식이 가장 많이 이용된다. 구문 측정 방식은 코드의 모든 라인이 검증되었는가를 측정하는 방식이다.
그럼 코드 커버리지를 100% 채우기 위해서 추가적인 테스트 코드를 작성해보자.
@Test
void getMemberInfoExceptionTest() {
//given
final int MEMBER_ID = 1L;
BDDMockito.given(memberRepository.findById(MEMBER_ID)).willReturn(Optional.empty());
//when
BusinessException exception = assertThrows(BusinessException.class, () -> memberService.getMemberInfo(MEMBER_ID));
//then
Assertions.assertEquals(ErrorCode.NOT_FOUND_MEMBER.getMessage(), exception.getMessage());
}
위 코드는 memberRepository.findById()가 Optional.empty()를 반환하게 하여 getMemberInfo()에서 예외처리 구문으로 진행되게 한다. 즉 우리가 검증하지 못했던 예외처리 구문을 검증할 수 있다.
결론
테스트 코드 작성은 귀찮을 수 있지만 안전하고 논리적인 코드 작성을 서포트하는 훌륭한 수단이 될 수 있다.
'Spring' 카테고리의 다른 글
스프링 로깅 기능 구현하기 (인터셉터, ThreadLocal 사용) (0) | 2022.09.13 |
---|---|
테스트 코드 커버리지의 종류 (0) | 2022.08.16 |
Spring Security Form Login 사용과 동시성 세션 제어 (0) | 2022.07.17 |
Spring Boot에서 AWS S3 PresignedURL 발급받기 (1) | 2022.06.25 |
[Spring Boot] Hibernate : GenerationTarget encountered exception accepting command (0) | 2022.06.21 |