일반적인 Domain 에서 Entity 생성을 Test 해보기 위한 과정을 담아보자 (출처 하단). 간단한 예제를 실무에서 사용하는 방식과 최대한 엮어본 듯 했다. 참고로 Repository 단은 단순 Data Jpa 사용했고, 필요한 필드에 대해서 네임드 쿼리 생성되도록 해주었다.
우선 User 는 다음과 같다
1. User.java
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String password;
private String email;
private String fullName;
@Enumerated(value = EnumType.STRING)
private UserEnum role; // ADMIN, CUSTOMER
@Builder
public User(Long id, String username, String password, String email, String fullName, UserEnum role) {
this.id = id;
this.username = username;
this.password = password;
this.email = email;
this.fullName = fullName;
this.role = role;
}
}
일반적인 RequestDto 와 ResponseDto 도 필요 정보들에 대하여 만들어 줄 수 있었다
2. UserJoinReqDto.java
@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class UserJoinReqDto {
private String username;
private String password;
private String email;
private String fullName;
}
3. UserJoinRespDto.java
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class UserJoinRespDto {
private Long id;
private String username;
private String fullName;
public UserJoinRespDto(User user) {
this.id = user.getId();
this.username = user.getUsername();
this.fullName = user.getFullName();
}
}
다음과 같이 Service 코드를 만들어볼 수 있었다. Service 단 테스트의 관례적인 모습을 적기 위한 예시이니, 간단히 생성 메소드만 적용해보자
4. UserService.java
@Service
@RequiredArgsConstructor
public class UserService {
private final Logger log = LoggerFactory.getLogger(getClass());
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Transactional // 트랜젝션 보장
public UserJoinRespDto save(UserJoinReqDto requestDto) {
// 동일 유저 네임 존재 검사
Optional<User> optionalUser = userRepository.findByUsername(requestDto.getUsername());
if (optionalUser.isPresent()) {
// 유저 네임 중복
throw new CustomApiException("동일한 username 이 존재합니다"); // RestControllerAdvice 에서 잡아내도록 세팅해둠
}
// 패스워드 인코딩 후 저장
User preUser = User.builder()
.username(requestDto.getUsername())
.password(passwordEncoder.encode(requestDto.getPassword()))
.email(requestDto.getEmail())
.fullName(requestDto.getFullName())
.role(UserEnum.CUSTOMER)
.build();
userRepository.save(preUser); // 영컨 등록
// DTO 응답 전달
return new UserJoinRespDto(preUser);
}
}
정말 특별할거 없는 일반적인 회원가입 예제 로직이다. 참고로 에러를 제어하기 위해 다음과 같이 ExceptionHandler 를 추가해주었다. (CustomApiException.java 는 단순 RuntimeException 상속 받아 사용하는 Exception)
5. handler > CustomExceptionHandler.java
@RestControllerAdvice // Exception Handling 하는 공간
public class CustomExceptionHandler {
private final Logger log = LoggerFactory.getLogger(getClass());
@ExceptionHandler(CustomApiException.class) // 해당 Exception 터질시 일로 옴
public ResponseEntity<ResponseDto<Object>> apiExection(CustomApiException cutomApiException) {
log.error(cutomApiException.getMessage());
return new ResponseEntity<>(new ResponseDto<>(-1, cutomApiException.getMessage(), null), HttpStatus.BAD_REQUEST);
}
}
(Status Code 도 전달해주기 위해 ResponseEntity 로 엮어주었다)
이제 정상적인 동작성을 위해 Test 를 해보는데, Service Test 는 Mocking 환경을 많이 사용한다. Mocking 을 사용하는 이유는 잘 정리된 블로그들이 많으니 더 참고하길 바란다. 요약하면 다음과 같다.
1. 테스트 환경 격리 - 테스트 하려는 객체 외의 객체들의 변경에 의해 해당 테스트가 영향을 받지 않도록 한다
2. Mocking 을 사용하면 다양한 시나리오를 손쉽게 시뮬레이션 할 수 있다
3. Test 코드 내 외부적인 API or 불필요한 의존성 누수를 방지할 수 있다
4. 공유 자원을 사용하지 않게끔 하여 안정적으로 테스트가 가능하다
그리고 이번 Service Test 를 하면서 알아둬야할 것들을 살펴보자
@ExtendWith(MockitoExtension.class) - JUnit 5 에서 Mockito 환경에서 테스트 함. Mockito 기능 활성화 (Service 단에서 많이 활용)
@AutoConfigureMockMvc - 이것 역시 Mocking 환경을 활성화하기 위함이지만, 단위테스트가 아닌 SpringBootTest 단으로 가서 Http 요청까지 Mocking 할 경우 사용됨 (Controller 단에서 많이 활용)
@InjectMocks - Test 대상인 객체에 주입 필요한 객체들을 Mock 환경에서 @Mock 되어 있는 객체를 찾아 주입
@Mock - Test 대상 외 활용이 필요한 객체들을 가짜 객체로 생성
@Spy - 내가 Mocking 하려는 것은 Mocking 하되, 진짜 로직을 수행하도록 하고 싶은건 내버려 둘 수 있는도록 함 (테스트의 일부분만 Mocking)
Spy 에 대해 조금 부가적으로 설명하자면 .. Spy Annotation 이 달려 있는 객체는 내가 Mock 활성화를 하지 않은 함수들은 정상적으로 작동한다고 보면 된다. 가령, InjectMock 을 한 객체에서 Test 하려는 Method 내에 있는 다른 private 한 Method 의 동작을 doNothing() 따위로 제어가 필요할 때가 있다. 혹은 아예 Mocking 을 사용하지 않고 진짜 객체를 사용하고자 할 때 사용 가능하기도 하다. 더 자세한 설명은 아래 참고한 블로그에 잘 정리되어 있으니 참고하자.
6. UserServiceTest.java
@ExtendWith(MockitoExtension.class)
public class UserServiceTest {
@InjectMocks
private UserService userService; // Test 대상
@Mock
private UserRepository userRepository; // 대상을 위한 의존성 주입 필요 객체
@Spy
private BCryptPasswordEncode passwordEncoder;
@Test
void test_회원가입() throws Exception {
// given (보통 들어가는 Param)
UserJoinReqDto requestDto = new UserJoinReqDto(
"회원이름", "1234", "hello@a.com", "김상수"
);
// given - stub1 - stub 이란 가정법을 말함
// any -> 뭐라도 들어간다면 텅빈 Optional 객체가 전달된다
when(userRepository.findByUsername(any())).thenReturn(Optional.empty());
// given - stub2
User ssar = User.builder()
.username("회원이름")
.password("1234")
.email("hello@a.com")
.fullName("김상수")
.role(UserEnum.CUSTOMER)
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
when(userRepository.save(any())).thenReturn(ssar);
//when
UserJoinRespDto responseDto = userService.save(requestDto);
//then
assertThat(responseDto.getUsername()).isEqualTo("회원이름");
assertThat(responseDto.getFullName()).isEqualTo("김상수");
}
}
Service 단은 위와 같이 의존하고 있는 객체들에 대해선 stub 를 진행해주고, Service 단의 비즈니스 로직이 잘 통과되는지를 확인한다. @Spy, @Mock 등의 에노테이션을 잘 활용하여 여러가시 시나리오를 제시할 수 있다. 가령, 위에서 userRepository 조회 결과가 있다고 한다면, 중복 회원이 있다는 경우이므로 다음과 같이 짤 수 있다.
@Test
void test_회원가입2(){
//given
UserJoinReqDto requestDto = new UserJoinReqDto("회원이름", "1234", "hello@a.com", "김상수");
when(userRepository.findByUsername(any())).thenReturn(Optional.of(new User())); // 중복 유저가 있음
//then
assertThatThrownBy(() -> userService.save(requestDto)).isInstanceOf(CustomApiException.class);
}
근데 모든 테스트를 이런식으로 하드 코딩 생성 방식으로 하면 당연히 TC 가 매우 더러워질 수 있다. TC 창도 유지 보수의 대상이기 때문에, 최대한 간결하고, 나중에 내가 봐도 부담이 없도록 짜는게 매우 중요하다. 따라서 다음과 같이 DummyObjectCreator.java 를 만들어 DummyObject 를 간결하게 만들 수 있는 방안을 V1으로 제시한다.
7. config > dummy > DummyObjectCreator.java
public class DummyObjectCreater {
protected User newMockUser(Long id, String username, String fullName) {
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
String encPassword = passwordEncoder.encode("1234");
return User.builder()
.id(id)
.username(username)
.password(encPassword)
.email(username + "@hello.com")
.fullName(fullName)
.role(UserEnum.CUSTOMER)
.build();
}
}
이렇게 한후 ServiceTestClass 가 DummyObjectCreator.java 를 상속받게 하면 다음과 같이 조금은 간결하게 짜질 수 있을 것이다.
@Test
void test_회원가입() throws Exception {
// given (보통 들어가는 Param)
UserJoinReqDto requestDto = new UserJoinReqDto(
"회원이름", "1234", "회원이름@hello.com", "김상수"
);
// given - stub1 - stub 이란 가정법을 말함
// any -> 뭐라도 들어간다면 텅빈 Optional 객체가 전달된다
when(userRepository.findByUsername(any())).thenReturn(Optional.empty());
// given - stub2
User ssar = newMockUser(1L, "회원이름", "김상수");
when(userRepository.save(any())).thenReturn(ssar);
//when
UserJoinRespDto responseDto = userService.save(requestDto);
//then
assertThat(responseDto.getUsername()).isEqualTo("회원이름");
assertThat(responseDto.getFullName()).isEqualTo("김상수");
}
@BeforeEach 를 사용해서 일괄적으로 생성, Mock 객체 stub 관리 등을 해줘도 된다. 각자의 스타일에 맞게 시나리오 세팅은 간결하게 짜면 되는 부분인 것 같다. 다만, 이 부분이 너무 길어지면 정말 보기 안좋고, 이게 맞나 싶을 정도의 생각이 든다. THEN 이 중심이 되어야 하는데, GIVEN 만 주구장창 만드는 느낌... 좋은 Test 가 아닌 느낌도 든다. 어찌보면 Service 단이 너무 많은 일을 한다는 증거일 수도..!
Test 를 짜면 정말 많은 생각이 들고 많은 Refactoring 이 하고 싶고.. 그런 것 같다.
어찌보면 당연한 상황을 만드는 단위 테스트지만, 막상 하려고 하면 잘 안되는게 단위 테스트인 것 같다. 이렇게 예제를 하나 하나씩 살펴보다 보면 언젠간 자연스럽게 테스트를 구상하고 좋은 시나리오들을 만들어 낼 수 있도록 발전할 날이 오지 않을까..
* 모든 출처
1. 인프런 메타코딩 강좌
스프링부트 JUnit 테스트 - 시큐리티를 활용한 Bank 애플리케이션 - 인프런 | 강의
스프링 부트(Spring Boot)로 은행 애플리케이션을 개발해 봅니다. 개발을 하면서 발생하는 여러 문제들을 하나씩 직접 제이유닛(JUnit)으로 테스트해 보면서 스스로 고민하고 애플리케이션을 구축할
www.inflearn.com
2. Spy 에 대한 블로그
https://jojoldu.tistory.com/239
Spy 사례1 - 테스트 대상 Mocking 하기
안녕하세요? Spy의 활용 사례 1번째를 소개드립니다. 모든 코드는 Github에 있기 때문에 함께 보시면 더 이해하기 쉬우실 것 같습니다. (공부한 내용을 정리하는 Github와 세미나+책 후기를 정리하는
jojoldu.tistory.com
'Testing > Unit' 카테고리의 다른 글
[단위테스트] Unit Test 를 통해 함수를 Refactoring 해보자 (0) | 2023.09.11 |
---|---|
[단위테스트] Mocking 을 사용한 단위테스트의 관점에 대하여 (0) | 2023.08.01 |