[Mock] 회원가입 유닛 테스트

2024. 1. 18. 19:06

들어가며

테스트를 실행하면서 겪었던 일들에 관해 기록하려 한다.

[그림1] AuthService 코드

회원가입 코드는 [그림1]과 같다. 우선 회원가입 기능은 다음과 같다.

  1. 회원가입시 프로필 이미지를 등록할 수 있다.
  2. 회원가입시 이메일이 유효한지 검증해야 한다.

위의 기능을 만족하는 회원가입 기능을 만들기 위해 테스트를 진행해보아야 한다. 2번 이메일 검증의 경우 이메일로 코드를 보내 확인하는 로직이 존재하기 때문에 기본적인 이메일 형식 유효성 검증만 하면 될 것 같다.


문제 상황

[그림2] 회원가입 로직

회원가입을 어떻게 테스트할 수 있을까? 우선 회원가입로직은 [그림2]과 같다. 요청이 들어오면 Email형식이 맞는지, 프로필 사진이 있는지 확인 후 DB에 저장하는 식으로 진행된다. 

따라서 회원가입 테스트는 두 부분을 확인하는 방향으로 가면 된다. 첫 번째는 이메일 형식이 유효하면 예외출력이 되는지를 확인한다. 두 번째는 회원가입시 프로필 사진이 있든 없든 회원정보가 DB에 저장이 되는지 확인하면 된다.

이메일 형식

이메일 형식이 유효한지 확인하는 부분이 있어야 한다. Tweaver 프로젝트의 경우 @Valid 외에 따로 검증하는 부분이 없다. 그 이유는 회원가입시 인증메일을 직접 받아서 인증코드를 입력하는 방식으로 인증을 통과해야 하기 때문이다. 

또 문제가 하나 있는데, 인증이 된 이메일은 체크를 해주어야 하는데 엔티티 설계할때 그부분을 누락했다. 그렇기 떄문에 이메일 형식 테스트는 인증여부를 확인할 수 있는 이메일 컬럼을 하나 추가해 준 뒤에 진행할 예정이다.

프로필 사진의 유무

프로필 사진이 있든 없든 확인하는 코드를 작성해봤다. 테스트하며 검증해볼 부분은 다음과 같다.

  1. signUp() 서비스가 호출되면 ImageService가 잘 작동 되는지 검증
  2. MemberRepository에 저장이 되는지 검증

첫 번째 시도

@SpringBootTest
@ActiveProfiles("test")
public class AuthServiceUnitTest {

  @Mock
  private MemberRepository memberRepository;

  @Mock // Mock ImageService
  private ImageService imageService;

  @InjectMocks
  private AuthService authService;


  @DisplayName("프사 있는 회원가입")
  @Test
  void signUpWithPicture() {
    // Given
    AuthDto.SignUpForm signUpForm = createSignUpForm();
    MockMultipartFile file = new MockMultipartFile("file", "file", "image/jpg", "file".getBytes());

    // When
    authService.signUp(signUpForm, file);
    when(imageService.uploadImageAndGetUrl(any(), any())).thenReturn("testURL");

    // Then
    verify(imageService, times(1)).uploadImageAndGetUrl(any(), any());
    verify(memberRepository, times(1)).save(any(Member.class));
  }

  private AuthDto.SignUpForm createSignUpForm() {
    AuthDto.SignUpForm form = new AuthDto.SignUpForm();
    form.setEmail("test@tweaver.com");
    form.setNickname("test");
    form.setPassword("1234test!");
    form.setGender("male");
    form.setAge(20);
    return form;
  }
}
java.lang.NullPointerException: Cannot invoke 
	"org.springframework.security.crypto.password.PasswordEncoder
    .encode(java.lang.CharSequence)" because "this.passwordEncoder" is null

정말 보기좋게 NPE가 떠버렸다. 서비스 로직에서 PasswordEncoder가 사용되는데 안써서 그렇다.

 

두 번째 시도

...
public class AuthServiceUnitTest {
  
    @Mock
    private BCryptPasswordEncoder passwordEncoder;  // 추가!
...

 

[그림3] 속도 차이

안써서 그렇다니까 passwordEncoder를 @Mock으로 만들어서 사용해주자. 그리고 @MockBean으로 만든다면 authService에 @InjectMocks가 아닌 @AutoWired를 써줘야 한다. 둘 다 해봤는데 @Autowired의 경우 82ms, @InjectMocks의 경우 57ms가 나왔다. @InjectMocks의 경우 Spring Context에서 빈을 찾지 않기 때문에 속도가 더 빠르다. 

 

세 번째 시도

@SpringBootTest
@ActiveProfiles("test")
public class AuthServiceUnitTest {

  @Mock
  private MemberRepository memberRepository;

  @Mock
  private BCryptPasswordEncoder passwordEncoder;

  @Mock // Mock ImageService
  private ImageService imageService;

  @InjectMocks
  private AuthService authService;

  @DisplayName("프사 있는 회원가입 성공")
  @Test
  void signUpWithPicture() {
    // Given
    AuthDto.SignUpForm signUpForm = createSignUpForm();
    MockMultipartFile file = new MockMultipartFile("file", "file",
        "image/jpg", "file".getBytes());

    // When
    authService.signUp(signUpForm, file);
    when(imageService.uploadImageAndGetUrl(file, ImageType.PROFILE)).thenReturn("testURL");

    // Then
    verify(passwordEncoder, times(1)).encode(signUpForm.getPassword());
    verify(imageService, times(1)).uploadImageAndGetUrl(file, ImageType.PROFILE);
    verify(memberRepository, times(1)).save(any(Member.class));
  }

  private AuthDto.SignUpForm createSignUpForm() {
    AuthDto.SignUpForm form = new AuthDto.SignUpForm();
    form.setEmail("test@tweaver.com");
    form.setNickname("test");
    form.setPassword("1234test!");
    form.setGender("male");
    form.setAge(20);
    return form;
  }
}
// 에러!
Argument(s) are different! Wanted: passwordEncoder.encode(null); 
	-> at com.valuewith.tweaver.auth.service.AuthServiceUnitTest.signUpWithPicture(AuthServiceUnitTest.java:55) 
Actual invocations have different arguments: passwordEncoder.encode("1234test!"); 
	-> at com.valuewith.tweaver.auth.service.AuthService.signUp(AuthService.java:60)

테스트는 성공적으로 완료할 수 있었다. 하지만 테스트를 명확히 표현하기 위해 any()를 지우고 무엇을 사용하는지 명시적으로 표현해줬다. 그랬더니 위의 에러를 뱉었다. 

이 문제를 찾아보니 Mockito가 호출된 시점의 상태를 기억하기 때문이라고 한다. signUpForm은 실제 signUp 메서드가 호출되는 'When' 단계에서 getPassword 메서드를 호출하게 된다. 하지만 'Then' 단계에서 signUpForm.getPassword()를 또다시 호출한다. signUp 메서드가 호출된 후에 또 다시 호출했기 때문에 Mockito는 이를 첫 번째 getPassword 호출과는 별개로 간주한다. 따라서 'Then' 단계의 verify(...).encode()에서는 null 값으로 들어오게 된다.

이러한 이유로 결과를 미리 저장해 두고 이 값을 encode() 메서드에 전달하는 방법으로 테스트를 진행해보았다. 결과는 성공이었다!


최종결과

@DisplayName("프사 있는 회원가입 성공")
@Test
void signUpWithPicture() {
    // Given
    AuthDto.SignUpForm signUpForm = createSignUpForm();
    MockMultipartFile file = new MockMultipartFile("file", "file",
        "image/jpg", "file".getBytes());
    String formPassword = signUpForm.getPassword();

    // When
    authService.signUp(signUpForm, file);

    // Then
    verify(passwordEncoder, times(1)).encode(formPassword);
    verify(imageService, times(1)).uploadImageAndGetUrl(file, ImageType.MEMBER);
    verify(memberRepository, times(1)).save(any(Member.class));
}

몇 줄뿐인 코드지만 생각보다 테스트하는데 정말 많은 시간이 소요됐다. 결과적으로 생각했던 그대로 코드를 구현할 수 있었다. imageService의 메서드가 잘 작동하고 저장이 됐다. 추가적으로 passwordEncoder도 signUp 로직상 구현해야 한다는 사실도 알 수 있었다.

어떤식으로 테스트를 작성했는지 기록해 나를 포함, 우리에게 도움이 될 수 있으면 좋겠다. 모르니까 무서운거지 부딪히다 보면 할 수 있다는 걸 깨달았다!

BELATED ARTICLES

more