e-commerce 대용량 서버 프로젝트를 진행하면서 테스트 코드에 대해 공부하다 @SpringBootTest
를 사용하는 테스트와 Mockito
라이브러리로 구성된 테스트의 차이가 궁금하였습니다. 이를 알아보던 중, 단위테스트에서는 @SpringBootTest
를 지양하는 것이 권장됨을 깨달았습니다.
@SpringBootTest
는 SpringApplication(@SpringBootApplication
)을 통해 테스트에서 사용할 ApplicationContext를 쉽게 생성할 수 있습니다. 즉, @SpringBootApplication
이 붙은 애너테이션을 찾아 하위의 모든 빈들을 scan합니다. 결국 애플리케이션에 정의된 모든 빈을 생성하기 때문에 실행 시 느려질 수 있어 통합테스트가 아닌 단위테스트에서는 적합하지 않습니다.
@MockBean
은 Spring ApplicationContext에 Mock객체를 빈으로 등록하는데 사용되는 애너테이션입니다. 즉, Spring영역에 있는 애너테이션이기 때문에 Spring Context를 실행해야합니다. 그렇기에 @Autowired
처럼 스프링이 제공하는 의존성 해결방법으로만 해당 Mock객체를 찾을 수 있어, 주로 @SpringBootTest
이나 @WebMvcTest
와 같이 사용될 수 있습니다.
✅ 공식문서를 참고해보니 구동에 필요한 거의 모든 빈들을 로딩하는
@SpringBootTest
와는 달리@WebMvcTest
는 요청을 처리하기 위한 빈들로 제한하여 로딩합니다. 따라서@WebMvcTest
를 사용할 수 있다면 보다 더 가볍게 테스트할 수 있습니다.
또한 @MockBean
과 같은 애너테이션을 사용하지 않고 테스트 코드를 작성하는것이 힘들다면 너무 많은 의존성을 가진 냄새나는 코드가 아닌지 의심해보아야 할 것 입니다.
해당 프로젝트는 다음 github에서 확인 가능합니다.
https://github.com/f-lab-edu/online-marketplace
처음에 저희 프로젝트의 UserServiceTest클래스는 다음과 같은 형식으로 작성되었었습니다.
@ExtendWith(MockitoExtension.class)
@SpringBootTest
public class UserServiceTest {
@Autowired
private UserService userService;
@MockBean
private UserRepository userRepository;
@DisplayName("중복되지 않은 이메일이면 회원가입에 성공한다.")
@Test
void signUp() {
// given
final SignUpRequestDto dto = SignUpRequestDto.builder()
.name(User1.NAME)
.email(User1.EMAIL)
.password(User1.PASSWORD)
.phone(User1.PHONE)
.build();
final Optional<User> notFoundUser = Optional.ofNullable(null);
given(userRepository.findByEmail(any())).willReturn(notFoundUser);
// when
userService.join(dto);
// then
then(userRepository).should(times(1)).insertUser(any());
}
...
위에서 언급한 것과 같이 @SpringBootTest
를 사용하게 되면 어플리케이션에 정의된 모든 빈을 생성하므로 단위테스트로서는 무겁습니다.
해당 단위테스트의 관심사는 UserService내의 join()메소드입니다.
이 메소드의 하위 메소드를 호출하여 원하는 결과값을 얻는지에 초점을 맞추면 됩니다.
public void join(SignUpRequestDto dto){
if (checkIsUserExist(dto.getEmail())) {
throw new IllegalArgumentException("이미 등록된 메일입니다.");
}
String salt = SaltGenerator.generateSalt();
CryptoData cryptoData = CryptoData.WithSaltBuilder()
.plainText(dto.getPassword())
.salt(salt)
.build();
String encryptedPassword = encryptor.encrypt(cryptoData);
User user = dto.toEntity(salt, encryptedPassword);
userRepository.insertUser(user);
}
해당 메소드를 보면, 결국에는 insertUser()
이 호출하는 것이 목적인 것을 알 수 있습니다. 그 과정에서 필요한 Mocking 대상은 UserRepository
와 Encryptor
에 대한 것입니다.
Mockito
를 활용하면 해당 클래스에 필요한 mock객체를 생성하여 의존성을 해결할 수 있습니다.
Mockito.mock()
을 이용할 수 있지만, 저는 좀 더 간결한 @Mock
을 사용하여 mock객체를 생성해보려고 합니다. 그 후, @InjectMocks
로 지정한 클래스안에 정의된 mock객체를 찾아 클래스의 객체가 만들어질 때 사용하여 주입하도록 작성하였습니다.
✅ JUnit5에서는
@Mock
이@ExtendWith(MockitoExtension.class)
와 함께 사용되어야 테스트가 시작하기 전에 애너테이션을 감지할 수 있습니다.
@ExtendWith(MockitoExtension.class)
public class UserServiceTest {
@InjectMocks
private UserService userService;
@Mock
private UserRepository userRepository;
@Mock
private Encryptor encryptor;
@DisplayName("중복되지 않은 이메일이면 회원가입에 성공한다.")
@Test
void signUp() {
// given
final SignUpRequestDto dto = SignUpRequestDto.builder()
.name(User1.NAME)
.email(User1.EMAIL)
.password(User1.PASSWORD)
.phone(User1.PHONE)
.build();
final Optional<User> notFoundUser = Optional.ofNullable(null);
given(userRepository.findByEmail(any())).willReturn(notFoundUser);
// when
userService.join(dto);
// then
then(userRepository).should(times(1)).insertUser(any());
}
}
통합테스트와 단위테스트가 지닌 목적을 구분하여 쓰임에 맞는 자료를 정하는 것이 테스트 성능을 높이는데 도움이 되는 것을 확인할 수 있었습니다. 또한 테스트코드가 작성되기 힘들다면 나쁜냄새를 발견하고 개선할 수 있을 것입니다.
https://docs.spring.io/spring-boot/docs/1.5.2.RELEASE/reference/html/boot-features-testing.html
https://docs.spring.io/spring-boot/docs/current/api/org/springframework/boot/test/mock/mockito/MockBean.html
https://docs.spring.io/spring-boot/docs/current/reference/html/features.html#features.testing.spring-boot-applications.spring-mvc-tests
https://jojoldu.tistory.com/320