새소식

Java

[Java] Default 접근제어자로 견고한 테스트 만들기

  • -

 

요즘 아주 재미있게 즐겨보고 있는 이중석님의 유튜브입니다.

올라온 당일날 봤었는데, 이번에 리팩토링을 진행하면서 딱 좋은 케이스가 생겨서 적용해봤습니다.

 

 

기존 코드는 어땠는가

간소화해서 가져왔지만, 리팩토링 전의 코드기도 하고 Login 로직은 쬐끔 복잡합니다.

  • OAuth provider 확인해서 적절한 bean 꺼내오기
  • (외부 통신) OAuth provider에 요청 보내서, 유효한 로그인인가에 대한 검증 진행하기
  • 회원가입 한 적이 없다면 DB에 유저 생성하기
  • (외부 통신) FCM 토큰 검증하고 저장하기

 

@Override
public Long login(String provider, LoginRequestDto requestDto) {
    OAuthProviderEnum providerEnum = OAuthProviderEnum.codeOf(provider);
    OAuthService oAuthService = oAuthServiceMap.get(providerEnum);
    OAuthUserInformation userInformation = oAuthService.requestUserInformation(requestDto.token());
    User user = getOrCreateUser(userInformation);
    if (requestDto.fcmDeviceToken() != null) {
        notificationService.registryFcmDeviceToken(user, requestDto.fcmDeviceToken());
    }
    return user.getId();
}

private User getOrCreateUser(OAuthUserInformation userInformation) {
    User user = null;
    Optional<User> target = userRepository.findByProviderAndProviderId(userInformation.getProvider(), userInformation.getProviderId());

    if (target.isPresent()) {
        log.info("UserServiceImpl|login(기존 회원): {}", userInformation);
        user = target.orElseThrow(() -> new PhochakException(ResCode.NOT_FOUND_USER));
    } else {
        log.info("UserServiceImpl|login(신규 회원): {}", userInformation);
        String nickname = generateInitialNickname();

        User newUser = User.builder()
                .provider(userInformation.getProvider())
                .providerId(userInformation.getProviderId())
                .nickname(nickname)
                .profileImgUrl(userInformation.getInitialProfileImage())
                .build();

        user = userRepository.save(newUser);
    }
    return user;
}

 

기존에 작성되어 있던 테스트를 보겠습니다.

@Test
@DisplayName("로그인 시 신규 회원이면 회원가입이 호출된다")
void login_newUser() {
    // given
    String code = "testCode";
    String providerId = "testProviderId";

    given(oAuthServiceMap.get(providerEnum)).willReturn(kakaoOAuthService);
    given(kakaoOAuthService.requestUserInformation(code)).willReturn(userInformation);
    given(userRepository.findByProviderAndProviderId(providerEnum, providerId)).willReturn(Optional.empty());
    given(userRepository.save(any())).willReturn(user);

    // when
    userService.login("kakao", code);

    // then
    then(userRepository).should(atLeastOnce()).save(any());
}

@Test
@DisplayName("로그인 시 기존 회원이면 회원가입이 호출되지 않는다")
void login_alreadyUser() {
    // given
    String code = "testCode";
    String providerId = "testProviderId";

    given(oAuthServiceMap.get(providerEnum)).willReturn(kakaoOAuthService);
    given(kakaoOAuthService.requestUserInformation(code)).willReturn(userInformation);
    given(userRepository.findByProviderAndProviderId(providerEnum, providerId)).willReturn(Optional.of(user));

    // when
    userService.login("kakao", code);

    // then
    then(userRepository).should(never()).save(any());
}

 

기존 코드는 무엇이 문제일까?

사실 우리가 검증하고 싶은 핵심 로직은 getOrCreateUser() 매서드의 "해당 유저가 이미 가입한 유저인가?" 입니다.

 

그렇지만 getOrCreateUser() 매서드가 Private 이기 때문에 상위 매서드를 테스트 하고 있고, 상위 테스트는 너무나도 많은 의존성을 걸치고 있습니다.

 

그래서 위의 코드를 보면 외부 통신이나 불필요한 객체 등에 대해서 mocking 을 진행하고 있습니다.

  • 이후 Login 관련 로직의 변경으로 테스트가 깨질 확률이 높습니다.
  • @Mock 으로 인한 테스트 클래스 자체의 선언부가 길어집니다
  • 각 테스트에서의 'given' 절에 불필요한 mocking 때문에 가독성이 떨어집니다.
  • 어떤 로직을 테스트 하고 싶은지가 코드 상으로 명확하지 않습니다.

 

 

Default 생성자를 고려하자

위의 영상에서 소개하는 item은 "로직을 default 매서드로 분리하고, 동일한 패키지에서 테스트하자." 입니다.

 

그러면 getOrCreateUser() 를 default(package private) 로 선언하고 테스트를 진행해볼까요?

User getOrCreateUser(OAuthUserInformation userInformation) { ... }
@Test
@DisplayName("로그인 시 신규 회원이면 회원가입이 호출된다")
void login_newUser() {
    // given
    given(userRepository.findByProviderAndProviderId(any(), any())).willReturn(Optional.empty());

    // when
    authService.getOrCreateUser(userInformation);

    // then
    then(userRepository).should(atLeastOnce()).save(any());
}

@Test
@DisplayName("로그인 시 기존 회원이면 회원가입이 호출되지 않는다")
void login_alreadyUser() {
    // given
    given(userRepository.findByProviderAndProviderId(any(), any())).willReturn(Optional.of(user));

    // when
    authService.getOrCreateUser(userInformation);

    // then
    then(userRepository).should(never()).save(any());
}

 

 

깔-끔

테스트가 짧아진 것도 좋지만, 더 중요한 것은 테스트가 명확해졌다는 점입니다.

해당 코드를 설명하는 필수적인 mocking 만 given 절에 나타내고, 필요한 로직만을 검증하고 있습니다.

 

또한 상위 public 매서드의 변경으로 인해 테스트가 깨지지 않게 됩니다.

좀 더 지속가능하고 유연한 테스트가 될 수 있을 것 같습니다.

 

정리

default 접근제어자를 통해 캡슐화를 최대한 지키면서 명확하고 견고한 테스트를 만들 수 있었습니다.

 

그러고보니 평소에 default 접근제어자에 대해서 무관심했고 의도적으로 사용해본 경험이 없었던 것 같네요.

 

감사합니다.

 

 

 


위의 방법 도입 후 5일 뒤 후기

정확하게 게시글을 작성하고 5일 뒤에 변경사항이 생겼고 위의 코드가 의도대로 작동해서 신나게 가져왔습니다 ㅎㅎㅎ .

 

 

두 번째 리팩토링에서 해당 프로젝트를 핵사고날 아키텍처로 전환하고 싶어서 다음 요구사항이 있었습니다.

 

  • JPA 엔티티와 도메인 객체를 구분한다.
  • 객체 Converter(Mapper)를 활용해서 두 객체를 전환한다.
  • 기존 OAuthService 를 기능으로 정의하고, port adapter 패턴으로 전환한다.

 

따라서 위의 로직을 CreateUserApdater 로 이관하고 Public으로 열어두는 작업을 진행했습니다.

 

그래서 바뀐 코드는 다음과 같습니다. (조금의 코드 정리도 진행했습니다)

@Override
public User getOrCreateUser(final OAuthUserInformation userInformation) {
    final Optional<UserEntity> target = userRepository.findByProviderAndProviderId(userInformation.getProvider(), userInformation.getProviderId());
    if (target.isPresent()) {
        log.info("CreateUserAdapter|getOrCreateUser(기존 회원 로그인): {}", userInformation);
        return userMapper.toDomain(target.get());
    } else {
        log.info("CreateUserAdapter|getOrCreateUser(신규 회원 가입): {}", userInformation);
        final UserEntity userEntity = userRepository.save(new UserEntity(
                userInformation.getProvider(),
                userInformation.getProviderId(),
                generateInitialNickname(),
                userInformation.getInitialProfileImage()
        ));
        return userMapper.toDomain(userEntity);
    }
}


분명 상위 매서드(UserService.createUser)의 로직이 바뀌게 되었고 getOrCreateUser의 자잘한 내부 구현이 바뀌게 되었습니다.

 

그러나 다음 이유 때문에 테스트의 로직은 깨지지 않았습니다.

  • getOrCreateUser 를 별개로 테스트했다.
  • 테스트하려고 했던 핵심 로직은 변하지 않았다.

 

기존 테스트였다면 저는 5일만에 테스트코드를 새로 작성하고 있었겠죠?

 

배운 내용을 코드에 적용하고, 빠르게 피드백을 받을 수 있는 경험이었습니다.

감사합니다.

 

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.