UserService 클래스의 테스트를 목적으로 단위 테스트 코드를 작성하고 일정 수준 이상의 테스트 커버리지를 달성하자.
UserSerivce 클래스에는 유저를 식별하는 VerifyUser 메서드 하나만이 존재하기에, 테스트 커버리지 100% 달성을 목표로 한다.
UserService 클래스는 다음과 같다.
@RequiredArgsConstructor
@Service
public class UserService {
private final UserRepository userRepository;
public BaseUser verifyUser(UserLoginDto userLoginDto) {
return userRepository.findByPhoneNumber(userLoginDto.getPhoneNumber()).orElseGet(
() -> userRepository.save(new BaseUser(userLoginDto.getPhoneNumber(), Role.USER)));
}
}
verifyUser메서드는 UserLoginDto를 파라미터로 받으며, UserLoginDto에서 유저의 전화번호를 추출한 후, UserRepository를 통해 해당 유저가 데이터베이스에 존재하는지 확인한다.
만약 유저가 데이터베이스에 존재한다면 유저를 조회하여 반환하며, 데이터베이스에 존재하지 않는다면 데이터베이스에 유저를 삽입한 후에 반환한다.
테스트는 유저가 존재할 때의 성공 테스트, 유저가 존재하지 않을 때의 성공 테스트 2개를 각각 작성한 후에 테스트 커버리지를 측정한다.
package JGS.CasperEvent.global.jwt.service;
import JGS.CasperEvent.global.jwt.repository.UserRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class) // 1
class UserServiceTest {
@Mock // 2
private UserRepository userRepository;
@InjectMocks // 3
UserService userService;
@Test // 4
@DisplayName("테스트 이름") // 5
void test() {}
}
대략적인 테스트 클래스의 소스코드를 보며 구조를 이해해 보자.
Mockito 프레임워크를 사용할 수 있게 해준다.
더 자세하게 말하자면 위 어노테이션이 MockitoAnnotations.initMocks()를 자동으로 호출하여, @Mock, @Spy, @InjectMocks와 같은 Mockito 어노테이션이 붙은 객체들을 자동으로 생성하고 주입한다.
위 코드에서는 @Mock, @InjectMocks 어노테이션이 붙은 userRepository와 userService 객체를 주입해준다.
Mock 객체를 생성해준다. Mock 객체는 실제 객체의 동작을 모방하는 객체로, 테스트에서 특정 객체의 동작을 시뮬레이션하고 그 동작을 검증하는 데 사용된다.
위 코드에서는 @Mock 어노테이션이 붙은 userRepository가 Mock 객체로서 생성되며, userRepository는 실제 객체가 아니며 UserRepository 객체의 동작을 모방한다.
테스트 중에 의존성 주입을 자동으로 수행해준다. 이 어노테이션은 해당 필드를 실제 객체로 생성하고, 그 객체가 의존하는 모든 @Mock(또는 @Spy)으로 주입 가능한 필드를 자동으로 주입한다.
위 코드에서는 @InjectMocks 어노테이션이 붙은 userService를 실제 객체로 생성한다. 이 후, UserRepository 내의 UserRepository 필드에 Mock 객체를 주입한다.
테스트 메서드임을 명시한다.
위 코드에서는 test() 메서드가 테스트 메서드임을 알 수 있다.
테스트 클래스나 테스트 메서드에 커스텀 이름을 부여할 수 있게 한다.
이 이름은 테스트 실행 시 표시되며, 더 읽기 쉽고 이해하기 쉬운 설명이나 이름을 제공할 수 있다.
위 코드에서는 테스트 이름이 테스트 결과에서 메서드 이름 대신 표시됨을 알 수 있다.
@Test
@DisplayName("유저 식별 테스트 - 성공 (가입한 유저)")
void verifyUserTest_Success() {
//given
UserLoginDto userLoginDto = new UserLoginDto("010-0000-0000");
BaseUser user = new BaseUser("010-0000-0000", Role.USER);
given(userRepository.findByPhoneNumber("010-0000-0000")).willReturn(Optional.of(user)); // 1
//when
BaseUser verifiedUser = userService.verifyUser(userLoginDto);
//then
assertThat(verifiedUser.getPhoneNumber()).isEqualTo("010-0000-0000");
assertThat(verifiedUser.getRole()).isEqualTo(Role.USER);
}
해당 테스트에서는 보편적인 given-when-then 패턴을 따른다.
여기서 잠깐! given-when-then이란?
given 테스트 시나리오에서 주어진 사전 조건when 테스트 시나리오에서 시도하는 특정 동작then 동작으로 인해 예상되는 변화코드를 뜯어보며 단위 테스트 작성법에 대해서 알아보자.
given(userRepository.findByPhoneNumber("010-0000-0000"))
.willReturn(Optional.of(user));
Mock 객체의 메서드를 모킹하는 부분이다.
이를 좀 더 보편적으로 풀어쓴다면 다음과 같겠다.
given(MOCK객체.대상메서드(입력값)).willReturn(출력값);
Mock 객체의 대상 메서드에 목표한 입력값이 입력된다면, 의도한 출력값이 반환되도록 메서드를 모방한다.
위 코드에서는 userRepository의 findByPhoneNumber 메서드에 "010-0000-0000"이 입력된다면, user 객체가 반환되도록 모방된 것을 알 수 있다.
입력값이 완전히 동등한 경우에만 의도한 출력값이 반환됨에 유의하자.
만약 입력값이 엔티티 등 객체라면, 필드값이 완전히 동일하더라도 메모리 주소가 다른 별개의 객체라면 개발자가 의도한 반환값을 얻을 수 없다.
이를 해소하기 위해서는 @EqualsAndHashCode 어노테이션 등 객체간의 동등성을 비교할 수 있는 코드가 존재해야 한다.
위 코드의 입력값은 String 객체인데, String 객체에는 기본적으로 equals 메서드가 오버라이딩 되어 있어 정상적으로 작동함을 알 수 있다.
BaseUser verifiedUser = userService.verifyUser(userLoginDto);
userService의 verifyUser 메서드에 userLoginDto를 파라미터로 호출한다.
userLoginDto의 전화번호 필드는 "010-0000-0000"로 입력되어 있으며, 모킹한 메서드의 동작에 따라 사전에 정의한 user 객체가 반환됨을 쉽게 유추할 수 있다.
assertThat(verifiedUser.getPhoneNumber()).isEqualTo("010-0000-0000");
assertThat(verifiedUser.getRole()).isEqualTo(Role.USER);
반환된 객체를 검증한다.
verifiedUser의 phoneNumber 필드와 Role 필드가 의도된 반환값이 맞는지 확인한다.
의도된 반환값이 맞다면 테스트가 통과하며, 그렇지 않다면 테스트가 실패된다.
@Test
@DisplayName("유저 식별 테스트 - 성공 (가입하지 않은 유저)")
void testName() {
//given
UserLoginDto userLoginDto = new UserLoginDto("010-0000-0000");
BaseUser user = new BaseUser("010-0000-0000", Role.USER);
given(userRepository.findByPhoneNumber("010-0000-0000")).willReturn(Optional.empty());
given(userRepository.save(user)).willReturn(user);
//when
BaseUser verifiedUser = userService.verifyUser(userLoginDto);
//then
assertThat(verifiedUser.getPhoneNumber()).isEqualTo("010-0000-0000");
assertThat(verifiedUser.getRole()).isEqualTo(Role.USER);
}
위의 테스트와 대동소이하므로 자세한 설명은 생략한다.

측정하고자 하는 테스트 코드를 우클릭 후 More Run/Debug - Run '테스트 이름' with Coverage를 클릭한다.

이후 우측 UI에서 테스트 커버리지를 확인할 수 있다.
커버리지 유형은 아래와 같다.
Class 테스트로 인해 검증된 클래스의 비율을 나타낸다.Method 테스트로 인해 검증된 메서드의 비율을 나타낸다.Line 전체 코드 라인 중 테스트로 인해 검증된 라인의 비율을 나타낸다.Branch 전체 분기 중 테스트로 인해 검증된 분기의 비율을 나타낸다.이 중 Branch Test Coverage의 개념이 생소하게 느껴진다.
자세한 예시를 통해 개념을 이해해보자.
public class test {
boolean isX(char a) {
if (a == 'x' || a == 'X') {
return true;
} else {
return false;
}
}
}
문자 a가 X인지 판별하는 간단한 메서드를 작성해봤다.
이 때, a = x 인 경우와 a = y인 경우를 테스트해 볼 때, 테스트 커버리지는 어떻게 될까?
Class 전체 클래스는 1개이며, 이 클래스에 대한 테스트가 진행됐으므로 100%의 Class Test Coverage를 가짐Method 전체 메소드는 1개이며, 이 메소드에 대한 테스트가 진행됐으므로 100%의 Mehtod Test Coverage를 가짐Line 전체 코드는 4줄이며, 4줄 모두 테스트가 진행됐으므로 100%의 Line Test Coverage를 가짐Branch 전체 분기는 3곳이며(a가 x일 때, a가 X일 때, a가 그 외의 문자일 때), 2곳의 분기가 테스트됐으므로 66%의 Branch Test Coverage를 가짐.이처럼 테스트 커버리지를 높히기 위해서는 단순히 전체 메서드, 전체 코드 라인을 테스트하는 것 뿐만 아니라 가능한 모든 분기를 테스트하는게 필요하다.