Sparta/What I Learned

23.1.31

코딩하는 또롱이 2023. 2. 2. 15:03
시큐리티 OAuth2

 

시큐리티를 듣기 전 숙련 주차 때 만들었던 프로젝트를 jwt 적용해서 도메인도 바꾸고, controller, service 등등 전부 바꾸어 주었다.

@AuthenticationPrincipal

요 놈을 이용해서 일일이 하나한 토큰을 검증해주어야했던 코드들을 JwtAuthFilter를 만들어서 갖고오는 방식으로 변경했더니 코드들이 예쁘게 줄었다😎

 

JPA의 영속성 context, fetch type의 지연로딩, 즉시로딩, 프록시 객체 라는 말을 튜터님이 하셨다. 지금은 언급을 안 하겠다는 말은 이후 강의에서 해주시겠지??? 간단하게 예습 정도로만 알아보자.

이야,,, 완전 쩌는 블로그 발견!

여기서 영속성 context 뿐만 아니라 엔티티 매니저, fetch type까지 한 번에 볼 수 있으니 꼭 보기~!

프록시 객체와 관련된 블로그!

 


 

🤔 OAuth란??

Open standard for Authorization 즉, 개방형 Authorization 의 표준이며 API 허가(Authorize)를 목적으로 JSON 형식으로 개발된 HTTP 기반의 보안 프로토콜이다. 사용자들이 사용하고자 하는 웹사이트 및 애플리케이션에 비밀번호를 제공하지 않고 접근 권한을 부여 받을 수 있게 해주는 공통적 수단으로서 사용 되어지는 기술이다.

다양한 클라이언트 환경에 적합한 인증(Authentication) 및 인가(Authorization) 의 위임 방법을 제공하고 그 결과로 클라이언트에게 접근 토큰 (Access Token) 을 발급하는 것에 대한 구조이다.

 

이런 방식으로 카카오 로그인, 네이버 로그인, 구글 로그인 등을 할 수 있게 되었고, 강의에는 카카오 로그인을 알려줘서 이렇게 카카오 로그인 구현 오나료!

만든 나만의 select shop을 눌러서 플랫폼 주소 http://localhost:8080 를 등록해주고 리다이렉트 주소로는 http://localhost:8080/api/user/kakao/callback 를 등록해준다.

그런 다음 ㅇㅣ렇게 활성화 버튼을 눌러주면!!

ㅋㅏ카오 로그인 구현이 완료~!

세세하게는 동의작업을 설정해주어야 하는데 이건 알아서 .... 🐱

 

나는 이렇게만 설정해씀 ㅋㅅㅋ

 

🤔 카카오 로그인을 만든 만큼 user에서 어떻게 구현할 것인가???

  1. 카카오 User 를 위한 테이블 (ex. KakaoUser) 을 하나 더 만든다.
    1. 장점: 결합도가 낮아짐
      1. 성격이 다른 유저 별로 분리 → 차후 각 테이블의 변화에 서로 영향을 주지 않음
      2. 예) 카카오 사용자들만 profile_image 컬럼 추가해서 사용 가능
    2. 단점: 구현 난이도가 올라감
      1. 예) 관심상품 등록 시, 회원별로 다른 테이블을 참조해야 함
        1. 일반 회원: User - Product
        2. 카카오 회원: KakaoUser - Product
  2. 기존 회원 (User) 테이블에 카카오 User 추가
    1. 장점: 구현이 단순해짐
    2. 단점: 결합도가 높아짐
      1. 폼 로그인을 통해 카카오 로그인 사용자의 username, password 를 입력해서 로그인한다면??

 

일단은 난이도가 낮은 2번으로 진행해보자.

 

 

  1. 카카오에서 보내주는 '인가코드'를 받음 ⇒ Controller
  2. '인가코드'를 가지고 카카오 로그인 처리 ⇒ Service
  3. 로그인 성공 시 생성한 JWT 반환
  4. 인가코드 로 엑세스 토큰을 요청할 때 카카오 디벨로퍼스 사이트에서 생성된 내 REST API 키를 넣어준다.

[UserController]

더보기

추가 

@GetMapping("/kakao/callback")
public String kakaoLogin(@RequestParam String code, HttpServletResponse response) throws JsonProcessingException {
    // code: 카카오 서버로부터 받은 인가 코드
    String createToken = kakaoService.kakaoLogin(code, response);

    // Cookie 생성 및 직접 브라우저에 Set
    Cookie cookie = new Cookie(JwtUtil.AUTHORIZATION_HEADER, createToken.substring(7));
    cookie.setPath("/");
    response.addCookie(cookie);

    return "redirect:/api/shop";
}

 

[KakaoService]

더보기
package com.sparta.myselectshop.service;


import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sparta.myselectshop.dto.KakaoUserInfoDto;
import com.sparta.myselectshop.entity.User;
import com.sparta.myselectshop.entity.UserRoleEnum;
import com.sparta.myselectshop.jwt.JwtUtil;
import com.sparta.myselectshop.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;

import jakarta.servlet.http.HttpServletResponse;
import java.util.UUID;

@Slf4j
@Service
@RequiredArgsConstructor
public class KakaoService {
    private final PasswordEncoder passwordEncoder;
    private final UserRepository userRepository;
    private final JwtUtil jwtUtil;

    public String kakaoLogin(String code, HttpServletResponse response) throws JsonProcessingException {
        // 1. "인가 코드"로 "액세스 토큰" 요청
        String accessToken = getToken(code);

        // 2. 토큰으로 카카오 API 호출 : "액세스 토큰"으로 "카카오 사용자 정보" 가져오기
        KakaoUserInfoDto kakaoUserInfo = getKakaoUserInfo(accessToken);

        // 3. 필요시에 회원가입
        User kakaoUser = registerKakaoUserIfNeeded(kakaoUserInfo);

        // 4. JWT 토큰 반환
        String createToken =  jwtUtil.createToken(kakaoUser.getUsername(), kakaoUser.getRole());
//        response.addHeader(JwtUtil.AUTHORIZATION_HEADER, createToken);

        return createToken;
    }

    // 1. "인가 코드"로 "액세스 토큰" 요청
    private String getToken(String code) throws JsonProcessingException {
        // HTTP Header 생성
        HttpHeaders headers = new HttpHeaders();
        headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

        // HTTP Body 생성
        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("grant_type", "authorization_code");
        body.add("client_id", "47e530f39e15cc33ea2f2ff010a0b034");
        body.add("redirect_uri", "http://localhost:8080/api/user/kakao/callback");
        body.add("code", code);

        // HTTP 요청 보내기
        HttpEntity<MultiValueMap<String, String>> kakaoTokenRequest =
                new HttpEntity<>(body, headers);
        RestTemplate rt = new RestTemplate();
        ResponseEntity<String> response = rt.exchange(
                "https://kauth.kakao.com/oauth/token",
                HttpMethod.POST,
                kakaoTokenRequest,
                String.class
        );

        // HTTP 응답 (JSON) -> 액세스 토큰 파싱
        String responseBody = response.getBody();
        ObjectMapper objectMapper = new ObjectMapper();
        JsonNode jsonNode = objectMapper.readTree(responseBody);
        return jsonNode.get("access_token").asText();
    }

    // 2. 토큰으로 카카오 API 호출 : "액세스 토큰"으로 "카카오 사용자 정보" 가져오기
    private KakaoUserInfoDto getKakaoUserInfo(String accessToken) throws JsonProcessingException {
        // HTTP Header 생성
        HttpHeaders headers = new HttpHeaders();
        headers.add("Authorization", "Bearer " + accessToken);
        headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

        // HTTP 요청 보내기
        HttpEntity<MultiValueMap<String, String>> kakaoUserInfoRequest = new HttpEntity<>(headers);
        RestTemplate rt = new RestTemplate();
        ResponseEntity<String> response = rt.exchange(
                "https://kapi.kakao.com/v2/user/me",
                HttpMethod.POST,
                kakaoUserInfoRequest,
                String.class
        );

        String responseBody = response.getBody();
        ObjectMapper objectMapper = new ObjectMapper();
        JsonNode jsonNode = objectMapper.readTree(responseBody);
        Long id = jsonNode.get("id").asLong();
        String nickname = jsonNode.get("properties")
                .get("nickname").asText();
        String email = jsonNode.get("kakao_account")
                .get("email").asText();

        log.info("카카오 사용자 정보: " + id + ", " + nickname + ", " + email);
        return new KakaoUserInfoDto(id, nickname, email);
    }

    // 3. 필요시에 회원가입
    private User registerKakaoUserIfNeeded(KakaoUserInfoDto kakaoUserInfo) {
        // DB 에 중복된 Kakao Id 가 있는지 확인
        Long kakaoId = kakaoUserInfo.getId();
        User kakaoUser = userRepository.findByKakaoId(kakaoId)
                .orElse(null);
        if (kakaoUser == null) {
            // 카카오 사용자 email 동일한 email 가진 회원이 있는지 확인
            String kakaoEmail = kakaoUserInfo.getEmail();
            User sameEmailUser = userRepository.findByEmail(kakaoEmail).orElse(null);
            if (sameEmailUser != null) {
                kakaoUser = sameEmailUser;
                // 기존 회원정보에 카카오 Id 추가
                kakaoUser = kakaoUser.kakaoIdUpdate(kakaoId);
            } else {
                // 신규 회원가입
                // password: random UUID
                String password = UUID.randomUUID().toString();
                String encodedPassword = passwordEncoder.encode(password);

                // email: kakao email
                String email = kakaoUserInfo.getEmail();

                kakaoUser = new User(kakaoUserInfo.getNickname(), kakaoId, encodedPassword, email, UserRoleEnum.USER);
            }

            userRepository.save(kakaoUser);
        }
        return kakaoUser;
    }
}

 

[KakaoUserInfoDto]

더보기
package com.sparta.myselectshop.dto;

import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class KakaoUserInfoDto {
    private Long id;
    private String email;
    private String nickname;

    public KakaoUserInfoDto(Long id, String nickname, String email) {
        this.id = id;
        this.nickname = nickname;
        this.email = email;
    }
}

 

코드를 이렇게 짜주면 카카오 간편 로그인, 회원가입 둘 다 할 수 있다~!

 

 

 

 


 

 

시큐리티 - Test

 

 

🤔 단위테스트 ❓

JUnit : 자바프로그래밍 언어용 단위 테스트 프레임 워크

 

  •  build.gradle 파일을 열어보면 JUnit 사용을 위한 환경 설정이 이미 되어있다.

 

✅ Product 단위 테스트

 

🐱 테스트 파일 생성하기

1. 파일 찾기 (단축키 익히면 좋음)

  • Windows: ctrl + shift + N
  • Mac: command + shifht + O

2. 'product' 입력 후 "Product.java" 파일 선택

3. "Product.java" 파일 내에서 마우스 오른쪽 버튼 클릭 > "Generate..." 클릭

4. "Test..." 클릭

5. 기본세팅 그대로 OK 눌러서 생성

 

👉 위의 방법대로 세팅 후 Test 코드 작성

package com.sparta.myselectshop.entity;

import com.sparta.myselectshop.dto.ProductRequestDto;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;
// (1) 테스트 클래스가 자동으로 생성
class ProductTest {
    @Test // (2) 테스트 클래스의 메서드가 하나의 테스트 케이스를 나타내고, @Test 으로 나타낸다
    @DisplayName("정상 케이스") // (3) 테스트에 라밸링을 해주는 어노테이션
    void createProduct_Normal() {
        // (4) given - 준비!
        Long userId = 100L;
        String title = "오리온 꼬북칩 초코츄러스맛 160g";
        String image = "https://shopping-phinf.pstatic.net/main_2416122/24161228524.20200915151118.jpg";
        String link = "https://search.shopping.naver.com/gate.nhn?id=24161228524";
        int lprice = 2350;

        ProductRequestDto requestDto = new ProductRequestDto(
                title,
                image,
                link,
                lprice
        );

        // (5) when - 테스트하려는 로직 수행!
        Product product = new Product(requestDto, userId);

        // (6) then - 검증!
        assertNull(product.getId()); // (6-a) assertNull : product.getId()가 null 이어야 test 를 통과 시켜주는 함수
        assertEquals(userId, product.getUserId()); // (6-b) assertEquals : 주어진 두 값이 같아야 test 를 통과 시켜주는 함수
        assertEquals(title, product.getTitle());
        assertEquals(image, product.getImage());
        assertEquals(link, product.getLink());
        assertEquals(lprice, product.getLprice());
        assertEquals(0, product.getMyprice());
    }

}

🚨 assert 관련 함수들은 다양하니까 한 번 알아보기

 

[테스트 결과]

성공~!

 

 

🐱 Edge Case?

🤔 결국에는 Product 객체의 생성자, 또는 메서드를 실행시킨다고 하면 될 걸 왜 굳이 헷갈리게 로직이라는 용어를 사용했을까?
☝ 실제로 테스트케이스의 단위는 메서드 등이 아니라, 하나의 로직이기 때문입니다.
(장금이야 뭐야,,,, 로직이니가 로직이라고 한다,,,,, 홍시니까 홍시맛이 난다고,,,,하는거랑 뭐가 달라,,,,떼잉,,,,,)

인스턴스를 생성하는 생성자 함수를 예로 들면, 잘 생성되는 케이스, 잘 생성되지 않는 케이스(다양하게 나뉨)로 크게 나뉜다. 잘 생성되는 케이스도 정상 동작이지만 잘 생성되지 않을 이유가 있을 때 잘 생성되지 않는 것 역시 정상 동작된다. 이러한 케이스들을 분리해서 각각의 케이스를 테스트 해야한다.

 

위의 Test 코드로 공부를 이어 나가보자. 위의 경우에 엣지 케이스는 5가지로 구분할 수 있다.

// 회원 Id
Long userId = 1230L;
// 상품명
String title = "오리온 꼬북칩 초코츄러스맛 160g";
// 상품 이미지 URL
String image = "https://shopping-phinf.pstatic.net/main_2416122/24161228524.20200915151118.jpg";
// 상품 최저가 페이지 URL
String link = "https://search.shopping.naver.com/gate.nhn?id=24161228524";
// 상품 최저가
int lprice = 2350;

1. 회원 Id


1. 회원 아이디 (userId) 가 null 로 들어온다면, 등록된 상품은 어떤 회원의 상품이 될까?
2. 회원 아이디 (userId) 가 마이너스 값이라면, 등록된 상품은 어떤 회원의 상품이 될까?

2. 상품명


1. 상품명이 null 로 들어오면 어떻게 될까?
2. 상품명이 빈 문자열인 경우에도 저장을 해야 될까? → 저장한다면 UI 에서는 어떻게 표시돼야 될까?

3. 상품 이미지 URL


1. 상품 이미지 URL 이 null 로 들어오면 어떻게 될까?
2. 상품 이미지 URL 이 URL 형태가 아니라면 어떻게 될까?

4. 상품 최저가 페이지 URL


1. 상품 최저가 페이지 URL 이 null 로 들어오면 어떻게 될까?
2. 상품 최저가 페이지 URL 이 URL 형태가 아니라면 어떻게 될까?

5. 상품 최저가


1 상품 최저가가 0 이라면 어떻게 될까?
2. 상품 최저가가 음수라면 어떻게 될까?

 

☝ 원래 엣지 케이스는 개발 단계에서 고민되어야 했지만, 이런 경우 기존 소스를 고쳐주면 된다. TDD 찬성론자들이 신나하겠지? 실제로 TDD까지는 아니더라도, 테스트 코드 작성의 가장 큰 효용 중 하나는 테스트 코드를 작성하면서 기존 소스의 구현이나 구조 설계를 끊임없이 고민하게 된다는 것이다!

 

 

[변경 전 Product]

public Product(ProductRequestDto requestDto, Long userId) {
    this.title = requestDto.getTitle();
    this.image = requestDto.getImage();
    this.link = requestDto.getLink();
    this.lprice = requestDto.getLprice();
    this.myprice = 0;
    this.userId = userId;
}

 

[변경 후 Product]

5가지 엣지 케이스의 예외 처리를 해준다.

public Product(ProductRequestDto requestDto, Long userId) {
    // 입력값 Validation
    if (userId == null || userId <= 0) {
        throw new IllegalArgumentException("회원 Id 가 유효하지 않습니다.");
    }

    if (requestDto.getTitle() == null || requestDto.getTitle().isEmpty()) {
        throw new IllegalArgumentException("저장할 수 있는 상품명이 없습니다.");
    }

    if (!isValidUrl(requestDto.getImage())) {
        throw new IllegalArgumentException("상품 이미지 URL 포맷이 맞지 않습니다.");
    }

    if (!isValidUrl(requestDto.getLink())) {
        throw new IllegalArgumentException("상품 최저가 페이지 URL 포맷이 맞지 않습니다.");
    }

    if (requestDto.getLprice() <= 0) {
        throw new IllegalArgumentException("상품 최저가가 0 이하입니다.");
    }

    // 관심상품을 등록한 회원 Id 저장
    this.userId = userId;
    this.title = requestDto.getTitle();
    this.image = requestDto.getImage();
    this.link = requestDto.getLink();
    this.lprice = requestDto.getLprice();
    this.myprice = 0;
}

boolean isValidUrl(String url)
{
    try {
        new URL(url).toURI();
        return true;
    }
    catch (URISyntaxException exception) {
        return false;
    }
    catch (MalformedURLException exception) {
        return false;
    }
}

 

[변경 후 ProductTest의 정상 케이스]

package com.sparta.myselectshop.entity;

import com.sparta.myselectshop.dto.ProductRequestDto;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;

class ProductTest {
    @Nested
    @DisplayName("회원이 요청한 관심상품 객체 생성")
    class CreateUserProduct {

        private Long userId;
        private String title;
        private String image;
        private String link;
        private int lprice;

        @BeforeEach
        void setup() {
            userId = 100L;
            title = "오리온 꼬북칩 초코츄러스맛 160g";
            image = "https://shopping-phinf.pstatic.net/main_2416122/24161228524.20200915151118.jpg";
            link = "https://search.shopping.naver.com/gate.nhn?id=24161228524";
            lprice = 2350;
        }

        @Test
        @DisplayName("정상 케이스")
        void createProduct_Normal() {
            // given
            ProductRequestDto requestDto = new ProductRequestDto(
                    title,
                    image,
                    link,
                    lprice
            );

            // when
            Product product = new Product(requestDto, userId);

            // then
            assertNull(product.getId());
            assertEquals(userId, product.getUserId());
            assertEquals(title, product.getTitle());
            assertEquals(image, product.getImage());
            assertEquals(link, product.getLink());
            assertEquals(lprice, product.getLprice());
            assertEquals(0, product.getMyprice());
        }
    }

}

 

[변경 후 ProductTest의 실패 케이스]

1. 회원 Id

더보기
package com.sparta.myselectshop.entity;

import com.sparta.myselectshop.dto.ProductRequestDto;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class ProductTest {
    @Nested
    @DisplayName("회원이 요청한 관심상품 객체 생성")
    class CreateUserProduct {

        private Long userId;
        private String title;
        private String image;
        private String link;
        private int lprice;

        @BeforeEach
        void setup() {
            userId = 100L;
            title = "오리온 꼬북칩 초코츄러스맛 160g";
            image = "https://shopping-phinf.pstatic.net/main_2416122/24161228524.20200915151118.jpg";
            link = "https://search.shopping.naver.com/gate.nhn?id=24161228524";
            lprice = 2350;
        }

        @Nested
        @DisplayName("실패 케이스")
        class FailCases {
            @Nested
            @DisplayName("회원 Id")
            class userId {
                @Test
                @DisplayName("null")
                void fail1() {
                    // given
                    userId = null;

                    ProductRequestDto requestDto = new ProductRequestDto(
                            title,
                            image,
                            link,
                            lprice
                    );

                    // when
                    Exception exception = assertThrows(IllegalArgumentException.class, () -> {
                        new Product(requestDto, userId);
                    });

                    // then
                    assertEquals("회원 Id 가 유효하지 않습니다.", exception.getMessage());
                }

                @Test
                @DisplayName("마이너스")
                void fail2() {
                    // given
                    userId = -100L;

                    ProductRequestDto requestDto = new ProductRequestDto(
                            title,
                            image,
                            link,
                            lprice
                    );

                    // when
                    Exception exception = assertThrows(IllegalArgumentException.class, () -> {
                        new Product(requestDto, userId);
                    });

                    // then
                    assertEquals("회원 Id 가 유효하지 않습니다.", exception.getMessage());
                }
            }
        }
    }

}

 

케이스 2개 다 제대로 예외를 잡았음을 알 수 있다.

🚨  assertEquals에서 메세지에 오타가 있거나 문구가 달라지면 실패한다.

 

 

 

2. 상품명

더보기
package com.sparta.myselectshop.entity;

import com.sparta.myselectshop.dto.ProductRequestDto;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class ProductTest {
    @Nested
    @DisplayName("회원이 요청한 관심상품 객체 생성")
    class CreateUserProduct {

        private Long userId;
        private String title;
        private String image;
        private String link;
        private int lprice;

        @BeforeEach
        void setup() {
            userId = 100L;
            title = "오리온 꼬북칩 초코츄러스맛 160g";
            image = "https://shopping-phinf.pstatic.net/main_2416122/24161228524.20200915151118.jpg";
            link = "https://search.shopping.naver.com/gate.nhn?id=24161228524";
            lprice = 2350;
        }

        @Nested
        @DisplayName("실패 케이스")
        class FailCases {
            @Nested
            @DisplayName("상품명")
            class Title {
                @Test
                @DisplayName("null")
                void fail1() {
                    // given
                    title = null;

                    ProductRequestDto requestDto = new ProductRequestDto(
                            title,
                            image,
                            link,
                            lprice
                    );

                    // when
                    Exception exception = assertThrows(IllegalArgumentException.class, () -> {
                        new Product(requestDto, userId);
                    });

                    // then
                    assertEquals("저장할 수 있는 상품명이 없습니다.", exception.getMessage());
                }

                @Test
                @DisplayName("빈 문자열")
                void fail2() {
                    // given
                    String title = "";

                    ProductRequestDto requestDto = new ProductRequestDto(
                            title,
                            image,
                            link,
                            lprice
                    );

                    // when
                    Exception exception = assertThrows(IllegalArgumentException.class, () -> {
                        new Product(requestDto, userId);
                    });

                    // then
                    assertEquals("저장할 수 있는 상품명이 없습니다.", exception.getMessage());
                }
            }
        }
    }

}

 

3. 상품 이미지 URL

더보기
package com.sparta.myselectshop.entity;

import com.sparta.myselectshop.dto.ProductRequestDto;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class ProductTest {
    @Nested
    @DisplayName("회원이 요청한 관심상품 객체 생성")
    class CreateUserProduct {

        private Long userId;
        private String title;
        private String image;
        private String link;
        private int lprice;

        @BeforeEach
        void setup() {
            userId = 100L;
            title = "오리온 꼬북칩 초코츄러스맛 160g";
            image = "https://shopping-phinf.pstatic.net/main_2416122/24161228524.20200915151118.jpg";
            link = "https://search.shopping.naver.com/gate.nhn?id=24161228524";
            lprice = 2350;
        }

        @Nested
        @DisplayName("실패 케이스")
        class FailCases {
            @Nested
            @DisplayName("상품 이미지 URL")
            class Image {
                @Test
                @DisplayName("null")
                void fail1() {
                    // given
                    image = null;

                    ProductRequestDto requestDto = new ProductRequestDto(
                            title,
                            image,
                            link,
                            lprice
                    );

                    // when
                    Exception exception = assertThrows(IllegalArgumentException.class, () -> {
                        new Product(requestDto, userId);
                    });

                    // then
                    assertEquals("상품 이미지 URL 포맷이 맞지 않습니다.", exception.getMessage());
                }

                @Test
                @DisplayName("URL 포맷 형태가 맞지 않음")
                void fail2() {
                    // given
                    image = "shopping-phinf.pstatic.net/main_2416122/24161228524.20200915151118.jpg";

                    ProductRequestDto requestDto = new ProductRequestDto(
                            title,
                            image,
                            link,
                            lprice
                    );

                    // when
                    Exception exception = assertThrows(IllegalArgumentException.class, () -> {
                        new Product(requestDto, userId);
                    });

                    // then
                    assertEquals("상품 이미지 URL 포맷이 맞지 않습니다.", exception.getMessage());
                }
            }
        }
    }

}

 

4. 상품 최저가 페이지 URL

더보기
package com.sparta.myselectshop.entity;

import com.sparta.myselectshop.dto.ProductRequestDto;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class ProductTest {
    @Nested
    @DisplayName("회원이 요청한 관심상품 객체 생성")
    class CreateUserProduct {

        private Long userId;
        private String title;
        private String image;
        private String link;
        private int lprice;

        @BeforeEach
        void setup() {
            userId = 100L;
            title = "오리온 꼬북칩 초코츄러스맛 160g";
            image = "https://shopping-phinf.pstatic.net/main_2416122/24161228524.20200915151118.jpg";
            link = "https://search.shopping.naver.com/gate.nhn?id=24161228524";
            lprice = 2350;
        }

        @Nested
        @DisplayName("실패 케이스")
        class FailCases {
            @Nested
            @DisplayName("상품 최저가 페이지 URL")
            class Link {
                @Test
                @DisplayName("null")
                void fail1() {
                    // given
                    link = "https";

                    ProductRequestDto requestDto = new ProductRequestDto(
                            title,
                            image,
                            link,
                            lprice
                    );

                    // when
                    Exception exception = assertThrows(IllegalArgumentException.class, () -> {
                        new Product(requestDto, userId);
                    });

                    // then
                    assertEquals("상품 최저가 페이지 URL 포맷이 맞지 않습니다.", exception.getMessage());
                }

                @Test
                @DisplayName("URL 포맷 형태가 맞지 않음")
                void fail2() {
                    // given
                    link = "https";

                    ProductRequestDto requestDto = new ProductRequestDto(
                            title,
                            image,
                            link,
                            lprice
                    );

                    // when
                    Exception exception = assertThrows(IllegalArgumentException.class, () -> {
                        new Product(requestDto, userId);
                    });

                    // then
                    assertEquals("상품 최저가 페이지 URL 포맷이 맞지 않습니다.", exception.getMessage());
                }
            }
        }
    }

}

 

5. 상품 최저가

더보기
package com.sparta.myselectshop.entity;

import com.sparta.myselectshop.dto.ProductRequestDto;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class ProductTest {
    @Nested
    @DisplayName("회원이 요청한 관심상품 객체 생성")
    class CreateUserProduct {

        private Long userId;
        private String title;
        private String image;
        private String link;
        private int lprice;

        @BeforeEach
        void setup() {
            userId = 100L;
            title = "오리온 꼬북칩 초코츄러스맛 160g";
            image = "https://shopping-phinf.pstatic.net/main_2416122/24161228524.20200915151118.jpg";
            link = "https://search.shopping.naver.com/gate.nhn?id=24161228524";
            lprice = 2350;
        }

        @Nested
        @DisplayName("실패 케이스")
        class FailCases {
            @Nested
            @DisplayName("상품 최저가")
            class LowPrice {
                @Test
                @DisplayName("0")
                void fail1() {
                    // given
                    lprice = 0;

                    ProductRequestDto requestDto = new ProductRequestDto(
                            title,
                            image,
                            link,
                            lprice
                    );

                    // when
                    Exception exception = assertThrows(IllegalArgumentException.class, () -> {
                        new Product(requestDto, userId);
                    });

                    // then
                    assertEquals("상품 최저가가 0 이하입니다.", exception.getMessage());
                }

                @Test
                @DisplayName("음수")
                void fail2() {
                    // given
                    lprice = -1500;

                    ProductRequestDto requestDto = new ProductRequestDto(
                            title,
                            image,
                            link,
                            lprice
                    );

                    // when
                    Exception exception = assertThrows(IllegalArgumentException.class, () -> {
                        new Product(requestDto, userId);
                    });

                    // then
                    assertEquals("상품 최저가가 0 이하입니다.", exception.getMessage());
                }
            }
        }
    }

}

 

✅ Mockito mock

ProdudctService 단위 테스트를 하기 전에!! Produdct가 단위 테스트를 통해 로직이 변경 되었다. 그리고 Service 또한 최저 금액을 설정해서 로직이 변경되었다. 그렇다면 어디를 테스트 해야하지?? Controller? Service? Repository? 근데 실행 했다고 해도 이게 Repository의 문제인지 Service의 문제인지 어떻게 알지??

 

이렇게 혼란스러울 때 가짜 객체(Mock object)를 이용해 주면 좋다.

 

✅ 가짜 객체 (Mock object) 

 

  • mockProductRepository
    • 실제 객체와 겉만 같은 객체. 동일한 클래스명, 함수명
    • 실제 DB 작업은 하지 않다.

예를들어 mockProductRepository를 만들고, 해당 리포지토리의 메서드를 호출하면 내가 미리 설정해둔 더미데이터만 반환하게 한다면, 우리는 서비스로직만을 배타적으로 테스트 할 수 있다.

 

이렇게 직접 가짜 객체를 만들어 주어도 되지만 내가 좀 더 편해지고 싶은데,,,,?? 해서 만들어진게 탸라~~

 

👑 Mockito mock 👑

 

build,gradle 에 의존성 추가해준다.

testImplementation 'org.mockito:mockito-core:4.8.0'
testImplementation 'org.mockito:mockito-junit-jupiter:4.8.0'

 

Product와 마찬가지로 ProductTest 만들어서 코드를 넣어준다.

package com.sparta.myselectshop.service;


import com.sparta.myselectshop.dto.ProductMypriceRequestDto;
import com.sparta.myselectshop.dto.ProductRequestDto;
import com.sparta.myselectshop.entity.Product;
import com.sparta.myselectshop.entity.User;
import com.sparta.myselectshop.repository.ProductRepository;
import org.junit.jupiter.api.DisplayName;
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 java.util.Optional;


import static com.sparta.myselectshop.service.ProductService.MIN_MY_PRICE;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class ProductServiceTest {
    @Mock //  (1) @Mock : 이 어노테이션을 붙이면 모킹할 객체라는 것을 의미
    ProductRepository productRepository;

    @InjectMocks //  (2) @InjectMock : Mock 어노테이션으로 모킹한 객체를 주입해주는 코드. 이제 서비스에 있는 리포지토리는 가짜 객체가 들어가게 된다.
    ProductService productService;

    @Mock
    User user;


    @Test
    @DisplayName("관심 상품 희망가 - 최저가 이상으로 변경 (정상)")
    void updateProduct_Success() {
        // given
        Long productId = 100L;
        int myprice = MIN_MY_PRICE + 100;
        Long userId = 777L;

        ProductMypriceRequestDto requestMyPriceDto = new ProductMypriceRequestDto(
                myprice
        );


        ProductRequestDto requestProductDto = new ProductRequestDto(
                "오리온 꼬북칩 초코츄러스맛 160g",
                "https://shopping-phinf.pstatic.net/main_2416122/24161228524.20200915151118.jpg",
                "https://search.shopping.naver.com/gate.nhn?id=24161228524",
                2350
        );

        Product product = new Product(requestProductDto, userId);

        //  (3) when() 메서드를 통해 모킹한 객체들이 특정 조건으로 특정 메서드를 호출하면 일괄적으로 다음과 같이 동작하도록 지정
        when(user.getId())
                .thenReturn(userId);
        when(productRepository.findByIdAndUserId(productId, userId))
                .thenReturn(Optional.of(product));


        // when, then : 실제로 내가 알아보고 싶은 부분을 돌려보는 것
        assertDoesNotThrow( () -> {
            productService.updateProduct(productId, requestMyPriceDto, user);
        });
    }

    @Test
    @DisplayName("관심 상품 희망가 - 최저가 미만으로 변경 (예외)")
    void updateProduct_Failed() {
        // given
        Long productId = 100L;
        int myprice = MIN_MY_PRICE - 50;

        ProductMypriceRequestDto requestMyPriceDto = new ProductMypriceRequestDto(
                myprice
        );

        // when
        Exception exception = assertThrows(IllegalArgumentException.class, () -> {
            Long result = productService.updateProduct(productId, requestMyPriceDto, user);
        });

        // then
        assertEquals(
                "유효하지 않은 관심 가격입니다. 최소 " + MIN_MY_PRICE + " 원 이상으로 설정해 주세요.",
                exception.getMessage()
        );
    }
}

제대로 작동 한다.

'Sparta > What I Learned' 카테고리의 다른 글

23.2.2  (0) 2023.02.03
23.2.1  (0) 2023.02.02
23.1.30  (0) 2023.01.30
23.1.29  (0) 2023.01.30
23.1.27  (0) 2023.01.30