반응형

저번 시간에는, 토큰을 발급하고 검증하기 위한 JwtHandler를 작성 및 테스트하고, 비밀번호 암호화를 위해 PasswordEncoder를 테스트해보았습니다.

이번 시간에는, 이를 활용하여 로그인과 회원가입을 수행하는 서비스 로직을 작성해보도록 하겠습니다.

 

 

먼저 service.sign 패키지를 만들고, SignService 클래스를 작성해보겠습니다.

전체 소스코드는 다음과 같습니다.

package kukekyakya.kukemarket.service.sign;

import kukekyakya.kukemarket.dto.sign.SignInRequest;
import kukekyakya.kukemarket.dto.sign.SignInResponse;
import kukekyakya.kukemarket.dto.sign.SignUpRequest;
import kukekyakya.kukemarket.entity.member.Member;
import kukekyakya.kukemarket.entity.member.RoleType;
import kukekyakya.kukemarket.exception.*;
import kukekyakya.kukemarket.repository.member.MemberRepository;
import kukekyakya.kukemarket.repository.role.RoleRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class SignService {

    private final MemberRepository memberRepository;
    private final RoleRepository roleRepository;
    private final PasswordEncoder passwordEncoder;
    private final TokenService tokenService;

    @Transactional
    public void signUp(SignUpRequest req) {
        validateSignUpInfo(req);
        memberRepository.save(SignUpRequest.toEntity(req,
                roleRepository.findByRoleType(RoleType.ROLE_NORMAL).orElseThrow(RoleNotFoundException::new),
                passwordEncoder));
    }

    public SignInResponse signIn(SignInRequest req) {
        Member member = memberRepository.findByEmail(req.getEmail()).orElseThrow(LoginFailureException::new);
        validatePassword(req, member);
        String subject = createSubject(member);
        String accessToken = tokenService.createAccessToken(subject);
        String refreshToken = tokenService.createRefreshToken(subject);
        return new SignInResponse(accessToken, refreshToken);
    }

    private void validateSignUpInfo(SignUpRequest req) {
        if(memberRepository.existsByEmail(req.getEmail()))
            throw new MemberEmailAlreadyExistsException(req.getEmail());
        if(memberRepository.existsByNickname(req.getNickname()))
            throw new MemberNicknameAlreadyExistsException(req.getNickname());
    }

    private void validatePassword(SignInRequest req, Member member) {
        if(!passwordEncoder.matches(req.getPassword(), member.getPassword())) {
            throw new LoginFailureException();
        }
    }

    private String createSubject(Member member) {
        return String.valueOf(member.getId());
    }
}

각각의 사항에 대해 세부적으로 살펴보겠습니다.

@Service
@RequiredArgsConstructor // 1
@Transactional(readOnly = true) // 2
public class SignService {

    private final MemberRepository memberRepository; // 3
    private final RoleRepository roleRepository; // 4
    private final PasswordEncoder passwordEncoder; // 5
    private final TokenService tokenService; // 6
    ...

1. @RequiredArgsConstructor를 클래스 레벨에 선언하면, final로 선언된 인스턴스 변수들로 생성자를 만들어줍니다. 또한, 생성자에 선언된 필드들에는 자동으로 스프링 빈이 주입됩니다. 이에 대한 내용은 더 이상 언급하지 않겠습니다.

2. 하나의 메소드를 하나의 트랜잭션으로 묶어주기 위해 @Transactional을 선언해줍니다. 읽기만 수행한다면, readOnly를 true로 설정해줌으로써 성능 이점을 얻을 수 있지만, 그 외의 작업을 수행한다면 해당 메소드에 @Transactional을 다시 선언하여 해당 옵션을 해제해야합니다. 이에 대한 내용은 더 이상 언급하지 않겠습니다.

3. 사용자 조회 및 등록 등의 기능을 수행하기 위해 필요합니다.

4. 사용자를 가입 시킬 때, 기본적인 권한을 등록해주기 위해 필요합니다.

5. 비밀번호를 암호화하기 위해 필요합니다.

6. 우리의 서비스에 알맞도록 액세스 토큰과 리프레시 토큰을 발급해줍니다. 이에 대한 내용은 아래에서 다시 살펴보겠습니다.

 

 

이제 각 메소드를 살펴보겠습니다.

 

먼저 회원가입을 처리하는 signUp 메소드입니다.

@Transactional
public void signUp(SignUpRequest req) {
    validateSignUpInfo(req);
    memberRepository.save(SignUpRequest.toEntity(req, // 1
            roleRepository.findByRoleType(RoleType.ROLE_NORMAL).orElseThrow(RoleNotFoundException::new),
            passwordEncoder));
}

private void validateSignUpInfo(SignUpRequest req) {
    if(memberRepository.existsByEmail(req.getEmail())) // 2
        throw new MemberEmailAlreadyExistsException(req.getEmail());
    if(memberRepository.existsByNickname(req.getNickname())) // 3
        throw new MemberNicknameAlreadyExistsException(req.getNickname());
}

파라미터로 전달 받은, 회원가입 정보를 검증합니다.

여기에서는 이메일과 닉네임의 중복성을 검사하고, 주어진 SingUpRequest를 Entity로 변환해줍니다.

이 때, 변환 과정에서 기본 권한 설정을 의미하는 Role과 비밀번호 암호화를 위한 PasswordEncoder도 함께 전달해줍니다.

1. SignUpRequest의 필드들을 getter로 호출하여 서비스 로직 내에서 엔티티로 변환해줄 수도 있겠지만, 코드의 간결함과 가독성을 위해 SignUpRequest의 메소드에서 의존성이 더 생기더라도 인자로 넘겨주는 방식을 취하게 되었습니다.

2~3. 이메일이나 닉네임의 중복을 검증하고, 중복이 있다면 런타임 예외를 발생시켜줍니다.

 

위 코드에서 작성된 SignUpRequest 클래스는,

package kukekyakya.kukemarket.dto.sign;

import kukekyakya.kukemarket.entity.member.Member;
import kukekyakya.kukemarket.entity.member.Role;
import kukekyakya.kukemarket.entity.member.RoleType;
import lombok.AllArgsConstructor;
import lombok.Data;
import org.springframework.security.crypto.password.PasswordEncoder;

import java.util.List;

@Data
@AllArgsConstructor
public class SignUpRequest {
    private String email;
    private String password;
    private String username;
    private String nickname;

    public static Member toEntity(SignUpRequest req, Role role, PasswordEncoder encoder) {
        return new Member(req.email, encoder.encode(req.password), req.username, req.nickname, List.of(role));
    }
}

dto.sign 패키지에 정의되어있습니다.

여러 계층에서 단순히 데이터 전달 용으로 사용되기 때문에 @Data를 지정해주었습니다.

* @Data는 Getter, Setter, EqualsAndHashCode, ToString 등을 만들어줍니다.

 

또한, 예외 클래스는,

package kukekyakya.kukemarket.exception;

public class MemberNicknameAlreadyExistsException extends RuntimeException{
    public MemberNicknameAlreadyExistsException(String message) {
        super(message);
    }
}
package kukekyakya.kukemarket.exception;

public class MemberEmailAlreadyExistsException extends RuntimeException {
    public MemberEmailAlreadyExistsException(String message) {
        super(message);
    }
}

이전과 동일하게 exception 패키지에 작성되어있습니다.

여기에서는 중복된 닉네임 또는 이메일을 생성자를 통해 넘겨받도록 하겠습니다.

 

 

 

다음으로 로그인을 처리하는 signIn 메소드입니다.

public SignInResponse signIn(SignInRequest req) {
    Member member = memberRepository.findByEmail(req.getEmail()).orElseThrow(LoginFailureException::new);
    validatePassword(req, member); // 1
    String subject = createSubject(member); // 2
    String accessToken = tokenService.createAccessToken(subject); // 3
    String refreshToken = tokenService.createRefreshToken(subject); // 4
    return new SignInResponse(accessToken, refreshToken); // 5
}

private void validatePassword(SignInRequest req, Member member) {
    if(!passwordEncoder.matches(req.getPassword(), member.getPassword())) {
        throw new LoginFailureException();
    }
}

private String createSubject(Member member) {
    return String.valueOf(member.getId());
}

SignInRequest로 전달받은 email로 Member를 조회한 뒤, 비밀번호 검증이 통과되었다면 액세스 토큰과 리프레시 토큰을 발급해줍니다.

1. 학습 테스트에서 배웠던 방법으로, 비밀번호를 검증해줍니다. 여기에서는 Member를 찾지 못하든, 비밀번호가 올바르지 않든, 모두 동일하게 LoginFailureException을 발생시켜줍니다. 이메일과 비밀번호 중에 어떤 항목에 의해서 로그인에 실패하였는지 모르게 하기 위함입니다.

2. 토큰에 넣어줄 subject를 생성해줍니다. Member의 id값을 subject로 사용한다는 사실은, 우리의 비즈니스 로직에서만 적용되어야하는 내용이므로 SignService에서 생성해서 전달해줄 것입니다.

3~4. TokenService를 이용하여 accessToken과 refreshToken을 발급해줍니다.

5. 발급된 토큰으로 SignInResponse를 반환해줍니다.

 

위 코드에서 작성된 SignInRequest, SignInResponse 클래스는,

package kukekyakya.kukemarket.dto.sign;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class SignInRequest {
    private String email;
    private String password;
}
package kukekyakya.kukemarket.dto.sign;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class SignInResponse {
    private String accessToken;
    private String refreshToken;
}

dto.sign 패키지에 정의되어 있습니다.

 

또한, 예외 클래스는,

package kukekyakya.kukemarket.exception;

public class LoginFailureException extends RuntimeException {
}

이전과 동일하게 exception 패키지로 작성되어있습니다.

 

 

***

- 엔티티가 수행해야할 작업의 범주?

코드를 작성하면서 항상 고민인 것은, 엔티티가 수행해야할 작업과 서비스에서 수행해야할 작업의 범위를 정하는 일입니다.

위 예시에서는, 이메일과 닉네임 중복을 검증하는 일과 비밀번호가 올바른지 검증하는 일은 Member가 수행할 수도 있습니다.

이렇게 되면, Member가 처리해야할 작업은 늘어나지만, 서비스 로직에서 작성되는 코드는 줄어들게 됩니다.

또한, 가독성의 향상도 기대할 수 있습니다.

하지만 Member가 이메일과 닉네임 중복을 검증해야한다면, MemberRepository를 알아야 하고,

비밀번호가 올바른지 검증하려면 PasswordEncoder를 알아야합니다.

즉, Member 본인이 자신의 데이터가 Repository 계층을 이용하여 어딘가에 저장되어있다는 사실과,

비밀번호를 검증하는데 PasswordEncoder가 필요하며 자신의 비밀번호는 암호화되어있다는 사실 마저 알게 됩니다.

다른 객체들과 의존성이 추가되면서, Member가 알아야 할(또는 알게 될) 내용이 너무 많은 상황입니다.

사실 다시 생각해보면, Member는 자신이 어디에 저장되어있든, 비밀번호가 어떻게 만들어져있든, 그 객체 자신이 알아야하는 내용은 아닙니다.

객체는 그 객체 자신이 할 수 있는 일만 하면 되고, 자신이 알아야하는 내용만 알면 됩니다.

중복 검사와 비밀번호 검사는 우리의 비즈니스(서비스) 로직에서 알아야할 사항이지, Member가 알아야할 사항이 아니라는 것입니다. 

이러한 까닭에, 단순히 서비스 로직의 가독성과 코드를 줄이기 위해서 Member에 의존성을 추가하여 너무 많은 정보를 알게 하는 것은 좋은 방법이 아니라고 판단되어, 서비스 로직에서 수행하도록 하였습니다.

SignUpRequest는 엔티티로 변환하는 과정에서 PasswordEncoder와 Role에 대한 의존성을 가지고 있긴 하지만, 단순히 계층에 구애받지 않으면서 데이터 전달을 위한 자율성 높은 객체로 판단하여 별개의 문제로 취급하였습니다.

***

 

 

이제 SignService에서 의존하고 있는 TokenService를 살펴보겠습니다.

TokenService는 SignService에서만 의존하므로, 동일한 패키지 내에 작성하도록 하겠습니다.

package kukekyakya.kukemarket.service.sign;

import kukekyakya.kukemarket.handler.JwtHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class TokenService {
    private final JwtHandler jwtHandler;

    @Value("${jwt.max-age.access}") // 1
    private long accessTokenMaxAgeSeconds;

    @Value("${jwt.max-age.refresh}") // 2
    private long refreshTokenMaxAgeSeconds;

    @Value("${jwt.key.access}") // 3
    private String accessKey;

    @Value("${jwt.key.refresh}") // 4
    private String refreshKey;

    public String createAccessToken(String subject) {
        return jwtHandler.createToken(accessKey, subject, accessTokenMaxAgeSeconds);
    }

    public String createRefreshToken(String subject) {
        return jwtHandler.createToken(refreshKey, subject, refreshTokenMaxAgeSeconds);
    }
}

TokenService는 JwtHandler를 주입받고 있습니다.

1~4. @Value 어노테이션을 이용하여 설정 파일에 작성된 내용을 가져옵니다.

그리고 이 설정 값과 JwtHandler를 이용하여 accessToken과 refreshToken을 발급해줄 뿐입니다.

액세스 토큰과 리프레시 토큰은 각각 다른 key와 다른 만료 시간을 가지게 됩니다.

 

위와 같이 @Value로 설정 값들을 가져오기 위해 다음과 같이 application.yml을 수정해주겠습니다.

spring:
  ...

  profiles:
    active: local
    include: secret

이제 시작할 때, 기본적으로 profile이 local로 활성화됩니다. 또한, secret을 include 하였습니다.

이렇게 해두면, application-secret.yml에 작성된 설정 값들을 읽어올 수 있게 됩니다.

 

application-secret.yml을 작성해줍니다.

jwt:
  key:
    access: base64 인코딩된 key
    refresh: base64 인코딩된 key
  max-age:
    access: 1800 # 60 * 30
    refresh: 604800 # 60 * 60 * 24 * 7

각 토큰 종류마다 key를 기입해줍니다.

인코딩되지 않은 key를 입력받은 뒤, 우리의 애플리케이션 내에서 key를 인코딩하여 사용하는 방법도 있겠지만,

계속 다른 문자열을 인코딩하는 것도 아니고, 로직 내에서 key를 사용할 때마다 새롭게 인코딩하는 것은 불필요하다 생각되어, 미리 인코딩된 키를 입력 받도록 하겠습니다.

이 때, application-secret.yml 은 비밀스러운 값을 가지고 있으므로, 반드시 .gitignore에 추가해주어야합니다.

 

* '#'은 주석입니다.

* 구글에 'base64 인코딩'을 검색하면 문자열을 인코딩 또는 디코딩해주는 다양한 사이트에 접근할 수 있습니다.

# .gitignore
...
application-secret.yml

 

 

이제 작성된 로직을 테스트해보도록 하겠습니다.

먼저 비교적 간단한 TokenService를 테스트해보겠습니다.

test 디렉토리 내에, 동일한 패키지 경로로 TokenServiceTest 클래스를 작성해줍니다.

전체 소스코드를 살펴본 뒤, 세부적으로 살펴보겠습니다.

package kukekyakya.kukemarket.service.sign;

import kukekyakya.kukemarket.handler.JwtHandler;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.*;

@ExtendWith(MockitoExtension.class)
class TokenServiceTest {
    @InjectMocks TokenService tokenService;
    @Mock JwtHandler jwtHandler;

    @BeforeEach
    void beforeEach() {
        ReflectionTestUtils.setField(tokenService, "accessTokenMaxAgeSeconds", 10L);
        ReflectionTestUtils.setField(tokenService, "refreshTokenMaxAgeSeconds", 10L);
        ReflectionTestUtils.setField(tokenService, "accessKey", "accessKey");
        ReflectionTestUtils.setField(tokenService, "refreshKey", "refreshKey");
    }

    @Test
    void createAccessTokenTest() {
        // given
        given(jwtHandler.createToken(anyString(), anyString(), anyLong())).willReturn("access");

        // when
        String token = tokenService.createAccessToken("subject");

        // then
        assertThat(token).isEqualTo("access");
        verify(jwtHandler).createToken(anyString(), anyString(), anyLong());
    }

    @Test
    void createRefreshTokenTest() {
        // given
        given(jwtHandler.createToken(anyString(), anyString(), anyLong())).willReturn("refresh");

        // when
        String token = tokenService.createRefreshToken("subject");

        // then
        assertThat(token).isEqualTo("refresh");
        verify(jwtHandler).createToken(anyString(), anyString(), anyLong());
    }
}

 

이제 각각에 대해서 살펴보도록 하겠습니다.

 

@ExtendWith(MockitoExtension.class)
class TokenServiceTest {
    @InjectMocks TokenService tokenService;
    @Mock JwtHandler jwtHandler;
    ...

여기에서는 MockitoExtension을 사용하였습니다.

TokenService는 다른 객체(JwtHandler)에 의존성을 가지고 있습니다.

하지만 지금의 테스트는 TokenService를 테스트하기 위함이지, 의존 관계에 있는 다른 객체들을 테스트하려는 것이 아닙니다.

TokenService가 의존하고 있는 JwtHandler는, 이미 자신만의 테스트를 거쳤기 때문에 검증된 상태입니다.

따라서, 우리는 TokenService만 테스트할 것이고, JwtHandler는 테스트하는데 필요한 행위 또는 상태만 제공해주면 됩니다.

이를 위해 사용할 수 있는 것이 Mockito 프레임워크입니다.

@InjectMocks를 필드에 선언하면, 의존성을 가지고 있는 객체들을 가짜로 만들어서 주입받을 수 있도록 합니다.

@Mock를 필드에 선언하면, 객체들을 가짜로 만들어서 @InjectMocks로 지정된 객체에 주입해줍니다.

위 예시에서는, TokenService에 필요한 JwtHandler를 가짜로 만들어서 주입해줍니다.

Mockito에서 제공해주는 given() 메소드를 이용하면, 의존하는 가짜 객체의 행위가 반환해야할 데이터를 미리 준비하여 주입해줄 수도 있고,

verify() 메소드를 이용하면, 그 가짜 객체가 수행한 행위도 검증할 수 있습니다.

자세한 내용은 문서를 참고하길 바라고, 작성된 테스트 코드를 보면서 Mockito의 각종 기능들에 대해 익혀보겠습니다.

* 이에 대한 내용은 앞으로 더 이상 언급하지 않겠습니다.

 

 

TokenService는 @Value를 이용하여 설정 파일에서 값을 읽어와야합니다.

하지만 우리는 TokenService에 대해서 단위 테스트만 수행할 것이기 때문에, 해당 값을 읽어올 수 없습니다.

그렇다고 해서 값을 주입해주지 않는다면, (스트링 같은 경우) null 포인터를 가지기 때문에, 우리의 코드가 정상적으로 동작하는지 확인할 수 없습니다.

이를 위해 취할 수 있는 첫번째 방법은 TokenService에 setter 메소드를 만들어주는 방법입니다.

각각의 테스트를 수행하기 전에, 가짜로 생성된 TokenService 객체에 필요한 값들을 주입해주는 것입니다.

하지만, 우리의 서비스에서는 TokenService가 굳이 setter 메소드를 가질 필요는 없습니다.

테스트를 위해서 원래의 코드를 수정한다는 점은 썩 좋아보이지 않습니다.

다음으로 취할 수 있는 두번째 방법은, ReflectionTestUtils를 이용하는 것입니다.

이를 이용하면, setter 메소드를 사용하지 않고도 리플렉션을 이용해서 어떠한 객체의 필드 값을 임의의 값으로 주입해줄 수 있게 됩니다.

    @BeforeEach
    void beforeEach() {
        ReflectionTestUtils.setField(tokenService, "accessTokenMaxAgeSeconds", 10L);
        ReflectionTestUtils.setField(tokenService, "refreshTokenMaxAgeSeconds", 10L);
        ReflectionTestUtils.setField(tokenService, "accessKey", "accessKey");
        ReflectionTestUtils.setField(tokenService, "refreshKey", "refreshKey");
    }

ReflectionTestUtils.setField(<값을 주입해줄 객체>, <값을 주입할 필드명>, <주입할 값>);

위와 같은 사용법으로 tokenService는 null이 아닌 값을 setter 메소드 작성 없이도 주입할 수 있게 되었습니다.

또한, @BeforeEach는 각각의 테스트를 진행하기에 앞서 수행되기 때문에, 각 테스트 메소드마다 중복으로 작성해줄 필요가 없습니다. 

* 이에 대한 내용은 앞으로 더 이상 언급하지 않겠습니다.

 

 

이제 각각의 테스트 코드를 살펴보겠습니다.

    @Test
    void createAccessTokenTest() {
        // given
        given(jwtHandler.createToken(anyString(), anyString(), anyLong())).willReturn("access");

        // when
        String token = tokenService.createAccessToken("subject");

        // then
        assertThat(token).isEqualTo("access");
        verify(jwtHandler).createToken(anyString(), anyString(), anyLong());
    }

액세스 토큰을 생성하는 테스트 코드입니다.

Mockito에서 제공해주는 given 스태틱 메소드의 인자로, TokenService가 의존하고 있는 가짜 객체의 행위를 지정해주고,

이에 대한 반환 값의 메소드로, 이 객체의 행위가 반환해야 할 데이터를 준비해서 지정해줍니다.

이렇게 되면, TokenService 내의 로직을 수행하는 와중에,

given에 인자로 주어진 행위를 수행한다면, 준비된(willReturn) 데이터를 사용하게 됩니다.

TokenService가 의존하고 있는 JwtHandler도 테스트하게 되는 것이 아니라, TokenService의 코드에 대해서만 테스트를 할 수 있게 된 것입니다.

이에 대한 결과 값을 검증뿐만 아니라, Mockito의 verify 스태틱 메소드를 이용하면 행위 또한 검증할 수 있습니다.

verify의 인자로 어떤 행위가 수행되었는지 확인할 객체를 넘겨주고, 이에 대한 반환 값으로 확인할 메소드를 호출해주면 됩니다.

의존하고 있는 가짜 객체가 사용할 인자를 지정해줄 수도 있습니다.

코드에서 사용된 anyString(), anyLong(), any~() 등을 사용하면, 해당 자료형에 알맞은 어떤 인자가 사용되든 상관 없게 됩니다.

* 이에 대한 내용은 앞으로 더 이상 언급하지 않겠습니다.

 

 

    @Test
    void createRefreshTokenTest() {
        // given
        given(jwtHandler.createToken(anyString(), anyString(), anyLong())).willReturn("refresh");

        // when
        String token = tokenService.createRefreshToken("subject");

        // then
        assertThat(token).isEqualTo("refresh");
        verify(jwtHandler).createToken(anyString(), anyString(), anyLong());
    }

리프레시 토큰을 생성하는 테스트 코드입니다.

액세스 토큰 생성 테스트 코드와 일치하는 부분이 많으므로, 설명은 생략하겠습니다.

 

 

이제 SignService를 테스트해보도록 하겠습니다.

test 디렉토리 내에, 동일한 경로에 SignServiceTest 클래스를 작성해줍니다.

먼저 전체 소스코드를 살펴보겠습니다.

package kukekyakya.kukemarket.service.sign;

import kukekyakya.kukemarket.dto.sign.SignInRequest;
import kukekyakya.kukemarket.dto.sign.SignInResponse;
import kukekyakya.kukemarket.dto.sign.SignUpRequest;
import kukekyakya.kukemarket.entity.member.Member;
import kukekyakya.kukemarket.entity.member.Role;
import kukekyakya.kukemarket.entity.member.RoleType;
import kukekyakya.kukemarket.exception.LoginFailureException;
import kukekyakya.kukemarket.exception.MemberEmailAlreadyExistsException;
import kukekyakya.kukemarket.exception.MemberNicknameAlreadyExistsException;
import kukekyakya.kukemarket.exception.RoleNotFoundException;
import kukekyakya.kukemarket.repository.member.MemberRepository;
import kukekyakya.kukemarket.repository.role.RoleRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.crypto.password.PasswordEncoder;

import java.util.Optional;

import static java.util.Collections.emptyList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;

@ExtendWith(MockitoExtension.class)
public class SignServiceTest {

    @InjectMocks SignService signService;
    @Mock MemberRepository memberRepository;
    @Mock RoleRepository roleRepository;
    @Mock PasswordEncoder passwordEncoder;
    @Mock TokenService tokenService;

    @Test
    void signUpTest() {
        // given
        SignUpRequest req = createSignUpRequest();
        given(roleRepository.findByRoleType(RoleType.ROLE_NORMAL)).willReturn(Optional.of(new Role(RoleType.ROLE_NORMAL)));

        // when
        signService.signUp(req);

        // then
        verify(passwordEncoder).encode(req.getPassword());
        verify(memberRepository).save(any());
    }

    @Test
    void validateSignUpByDuplicateEmailTest() {
        // given
        given(memberRepository.existsByEmail(anyString())).willReturn(true);

        // when, then
        assertThatThrownBy(() -> signService.signUp(createSignUpRequest()))
                .isInstanceOf(MemberEmailAlreadyExistsException.class);
    }

    @Test
    void validateSignUpByDuplicateNicknameTest() {
        // given
        given(memberRepository.existsByNickname(anyString())).willReturn(true);

        // when, then
        assertThatThrownBy(() -> signService.signUp(createSignUpRequest()))
                .isInstanceOf(MemberNicknameAlreadyExistsException.class);
    }

    @Test
    void signUpRoleNotFoundTest() {
        // given
        given(roleRepository.findByRoleType(RoleType.ROLE_NORMAL)).willReturn(Optional.empty());

        // when, then
        assertThatThrownBy(() -> signService.signUp(createSignUpRequest()))
                .isInstanceOf(RoleNotFoundException.class);
    }

    @Test
    void signInTest() {
        // given
        given(memberRepository.findByEmail(any())).willReturn(Optional.of(createMember()));
        given(passwordEncoder.matches(anyString(), anyString())).willReturn(true);
        given(tokenService.createAccessToken(anyString())).willReturn("access");
        given(tokenService.createRefreshToken(anyString())).willReturn("refresh");

        // when
        SignInResponse res = signService.signIn(new SignInRequest("email", "password"));

        // then
        assertThat(res.getAccessToken()).isEqualTo("access");
        assertThat(res.getRefreshToken()).isEqualTo("refresh");
    }

    @Test
    void signInExceptionByNoneMemberTest() {
        // given
        given(memberRepository.findByEmail(any())).willReturn(Optional.empty());

        // when, then
        assertThatThrownBy(() -> signService.signIn(new SignInRequest("email", "password")))
                .isInstanceOf(LoginFailureException.class);
    }

    @Test
    void signInExceptionByInvalidPasswordTest() {
        // given
        given(memberRepository.findByEmail(any())).willReturn(Optional.of(createMember()));
        given(passwordEncoder.matches(anyString(), anyString())).willReturn(false);

        // when, then
        assertThatThrownBy(() -> signService.signIn(new SignInRequest("email", "password")))
                .isInstanceOf(LoginFailureException.class);
    }


    private SignUpRequest createSignUpRequest() {
        return new SignUpRequest("email", "password", "username", "nickname");
    }

    private Member createMember() {
        return new Member("email", "password", "username", "nickname", emptyList());
    }

}

 

각각의 테스트에 대해 세부적으로 살펴보겠습니다.

 

@ExtendWith(MockitoExtension.class)
public class SignServiceTest {

    @InjectMocks SignService signService;
    @Mock MemberRepository memberRepository;
    @Mock RoleRepository roleRepository;
    @Mock PasswordEncoder passwordEncoder;
    @Mock TokenService tokenService;
    ...

여기에서도 SignService의 코드만 테스트하기 위해, Mockito 프레임워크를 사용하였습니다.

테스트를 위해, 의존하고 있는 객체들을 가짜로 만들어서 SignService에 주입해줍니다.

 

 

    @Test
    void signUpTest() {
        // given
        SignUpRequest req = createSignUpRequest();
        given(roleRepository.findByRoleType(RoleType.ROLE_NORMAL)).willReturn(Optional.of(new Role(RoleType.ROLE_NORMAL)));

        // when
        signService.signUp(req);

        // then
        verify(passwordEncoder).encode(req.getPassword());
        verify(memberRepository).save(any());
    }

정상적인 회원가입 처리 로직입니다.

verify를 이용하여 PasswordEncoder가 encode를 수행했는지,MemberRepository가 save를 수행했는지 확인해줍니다.

 

 

    @Test
    void validateSignUpByDuplicateEmailTest() {
        // given
        given(memberRepository.existsByEmail(anyString())).willReturn(true);

        // when, then
        assertThatThrownBy(() -> signService.signUp(createSignUpRequest()))
                .isInstanceOf(MemberEmailAlreadyExistsException.class);
    }

이메일이 중복되었을 때의 검증 테스트입니다.

이미 등록된 이메일이어서 MemberRepsitory.existsByEmail이 true를 반환한다면,

MemberEmailAlreadyExistsException이 발생해야합니다.

 

    @Test
    void validateSignUpByDuplicateNicknameTest() {
        // given
        given(memberRepository.existsByNickname(anyString())).willReturn(true);

        // when, then
        assertThatThrownBy(() -> signService.signUp(createSignUpRequest()))
                .isInstanceOf(MemberNicknameAlreadyExistsException.class);
    }

닉네임이 중복되었을 때의 검증 테스트입니다.

이미 등록된 닉네임이어서 MemberRepsitory.existsByNickname이 true를 반환한다면,

MemberNicknameAlreadyExistsException이 발생해야합니다.

 

 

    @Test
    void signUpRoleNotFoundTest() {
        // given
        given(roleRepository.findByRoleType(RoleType.ROLE_NORMAL)).willReturn(Optional.empty());

        // when, then
        assertThatThrownBy(() -> signService.signUp(createSignUpRequest()))
                .isInstanceOf(RoleNotFoundException.class);
    }

만약 등록되지 않은 권한 등급으로 회원가입을 수행한다면, 해당하는 권한 등급은 찾을 수 없습니다.

따라서 RoleRepository는 Optional.empty()를 반환하고,

SignService.signUp은 RoleNotFoundException이 발생해야합니다.

 

 

    @Test
    void signInTest() {
        // given
        given(memberRepository.findByEmail(any())).willReturn(Optional.of(createMember()));
        given(passwordEncoder.matches(anyString(), anyString())).willReturn(true);
        given(tokenService.createAccessToken(anyString())).willReturn("access");
        given(tokenService.createRefreshToken(anyString())).willReturn("refresh");

        // when
        SignInResponse res = signService.signIn(new SignInRequest("email", "password"));

        // then
        assertThat(res.getAccessToken()).isEqualTo("access");
        assertThat(res.getRefreshToken()).isEqualTo("refresh");
    }

정상적인 로그인 처리 로직입니다.

SignService.signIn이 수행되면, 액세스 토큰과 리프레시 토큰을 가지고 있는 SignInResponse가 반환됩니다.

각 토큰 값은 willReturn으로 준비된 데이터를 지정해주었기 때문에, 검증해볼 수 있습니다.

 

 

    @Test
    void signInExceptionByNoneMemberTest() {
        // given
        given(memberRepository.findByEmail(any())).willReturn(Optional.empty());

        // when, then
        assertThatThrownBy(() -> signService.signIn(new SignInRequest("email", "password")))
                .isInstanceOf(LoginFailureException.class);
    }

등록된 이메일이 아니라면, LoginFailureException이 발생합니다.

 

    @Test
    void signInExceptionByInvalidPasswordTest() {
        // given
        given(memberRepository.findByEmail(any())).willReturn(Optional.of(createMember()));
        given(passwordEncoder.matches(anyString(), anyString())).willReturn(false);

        // when, then
        assertThatThrownBy(() -> signService.signIn(new SignInRequest("email", "password")))
                .isInstanceOf(LoginFailureException.class);
    }

PasswordEncoder.mathes가 false를 반환하여 비밀번호가 유효하지않다면, LoginFailureExcpetion이 발생합니다.

 

 

 

 

이번 시간에는, 로그인과 회원가입을 위한 서비스 로직을 작성하고 테스트해보았습니다.

다음 시간에는 로그인과 회원가입을 처리하기 위한 웹 계층을 작성하는 시간을 가져보도록 하겠습니다.

 

* 질문 및 피드백은 환영입니다.

* 전체 소스코드에서는 여기에서 확인해볼 수 있습니다.

https://github.com/SongHeeJae/kuke-market

반응형

+ Recent posts