본문 바로가기

Testing/Unit

[단위테스트] Spring Service 단을 테스트해보자

728x90

 

일반적인 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.  인프런 메타코딩 강좌 


https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-junit-%ED%85%8C%EC%8A%A4%ED%8A%B8

 

스프링부트 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

 

728x90