java, mockito를 활용한 대역 구현

Mockito

  • 저수준의 객체를 테스트하는 것은 어렵지 않다. 한 두개 정도의 대역이나 인자를 생성하고 주입하면 된다. 고수준의 객체를 유닛 테스트하는 것은 쉽지 않다. 고수준의 객체는 수많은 객체의 구성을 통해 완성된다. 객체의 갯수만큼 대역을 필요로 한다. 어느 순간 프로덕션 코드보다 더 많은 테스트 코드를 확인할지도 모른다.
  • 인프라스트럭처를 사용하는 테스트 역시 어렵다. 외부 API나 DB와 커넥션을 연결하고 테스트를 진행해야 한다. 스프링 컨텍스트를 로딩하고 DB나 API와 연결하느라 오랜 시간이 필요하다. 복잡한 로딩을 위하여 많은 컴퓨터 리소스를 소모한다.
  • Mockito는 복잡한 대역의 구현이나 외부 통신에 대한 부담감을 줄여준다.

스프링 컨텍스트를 로딩하고 DB와 커넥션을 맺는 통합 테스트

  • UserService#updateEmail를 테스트하고자 한다.
  • UserService에 UserDao를 DI한다.
  • UserService#updateEmail 프로세스는 다음과 같다.
    • UserDao에서 요청한 id가 있는지 여부를 select 한다.
    • 있으면 update 한다.
    • 없으면 예외를 던진다.
public class UserService {
    private final UserDao userDao;

    public UserService(UserDao userDao) {
        this.userDao = userDao;
    }

    public void changeEmail(String id, String email){
        if(userDao.countById(id) == 0) throw new IllegalArgumentException("존재하지 않는 아이디 입니다.");
        userDao.updateEmail(id, email);
    }
}

public interface UserDao {
    int countById(String id);
    void updateEmail(String id, String email);
    // 이하 CRUD 메서드 생략
}
  • 만약 이 상태에서 통합 테스트를 진행하면 어떻게 될까?
    • 스프링 컨텍스트를 띄운다.
    • UserDao을 로딩한다. db와 연결한다.
    • 오염의 여지가 있으므로 테스트하려는 user 테이블을 truncate 한다.
    • 테스트를 위한 레코드를 insert한다.
    • 준비가 끝났다. 본격적인 userService 테스트를 수행한다.
  • 예를 들면 다음과 같다. (아래 코드의 동작 여부는 확인하지 않았습니다.)
@SpringBootTest
public class UserServiceTest {

  @Autowired
  UserService userService;

  @Autowired
  UserDao userDao;

  @BeforeEach
  void setUp(){
    userDao.truncate(); // 테이블을 비운다.
  }

  @Test
  void success_change_email() {
    // 테스트 용 유저 "kim"을 insert한다.
    userDao.insert("kim", "abc@gmail.com");

    // 이메일 변경을 시도한다. 정상적인 요청이므로 성공한다.
    userService.changeEmail("kim", "kim@naver.com");

    // 성공 여부를 확인한다.
    User found = userDao.findById("kim");
    assertThat(found.getEmail()).isEqualTo("kim@naver.com");
  }
}

mockito와 유닛 테스트

  • mockito의 의존성 : implementation 'org.mockito:mockito-core:5.1.1' (gradle)
  • mockito를 사용하여 다음과 같이 코드를 작성하였다.
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.matches;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.mock;

public class UserServiceTest {
    @Test
    void success_change_email(){
        // UserDao 대역을 생성한다. userService에 주입한다. 
        UserDao userDao = mock(UserDao.class); // (1)
        UserService userService = new UserService(userDao);
        
        // userDao.countById을 1로 리턴한다. userService는 "userId"를 존재하는 회원으로 판단한다.
        given(userDao.countById(anyString())).willReturn(1); // (2)
        userService.changeEmail("userId", "user.id@naver.com");
      
        // userDao는 반드시 호출된다. 삽입된 인자는 "user.id@naver.com" 와 같다.
        then(userDao).should().updateEmail(anyString(), matches("user.id@naver.com"));
    }
}
  • (1) Mockito.mock 메서드는 특정 클래스의 대역을 생성한다.
    • 실제 DB와 연결되는 UserDao 구현체가 아닌 대역 객체를 생성한다.
    • 더이상 DB커넥션이 필요하지 않는다. 스프링 컨텍스트가 더는 필요 없다. @SpringBootTest를 제거한다.
  • (2) BDDMockito.given 메서드는 mock 객체의 특정 메서드에 대한 리턴값을 정의한다.
    • userDao.countById() 메서드를 실행할 경우 어떤 인자(anyString())를 삽입하든 언제나 1을 리턴한다.
  • (3) BDDMockito.then 메서드는 mock 객체의 스파이 역할을 수행한다.
    • should : userDao.updateEmail 메서드는 반드시 호출된다.
    • matches : 삽입된 인자는 “user.id@naver.com”와 같다.
    • assert를 수행한다. 위의 조건을 만족하지 못하면 테스트는 실패한다.
  • mockito를 사용하여 예외 상황에 대한 코드를 아래와 같이 작성하였다.
@Test
void does_not_exist_then_throw_ex(){
    UserDao userDao = mock(UserDao.class);
    UserService userService = new UserService(userDao);

    // 어떤 값을 입력하든 0을 리턴한다. 
    given(userDao.countById(anyString())).willReturn(0);
    
    // 존재하지 않는 회원의 이메일의 변경에 대해 예외로 응답한다.
    assertThatThrownBy(() -> userService.changeEmail("userId", "user.id@naver.com"))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage("존재하지 않는 아이디 입니다.");
}

mockito의 효용

  • mockito를 활용한 대역은 시간 및 자원 사용 측면에서 효과적이다.
    • DB 커넥션이 더는 필요 없다. UserDao의 객체를 띄우기 위한 스프링 컨텍스트 또한 더는 필요 없다.
    • 통합 테스트를 수행하지 않아 매우 빠르게 테스트를 수행한다.
  • userService는 자신이 할 역할만 관심을 가진다. 테스트의 초점이 userService로 집중된다.
    • userService#changeEmail는 userDao#updateEmail 메서드를 호출한다. 이때 userService의 역할은 userDao에 적합한 인자를 전달하는 것 이외에 없다. updateEmail 메서드의 정상동작 여부는 userService가 책임이 아니다. then은 스파이로서 userService가 userDao에 적절한 인자를 전달했는지의 여부만 테스트한다.
    • 통합테스트의 경우 로직의 수행 여부를 DB와의 쿼리를 통해 확인할 수밖에 없다. userDao.findById("kim"); 메서드는 명백하게 UserService가 테스트할 대상이 아니다. 하지만 정상 여부를 평가하기 위하여 불가피하게 findById 메서드를 호출한다.
  • 대역을 구현하지 않아도 된다.
    • 현재 UserDao는 메서드는 많지 않다. 대체로 UserService의 테스트에 활용되는 메서드이며 이를 대역으로 구현하는 것은 크게 부담스럽지 않다.
    • 하지만 userDao의 메서드가 10개 이상으로 늘어나는 상황을 상상할 수 있다. 그리고 어떤 메서드는 UserService의 테스트에 전혀 관계 없을 수도 있다. 하지만 인터페이스를 구현 클래스로 작성하기 위해서는 모든 메서드를 구현해야 한다. 필요 없는 대다수의 메서드를 구현해야 한다.
    • mockito는 그럴 필요가 없이 필요로한 메서드에 대한 가상의 응답값만 정의하면 된다.
  • 유닛 테스트마다 대역을 구현하거나 혹은 대역을 재활용하는 것이 더 나을 수도 있다. 통합테스트가 더 간단하고 명확할 수 있다. 하지만 잘 만들어진 메서드는 6줄을 넘지 않는다고 한다. 그런 메서드를 위한 mockito의 코드는 생각보다 길지 않을 수 있다. 은탄환이 될 수 없지만 유닛 테스트를 구현하는데 무척 큰 도움을 준다.
  • 최범균 개발자님의 ‘테스트 주도 개발 시작하기’ 를 참고하였습니다.