반응형

원래 이번 시간에는 API 문서를 작성해볼 예정이었습니다.

하지만 그 전에 짚고 넘어가야할 사항에 대해서 우선적으로 처리해보겠습니다.

 

 

우리의 코드에서는 중복이 나타나고 있습니다.

앞으로의 원활한 진행을 위해, 코드를 리팩토링하여 코드의 중복을 제거하는 시간을 가져보도록 하겠습니다.

우리에겐 자동화된 테스트가 있으므로, 큰 변화가 있더라도 그 여파를 감지하여 손쉽게 대응할 수 있을 것입니다.

 

 

문제가 된 코드는 다음과 같습니다.

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

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

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

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

    @Value("${jwt.key.refresh}")
    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);
    }

    public boolean validateAccessToken(String token) {
        return jwtHandler.validate(accessKey, token);
    }

    public boolean validateRefreshToken(String token) {
        return jwtHandler.validate(refreshKey, token);
    }

    public String extractAccessTokenSubject(String token) {
        return jwtHandler.extractSubject(accessKey, token);
    }

    public String extractRefreshTokenSubject(String token) {
        return jwtHandler.extractSubject(refreshKey, token);
    }
}

service.token.TokenService에서는 유사한 코드가 반복적으로 나타나고 있습니다.

지금의 코드는 각 토큰의 종류 별로, 토큰을 생성하고 검증하고 subject를 추출하는 작업을 수행할 수 있습니다.

그리고 두 종류의 토큰에 대한 처리 로직은, JwtHandler에 전달되는 인자를 제외하면 완전히 동일합니다.

단지 전달되는 인자가 다르다는 이유로, 토큰의 종류에 따라서 별개의 메소드로 구분하고 있습니다.

코드가 중복되고 있는 것입니다.

만약 새로운 토큰을 발급해야하는 상황이 생긴다면, 우리는 다시 한번 유사한 코드를 반복해야합니다.

하나의 토큰이 추가된다면, 추가된 토큰에 대해 생성과 검증, subject 추출이라는 세 가지 작업을 수행할 수 있도록, 세 개의 새로운 메소드를 작성하게 될 것입니다.

열 개의 토큰이 추가된다면, 30개의 새로운 메소드가 작성되어야합니다.

 

* 물론, 우리의 서비스에서는 토큰의 종류가 더 이상 늘어날 일은 없을 것입니다. 오히려 지난 시간에는, 토큰을 하나로 줄이는 방안에 대해서 논의하였습니다. 하지만 토큰이 두 가지 밖에 없더라도, 코드의 중복은 제거하고 넘어가도록 하겠습니다.

 

일전에 논의했을 때는, SignService가 각각의 토큰을 생성하기 위한 설정 정보(또는 설정을 주입받는 방법)와 JwtHandler에 대한 구체적인 사용법을 모르도록 하여 책임을 분산하고자 하였습니다.

이를 위해 SignService와 JwtHandler 간에 중간 다리 역할을 하는 TokenService가 등장하게 되었습니다.

TokenService는 SignService를 대신해서 토큰에 대한 설정 정보를 주입받고, 의존성으로 가지고 있는 JwtHandler를 통해 두 종류의 토큰을 다룰 수 있게 되는 것입니다. 

 

SignService는 토큰을 생성하기 위한 구체적인 설정 정보는 모르더라도, 두 가지 종류(액세스 토큰과 리프레시 토큰)의 토큰이 있다는 것은 이미 알고 있습니다.

또, TokenService는 자신이 요청 받을 수 있는 메시지를 추상화시킨다면, 메시지에 응답하기 위해 사용되는 설정 값들은 외부에서 주입받거나 미리 주입받아두어 활용하면 됩니다.

이렇게 되면, SignService는 토큰의 종류에 따라서 TokenService에게 동일한 요청 메시지를 보내더라도 다른 결과를 응답받을 수 있게 됩니다.

TokenService가 토큰의 설정 정보에 따라 여러 작업을 수행할 수 있도록, 이를 외부에서 주입해주는 것입니다.

 

이를 구현하기 위한 방법에는 몇 가지 선택지가 있었습니다.

SignService는 여전히 설정 정보(또는 설정을 주입 받는 방법)에 대해서 자세히 몰라도 되도록, 설정 정보를 저장하는 객체를 별도로 만들어서 의존하도록 하겠습니다.

 

- SignService가 설정 정보를 가지고 있고, TokenService에 요청을 보낼 때마다 설정 정보를 함께 보내준다.

TokenService가 전달 받은 설정 값에 따라서 다른 결과를 응답할 수 있도록, 메소드 파라미터로 토큰의 설정 정보를 함께 전달받게 됩니다. 이 방법을 택할 경우, 모든 메소드에 새로운 파라미터(설정 정보 객체)가 추가되어야하고, 전달 받은 설정 정보 객체에서 getter를 통해 데이터를 꺼내는 과정이 생기게 됩니다.

 

- SignService가 설정 정보를 가지고 있지 않고, TokenService를 인터페이스로 정의하여, 각각의 토큰 종류에 따른 설정 값을 가지는 AccessTokenService와 RefreshTokenService를 만든다. SignService는 이 두 가지 타입의 객체를 의존한다.

이 방법에도 문제가 있습니다.

토큰의 종류가 늘어날 때마다 새로운 클래스를 작성해야합니다.

또, 새롭게 작성된 두 종류의 TokenService 구현 클래스들은, 완전히 동일한 작업을 수행합니다.

단지 JwtHandler에 전달되는 인자만 달라지면 됩니다.

즉, 토큰의 종류마다 새로운 클래스를 작성할 필요 없이, 단일한 클래스의 각 인스턴스마다 사용되는 설정 값들을 주입해주면 되는 것입니다.

각각의 인스턴스는 완전히 동일한 작업을 수행하지만, 자신의 상태에 따라 다른 결과를 돌려주게되는 것입니다.

 

결국, SignService는 각각의 토큰 종류마다 다른 설정 정보를 가지는, 동일한 타입의 인스턴스를 의존하면 문제가 해결되는 것입니다.

그렇다면, 이렇게 SignService가 의존할 각각의 인스턴스는, 어떻게 주입(또는 생성)받아서 사용할 수 있을지 결정해야합니다.

 

이에 대해서도 몇 가지 선택지가 있었습니다.

- SignService에서 지정해두었던 토큰들의 설정 정보를 주입 받고, 의존해야하는 인스턴스를 직접 생성(new)한다.

의존성을 외부에서 주입받지 않고 직접 생성하는 것은, 스프링에서 지원해주는 철학을 위배하는 듯 해서 별로 내키지 않았습니다.

또한, 이전에 논의에서 SignService는 토큰 설정 정보를 다루는 방식에 대해서 직접적으로 모르길 원했습니다.

 

- SignService에서 의존할 동일한 타입의 인스턴스를 스프링 컨테이너에서 주입받는다.

DI를 이용하여 의존성을 만드는 것은 스프링의 철학을 유지할 수도 있고, SignService가 토큰 설정 정보를 다루는 방식에 대해서 직접적으로 몰라도 됩니다. 주입 받을 인스턴스에서 처리해두면 됩니다.

이로 인해, 이 방법을 택하게 되었습니다.

 

그렇다면, 동일한 타입의 인스턴스를 상태만 달리하여 스프링 컨테이너에 등록시키면 문제가 해결됩니다.

이를 수행하기 위해, @Bean 어노테이션으로 직접적으로 스프링 빈에 등록하는 방식을 택하게 되었습니다.

 

 

이제 코드를 살펴보도록 하겠습니다.

config.token 패키지에 TokenConfig를 작성해줍니다.

package kukekyakya.kukemarket.config.token;

import kukekyakya.kukemarket.handler.JwtHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
@RequiredArgsConstructor
public class TokenConfig {

    private final JwtHandler jwtHandler;

    @Bean
    public TokenHelper accessTokenHelper(
            @Value("${jwt.key.access}") String key,
            @Value("${jwt.max-age.access}") long maxAgeSeconds) {
        return new TokenHelper(jwtHandler, key, maxAgeSeconds);
    }

    @Bean
    public TokenHelper refreshTokenHelper(
            @Value("${jwt.key.refresh}") String key,
            @Value("${jwt.max-age.refresh}") long maxAgeSeconds) {
        return new TokenHelper(jwtHandler, key, maxAgeSeconds);
    }
}

TokenHelper는 기존의 TokenService의 역할을 수행한다고 보면 됩니다.

Service의 역할을 수행한다고 보기에는 어려움이 있다고 판단되어, TokenHelper로 명명하였습니다.

@Bean을 통해 직접 인스턴스를 생성하여 JwtHandler와 토큰의 설정 정보들을 주입 받고, 이를 이용하여 토큰의 생성과 검증, subject 추출을 수행합니다.

메소드 이름은 빈의 이름이 될 것이기 때문에, 동일한 타입의 빈을 등록하더라도, 이를 구분 지을 수 있을 것입니다.

 

 

 

이제 config.token 패키지에 작성된 TokenHelper를 살펴보겠습니다.

package kukekyakya.kukemarket.config.token;

import kukekyakya.kukemarket.handler.JwtHandler;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
public class TokenHelper {
    private final JwtHandler jwtHandler;
    private final String key;
    private final long maxAgeSeconds;

    public String createToken(String subject) {
        return jwtHandler.createToken(key, subject, maxAgeSeconds);
    }

    public boolean validate(String token) {
        return jwtHandler.validate(key, token);
    }

    public String extractSubject(String token) {
        return jwtHandler.extractSubject(key, token);
    }
}

@Bean을 통해 직접적으로 스프링 컨테이너에 등록할 것이기 때문에, 컴포넌트 스캔이 되지 않도록 @Component와 같은 어노테이션을 붙이지 않았습니다.

메소드 이름과 변수 명은, 한층 더 추상화되었습니다.

 

 

이를 이용하여 수정된 service.sign.SignService를 살펴보겠습니다.

package kukekyakya.kukemarket.service.sign;

import kukekyakya.kukemarket.config.token.TokenHelper;
import kukekyakya.kukemarket.dto.sign.RefreshTokenResponse;
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
public class SignService {

    private final MemberRepository memberRepository;
    private final RoleRepository roleRepository;
    private final PasswordEncoder passwordEncoder;
    private final TokenHelper accessTokenHelper; // 1
    private final TokenHelper refreshTokenHelper; // 1

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

    @Transactional(readOnly = true)
    public SignInResponse signIn(SignInRequest req) {
        Member member = memberRepository.findByEmail(req.getEmail()).orElseThrow(LoginFailureException::new);
        validatePassword(req, member);
        String subject = createSubject(member);
        String accessToken = accessTokenHelper.createToken(subject);
        String refreshToken = refreshTokenHelper.createToken(subject);
        return new SignInResponse(accessToken, refreshToken);
    }

    public RefreshTokenResponse refreshToken(String rToken) {
        validateRefreshToken(rToken);
        String subject = refreshTokenHelper.extractSubject(rToken);
        String accessToken = accessTokenHelper.createToken(subject);
        return new RefreshTokenResponse(accessToken);
    }

    private void validateRefreshToken(String rToken) {
        if(!refreshTokenHelper.validate(rToken)) {
            throw new AuthenticationEntryPointException();
        }
    }

    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());
    }
}

1. 동일한 TokenHelper 타입의 빈을 두 개 주입 받고 있습니다. @RequiredArgsConstructor로 인해 final로 선언한 변수들의 생성자가 만들어지고, 이 생성자에서는 자동으로 필요한 의존성을 주입받게 됩니다. 주입 전략에 의해 타입이 동일한 여러 개의 빈에 대해서는, 빈의 이름과 매핑되는 변수 명에 빈을 주입받게 될 것입니다.

 

 

config.security.SecurityConfig도 수정해주겠습니다.

@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final TokenHelper accessTokenHelper;
    private final CustomUserDetailsService userDetailsService;

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().mvcMatchers("/exception/**");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .httpBasic().disable()
                .formLogin().disable()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                    .authorizeRequests()
                        .antMatchers(HttpMethod.POST, "/api/sign-in", "/api/sign-up", "/api/refresh-token").permitAll()
                        .antMatchers(HttpMethod.GET, "/api/**").permitAll()
                        .antMatchers(HttpMethod.DELETE, "/api/members/{id}/**").access("@memberGuard.check(#id)")
                        .anyRequest().hasAnyRole("ADMIN")
                .and()
                    .exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler())
                .and()
                    .exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())
                .and()
                    .addFilterBefore(new JwtAuthenticationFilter(accessTokenHelper, userDetailsService), UsernamePasswordAuthenticationFilter.class);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

빈 이름이 accessTokenHelper인 TokenHelper 타입의 빈을 주입받도록 합니다.

JwtAuthenticationFilter의 생성자로 이를 넘겨줍니다.

 

 

수정된 config.security.JwtAuthenticationFilter는 다음과 같습니다.

@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends GenericFilterBean {

    private final TokenHelper accessTokenHelper;
    private final CustomUserDetailsService userDetailsService;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        String token = extractToken(request);
        if(validateToken(token)) {
            setAuthentication(token);
        }
        chain.doFilter(request, response);
    }

    private String extractToken(ServletRequest request) {
        return ((HttpServletRequest)request).getHeader("Authorization");
    }

    private boolean validateToken(String token) {
        return token != null && accessTokenHelper.validate(token);
    }

    private void setAuthentication(String token) {
        String userId = accessTokenHelper.extractSubject(token);
        CustomUserDetails userDetails = userDetailsService.loadUserByUsername(userId);
        SecurityContextHolder.getContext().setAuthentication(new CustomAuthenticationToken(userDetails, userDetails.getAuthorities()));
    }

}

accessTokenHelper를 이용하여 토큰을 검증합니다.

 

 

이제 수정된 코드를 검증해야합니다.

모든 테스트를 수행해줍니다.

코드의 변화가 컸기 때문에, 컴파일 에러가 발생하는 코드들을 모두 찾아서 수정해줍니다.

SignServiceTest 클래스는 다음과 같이 수정됩니다.

package kukekyakya.kukemarket.service.sign;

import kukekyakya.kukemarket.config.token.TokenHelper;
import kukekyakya.kukemarket.dto.sign.RefreshTokenResponse;
import kukekyakya.kukemarket.dto.sign.SignInResponse;
import kukekyakya.kukemarket.dto.sign.SignUpRequest;
import kukekyakya.kukemarket.entity.member.Role;
import kukekyakya.kukemarket.entity.member.RoleType;
import kukekyakya.kukemarket.exception.*;
import kukekyakya.kukemarket.repository.member.MemberRepository;
import kukekyakya.kukemarket.repository.role.RoleRepository;
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.security.crypto.password.PasswordEncoder;

import java.util.Optional;

import static kukekyakya.kukemarket.factory.dto.SignInRequestFactory.createSignInRequest;
import static kukekyakya.kukemarket.factory.dto.SignUpRequestFactory.createSignUpRequest;
import static kukekyakya.kukemarket.factory.entity.MemberFactory.createMember;
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 {

    SignService signService;
    @Mock MemberRepository memberRepository;
    @Mock RoleRepository roleRepository;
    @Mock PasswordEncoder passwordEncoder;
    @Mock TokenHelper accessTokenHelper;
    @Mock TokenHelper refreshTokenHelper;

    @BeforeEach
    void beforeEach() {
        signService = new SignService(memberRepository, roleRepository, passwordEncoder, accessTokenHelper, refreshTokenHelper);
    }

    @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(accessTokenHelper.createToken(anyString())).willReturn("access");
        given(refreshTokenHelper.createToken(anyString())).willReturn("refresh");

        // when
        SignInResponse res = signService.signIn(createSignInRequest("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(createSignInRequest("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(createSignInRequest("email", "password")))
                .isInstanceOf(LoginFailureException.class);
    }

    @Test
    void refreshTokenTest() {
        // given
        String refreshToken = "refreshToken";
        String subject = "subject";
        String accessToken = "accessToken";
        given(refreshTokenHelper.validate(refreshToken)).willReturn(true);
        given(refreshTokenHelper.extractSubject(refreshToken)).willReturn(subject);
        given(accessTokenHelper.createToken(subject)).willReturn(accessToken);

        // when
        RefreshTokenResponse res = signService.refreshToken(refreshToken);

        // then
        assertThat(res.getAccessToken()).isEqualTo(accessToken);
    }

    @Test
    void refreshTokenExceptionByInvalidTokenTest() {
        // given
        String refreshToken = "refreshToken";
        given(refreshTokenHelper.validate(refreshToken)).willReturn(false);

        // when, then
        assertThatThrownBy(() -> signService.refreshToken(refreshToken))
                .isInstanceOf(AuthenticationEntryPointException.class);
    }
}

각 테스트 코드를 의존성에 맞게 수정해주었습니다. 자세한 설명은 생략하겠습니다.

 

SignServiceTest는 각 테스트 코드 외에도, 테스트 객체의 의존성 설정 코드에서도 변화가 있었습니다.

@ExtendWith(MockitoExtension.class)
public class SignServiceTest {

    SignService signService;
    @Mock MemberRepository memberRepository;
    @Mock RoleRepository roleRepository;
    @Mock PasswordEncoder passwordEncoder;
    @Mock TokenHelper accessTokenHelper;
    @Mock TokenHelper refreshTokenHelper;

    @BeforeEach
    void beforeEach() {
        signService = new SignService(memberRepository, roleRepository, passwordEncoder, accessTokenHelper, refreshTokenHelper);
    }

SignService에 선언되어있던 @InjectMokcs가 제거되었고, @BeforeEach에서 직접적으로 인스턴스를 생성하여 의존성을 초기화해주고 있습니다.

동일한 타입의 @Mock에 대해서 Mockito에서 제대로 인식하지 못하기 때문에, @Mock으로 만들어진 의존성을 직접 지정해준 것입니다.

 

 

기존에 작성되었던 TokenServiceTest는 제거해줍시다.

그리고 새롭게 작성된 TokenHelper를 테스트하기 위해, test 디렉토리에서 config.token 디렉토리에 TokenHelperTest를 작성해주겠습니다.

package kukekyakya.kukemarket.config.token;

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.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;

@ExtendWith(MockitoExtension.class)
class TokenHelperTest {
    TokenHelper tokenHelper;
    @Mock JwtHandler jwtHandler;

    @BeforeEach
    void beforeEach() {
        tokenHelper = new TokenHelper(jwtHandler, "key", 1000L);
    }

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

        // when
        String createdToken = tokenHelper.createToken("subject");

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

    @Test
    void validateTest() {
        // given
        given(jwtHandler.validate(anyString(), anyString())).willReturn(true);

        // when
        boolean result = tokenHelper.validate("token");

        // then
        assertThat(result).isTrue();
    }

    @Test
    void invalidateTest() {
        // given
        given(jwtHandler.validate(anyString(), anyString())).willReturn(false);

        // when
        boolean result = tokenHelper.validate("token");

        // then
        assertThat(result).isFalse();
    }

    @Test
    void extractSubjectTest() {
        // given
        given(jwtHandler.extractSubject(anyString(), anyString())).willReturn("subject");

        // when
        String subject = tokenHelper.extractSubject("token");

        // then
        assertThat(subject).isEqualTo(subject);
    }


}

 TokenHelper의 의존성 JwtHandler를 @Mock을 통해 가짜로 만들어주었습니다.

@BeforeEach에서는 TokenHelper 인스턴스를 직접 생성해줍니다.

각 테스트에 대해 자세한 설명은 생략하겠습니다.

 

 

컴파일 에러가 해결되었고 새로운 테스트 작성이 끝났다면, 모든 테스트를 수행해봅니다.

모든 테스트 성공

모든 테스트가 성공적으로 수행되었습니다.

 

 

이번 시간에는, 코드 리팩토링을 통해 기능 자체의 변화 없이 코드를 개선할 수 있었습니다.

TokenService에서 발생하는 중복 코드를 제거하고, 이에 대한 테스트를 진행한 것입니다. 

변경의 여파는 상당했지만, 자동화된 테스트로 인해 변경을 감지하여 발빠르게 대응할 수 있었습니다.

 

다음 시간에는, 원래 하고자 했던 API 문서를 작성해보도록 하겠습니다.

 

 

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

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

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

반응형

+ Recent posts