[스파르타코딩클럽] Spring 심화반 - 3주차

[수업 목표]
1. 테스트의 필요성을 이해한다.
2. 스프링 테스트 프레임워크 이해 및 사용 학습한다.

01. 테스트의 필요성

'버그' (bug) 란?

출처: 위키백과

  • 소프트웨어가 예상하지 못한 결과를 내는 것
  • 버그는 '소스 코드''설계과정에서의 오류' 때문에 발생

개발 코드 배포 전, 버그를 (최대한 많이) 찾아내는 법

  1. 블랙박스 테스팅

    소프트웨어 내부 구조나 동작원리를 모르는 블랙박스와 같은 상태에서, 즉 웹 서비스의 사용자 입장에서 동작을 검사하는 방법

  • 장점
    • 누구나 테스트 가능 - 개발자부터 디자이너, 베타 테스터 혹은 사장님까지!
  • 단점
    • 기능이 증가될 수록 테스트의 범위 증가 (시간이 갈 수록 테스트하는 사람 계속 늘어야함)
    • 테스트 하는 사람에 따라 테스트 퀄리티가 다를 수 있음 (QA 직군이 있는 이유)
  1. 개발자 테스트

    개발자가 직접 "본인이 작성한 코드"를 검증하기 위해 "테스트 코드"를 작성

  • 장점
    • 빠르고 정확한 테스트 가능(예상 동작 vs 실제 동작)
    • 테스트 자동화 가능(배포 절차 시 테스트 코드가 수행되어 동작 검증)
    • 리팩토링 후 기존 동작에 대한 보증 수표!
  • 단점
    • 개발 시간이 오래걸림
    • 테스트 코드를 유지보수하는 비용

스프링에서 '테스트 코드' 작성을 잘 할 수 있는 환경 제공 해줌!


02. JUnit을 이용한 단위 테스트

단위 테스트란?

프로그램을 작은 단위로 쪼개서 각 단위가 정확하게 동작하는지 검사하고 이를 통해 문제 발생 시 정확하게 어느 부분이 잘못되었는 지를 재빨리 확인할 수 있게 해줌
출처: 단위 테스트(위키백과)

버그 발견 시간이 늦어짐에 따라 비용이 기하급수적으로 커짐

  1. Development : 개발
  2. Unit Tests(단위 테스트) : 개발자 테스트
  3. QA Testing
    • 블랙박스 테스팅
    • 주로 QA 팀이 Production 환경과 유사한 환경(Stage) 에서 테스팅
  4. Production : 실 서비스 운영 환경

1) JUnit 사용 설정

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

  • build.gradle 파일을 확인하면 JUnit 사용을 위한 환경 설정이 이미 되어있음.
dependencies {
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

test {
    useJUnitPlatform()
}

2) 테스트 파일 생성

  1. "Product.java" 파일 내에서 마우스 오른쪽 버튼 클릭 > "Generate..." 클릭
  2. "Test..." 클릭
  3. 기본세팅 그대로 OK 눌러서 생성

3) 테스트 코드 작성

  • model > ProductTest
import com.sparta.springcore.dto.ProductRequestDto;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

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

class ProductTest {
    // JUnit에서 제공하는 것, JUnit으로 테스트를 돌려주겠다
    // (함수 위에 붙임)
    @Test 
    // (없으면 Run할 때 함수 이름 표시됨)
    @DisplayName("정상 케이스") 
    void createProduct_Normal() {
        // 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
        );

        // when (이 코드를 실행하면 -> 이 테스트를 하기 위한 것임!)
        // [생성자 코드 검증]
        Product product = new Product(requestDto, userId);

        // then (결과로 이렇게 되어야한다)
        // 인자값이 null이 된다고 강하게 주장, Null 아니면 에러
        assertNull(product.getId()); 
        // 같다고 강하게 주장
        // (expected[테스트하고 되었으면 좋겠다고 기대한 값],
        // actual[실제 테스트하고 나서 그 결과를 넣는 것])
        assertEquals(userId, product.getUserId());
        assertEquals(title, product.getTitle());
        assertEquals(image, product.getImage());
        assertEquals(link, product.getLink());
        assertEquals(lprice, product.getLprice());
        assertEquals(0, product.getMyprice());
    }
}

4) 테스트 수행

  1. 함수 좌측의 실행 아이콘 클릭 후에 "Run.." 버튼 클릭
  • 클래스에 있는 실행 아이콘 : 모든 함수 테스트
  • 함수에 있는 실행 아이콘 : 함수에 대해서만 테스트
  1. 수행 결과
  2. 수행 결과가 한글로 표시되지 않는 경우
    [Intellij 메뉴] File - Settings - 검색창에 "gradle" 입력

03. Edge 테스트를 고려한 단위 테스트(1)

테스트 작성 시 Edge케이스를 고려하는 것이 굉장히 중요함

다양한 Edge 케이스

  • 입력 가능한 모든 케이스
// 회원 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)가 마이너스 값이면, 등록된 상품은 어떤 회원의 상품이 되는 거지?
    (DB 테이블 Id의 경우 마이너스 값을 가질 수 없음)

  2. 상품명

    1) 상품명이 null로 들어오면?
    2) 상품명이 빈 문자열인 경우도 저장해야 할까?
    -> 저장하면 UI에서는 어떻게 표시?

  3. 상품 이미지 URL

    1) 상품 이미지 URL이 null로 들어오면?
    2) 상품 이미지 URL이 URL 형태가 아니면?
    -> UI에는 어떻게 표시?

  4. 상품 최저가 페이지 URL

    1) 상품 최저가 URL이 null로 들어오면?
    2) 상품 최저가 페이지 URL이 URL 형태가 아니면?
    -> UI에는 어떻게 동작?

  5. 상품 최저가

    1) 상품 최저가가 0이면? (공짜 상품?)
    2) 상품 푀저가가 음수?

Edge 케이스 발견 후 처리 방법

Edge 케이스에 대해 개발자가 독단적으로 방향을 결정하지 않고, 관련 담당자(들)과 협의 진행 후 결정

ex)

  • 에러 발생으로 결정
    (정확한 에러문구 필요)
  • 빈 문자열 허용
    (DB에는 빈 문자열(""), UI에 표시할 때는 "알 수 없음"으로 표시(프론트 개발 필요)
  • 빈 이미지 허용
    (UI에 대체 이미지 표시(디자이너, 프론트 개발 작업 필요)

04. Edge 케이스를 고려한 단위 테스트(2)

Edge 케이스를 고려한 코드 수정

  • Product 생성자에서 입력값 Validation 진행

(ex)

if (userId == null || userId <= 0) {
	throw new IllegalArgumentException("회원 Id 가 유효하지 않습니다.");
}
  • URL 유효성 여부의 경우, 이미 만들어진 클래스 이용

(ex)

boolean isValidUrl(String url)
{
	try {
		new URL(url).toURI();
        return true;
    }
    catch (URISyntaxException exception) {
        return false;
    }
    catch (MalformedURLException exception) {
        return false;
    }
}
  • test > model > ProductTest
import com.sparta.springcore.dto.ProductRequestDto;
import org.junit.jupiter.api.*;

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

class ProductTest {

    @Nested // Depth로 트리 처럼 내려감
    @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());
        }

        @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
                    // 에러가 throw 되어야한다(리턴값, Exception, error명도 같아야함)
                    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());
                }
            }

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

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

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

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

리팩토링

클래스를 가져다 쓰는 방법 
-> @Component해서 빈으로 등록한 후 그 빈을 DI 받아서 사용하면됨
-> But! DI를 받을 수 있는 조건 : 스프링 Bean 끼리만 가능 
(Product는 빈이 아니라서 못 함)
-> 따라서, static으로 함수를 선언하면 클래스 이름 써서 호출 가능!
  • model > Product 리팩토링
  1. Product 생성자에서 수정한 Validation들 ProductValidator 클래스의 validateProductInput 이름의 메소드로 리팩토링
  2. isValidUrl메소드 URLValidator 클래스의 메소드로 리팩토링
  • validator > ProductValidator
    validateProductIput 메소드는 static으로 선언
  • validator > URLValidator
    isValidUrl 메소드는 static으로 선언
  • test > validator > URLValidatorTest
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

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

class URLValidatorTest {

    @Test
    @DisplayName("URL 형태: 정상")
    void urlValidator1() {
        // given
        String url = "https://shopping-phinf.pstatic.net/main_8232398/82323985017.4.jpg";

        // when
        boolean isValid = URLValidator.isValidUrl(url);

        // then
        // url 정상일 때는 항상 true로 나와야 함
        assertTrue(isValid);
    }

    @Test
    @DisplayName("URL 형태: 비정상 (null 인 경우)")
    void urlValidator2() {
        // given
        String url = null;

        // when
        boolean isValid = URLValidator.isValidUrl(url);

        // then
        assertFalse(isValid);
    }

    @Test
    @DisplayName("URL 형태: 비정상 (빈 문자열)")
    void urlValidator3() {
        // given
        String url = "";

        // when
        boolean isValid = URLValidator.isValidUrl(url);

        // then
        assertFalse(isValid);
    }

    @Test
    @DisplayName("URL 형태: 비정상 (일반 문자열)")
    void urlValidator4() {
        // given
        String url = "단위 테스트";

        // when
        boolean isValid = URLValidator.isValidUrl(url);

        // then
        assertFalse(isValid);
    }

    @Test
    @DisplayName("URL 형태: 비정상 (`://` 빠짐)")
    void urlValidator5() {
        // given
        String url = "httpfacebook.com";

        // when
        boolean isValid = URLValidator.isValidUrl(url);

        // then
        assertFalse(isValid);
    }
}

테스트 코드 결과 확인

  1. ProductTest 테스트 코드 수행
  2. URLValidatorTest 테스트 코드 수행

TDD (Test-Driven Development)?

  • AS-IS) 설계 -> 개발 -> 테스트 (-> 설계 수정) 순서를
  • TO-BE) 설계 -> 테스트 (-> 설계 수정) -> 개발로 변경

05. Mock object 직접 구현을 통한 단위 테스트

ProductService.java 의 단위 테스트 작성 시도

  • 상품 희망 최저가 업데이트 시
    • "최저가 입력 조건" 추가 : 최소 100원 이상
      int myprice = requestDto.getMyprice();
      if (myprice < MIN_MY_PRICE) {
      	throw new IllegalArgumentException("유효하지 않은 관심 가격입니다. 최소 " + MIN_MY_PRICE + " 원 이상으로 설정해 주세요.");
      }
    • 문제점 : ProductRepository에서 에러가 난다면 위의 테스트를 제대로 못함

Mock object (가짜 객체)?

이상적으로, 각 테스트 케이스는 서로 분리되어야 한다.
이를 위해 가짜 객체(Mock object)를 생성하는 것도 좋은 방법이다.
출처: 단위 테스트 (위키백과)

  1. 분리되기 어려운 클래스들
  • Controller, Service, Repository 클래스 각각 따로 테스트할 수 없을까?
  1. 가짜 객체(Mock object)를 통한 분리 방법
  • 가짜 객체 (Mock object) 로 분리
  • MockRepository
    • 실제 객체와 겉만 같은 객체!
      • 동일한 클래스명, 함수명
    • 실제 DB 작업은 하지 않음
      • DB 작업이 이뤄지는 것처럼~
      • 테스트를 위해 필요한 결과값을 return
  • 간단히 'mock'(목) 이라고 부르기로 함

Mock 직접 구현

  • test > mockobject > MockProductService
    • 단위 테스트는 스프링 빈을 사용하지 않음
    • MockProductRepository를 가리키도록 하기 위해 만들어야함
    • 이는 ProductService 기능 그대로 복사해서 가짜 객체는 아님
    • ex) this.mockProductRepository = new MockProductRepository();
  • test > mockobject > MockProductRepository
    • products(Product 배열)가 DB역할을 가짜로 해줌
    • save의 2가지 역할
      1. 상품 저장
      2. myPrice만 update함
        (save할 상품 Id 같으면 update로 생각한다)

06. Mockito mock을 이용한 단위 테스트

1) Mockito를 사용한 단위 테스트 구현

  • Mockito framework : Mock 객체를 쉽게 만들 수 있는 방법 제공
  • test > service > ProductServiceTest
    • @ExtendWith(MockitoExtension.class) : Mockito가 무언갈 해준다. (class 위에)
    • @Mock : Autowired처럼 가짜 객체가 자동으로 생성되는 구나 (ProductRepository 객체 생성 위에)

-> MockProductRepository, MockProductService 필요없음!

2) Mockito를 사용한 단위 테스트 실행 결과

에러!!! Mock을 선언만 하고 사용 케이스를 제대로 정의하지 못했기 때문!

ProductRespository에 없어서 에러 
-> Mock 객체에 대해 가짜함수를 호출해줄 수 있지만,
   그 결과에 대해 명시해줘야함
  • 사용 케이스 추가
    ProductServiceTest 클래스의 updateProduct_Normal() 안에
// input 이거면 output 이거 내줘라!!!!
when(productRepository.findById(productId)) 
// -> 함수 호출했을때 (Mock 객체에 대해서만)
	.thenReturn(Optional.of(product)) 
    // -> 결과로 Optional product(샘플)를 만들어서 반환해라!

07. 통합 테스트란?

단위 테스트 VS 통합 테스트

출처: Unit Testing: Creating Functional Alexa Skills

  1. 단위 테스트 (Unit Test)
  • 하나의 모듈이나 클래스에 대해 세밀한 부분까지 테스트 가능
  • 모듈 간에 상호 작용 검증 못함
  1. 통합 테스트 (Integration Test)
  • 두 개 이상의 모듈이 연결된 상태를 테스트
  • 모듈 간의 연결에서 발생하는 에러 검증 가능
  1. E2E 테스트 (End to End Test)
  • 실제 사용자의 실행 환경과 거의 동일한 환경에서 테스트 진행 (=블랙박스 테스팅)

08. 스프링 부트를 이용한 통합 테스트 - 설계

1) 스프링 부트를 이용한 통합 테스트

  • 통합 테스트
    • 여러 단위 테스트를 하나의 통합된 테스트로 수행
    • Controller -> Service -> Repository
  • 단위 테스트 시 스프링은 동작 안 됨
class ProductTest {

    @Autowired
    ProductService productService;

		// ...

	@Test
    @DisplayName("정상 케이스")
    void createProduct_Normal() {
		// ...

		// 에러 발생! productService 가 null
        Product productByService  = productService.createProduct(requestDto, userId);
  • "@SpringBootTest"
    • 스프링이 동작되도록 해주는 어노테이션!
    • 테스트 수행 시 스프링이 동작함
      • Spring IoC 사용 가능
      • Repository 사용해 DB CRUD 가능
    • End to End 테스트도 가능
      • Client 요청 -> Controller -> Service -> Repository -> Client 응답
  • @Order(1), @Order(2), ...
    • 테스트의 순서를 정할 수 있음

2) 관심상품 통합 테스트 설계

  1. 신규 관심상품 등록
    • 회원 Id는 임의의 값
  2. 신규 등록된 관심상품의 희망 최저가 변경
    • 1번에서 등록한 관심 상품의 희망 최저가를 변경
  3. 회원 Id로 등록된 모든 관심 상품 조회
    • 조회된 모든 관심상품 중 1번에서 등록한 관심상품이 존재하는지?
    • 2번에서 업데이트한 내용이 잘 반영되었는지?

09. 스프링 부트를 이용한 통합 테스트 - 구현

관심 상품 통합 테스트 구현

  • test > integration > ProductIntegrationTest
import com.sparta.springcore.dto.ProductMypriceRequestDto;
import com.sparta.springcore.dto.ProductRequestDto;
import com.sparta.springcore.model.Product;
import com.sparta.springcore.service.ProductService;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;

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

// @SpringBootTest 하면 스프링 뜰 때 포트 번호가 필요함(랜덤으로)
// (다른 애플리케이션 돌리고 있을 때 같은 포트로 돌리면 에러가 뜸)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
// @Order 쓰기 위해서 사용
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ProductIntegrationTest {
    @Autowired
    ProductService productService;

    Long userId = 100L;
    Product createdProduct = null;
    int updatedMyPrice = -1;

    @Test
    @Order(1)
    @DisplayName("신규 관심상품 등록")
    void test1() {
        // given
        String title = "Apple <b>에어팟</b> 2세대 유선충전 모델 (MV7N2KH/A)";
        String imageUrl = "https://shopping-phinf.pstatic.net/main_1862208/18622086330.20200831140839.jpg";
        String linkUrl = "https://search.shopping.naver.com/gate.nhn?id=18622086330";
        int lPrice = 77000;
        ProductRequestDto requestDto = new ProductRequestDto(
                title,
                imageUrl,
                linkUrl,
                lPrice
        );

        // when
        Product product = productService.createProduct(requestDto, userId); // 실제로 DB에 생성함(단위 테스트와는 다르게), Product == DB에 만들어져 가져온 데이터

        // then
        assertNotNull(product.getId());
        assertEquals(userId, product.getUserId());
        assertEquals(title, product.getTitle());
        assertEquals(imageUrl, product.getImage());
        assertEquals(linkUrl, product.getLink());
        assertEquals(lPrice, product.getLprice());
        assertEquals(0, product.getMyprice());
        createdProduct = product; // 멤버 변수에 넘겨서 Order(2)에서 사용!
    }

    @Test
    @Order(2)
    @DisplayName("신규 등록된 관심상품의 희망 최저가 변경")
    void test2() {
        // given
        Long productId = this.createdProduct.getId();
        int myPrice = 70000;
        ProductMypriceRequestDto requestDto = new ProductMypriceRequestDto(myPrice);

        // when
        Product product = productService.updateProduct(productId, requestDto);

        // then
        assertNotNull(product.getId());
        assertEquals(userId, product.getUserId());
        assertEquals(this.createdProduct.getTitle(), product.getTitle());
        assertEquals(this.createdProduct.getImage(), product.getImage());
        assertEquals(this.createdProduct.getLink(), product.getLink());
        assertEquals(this.createdProduct.getLprice(), product.getLprice());
        assertEquals(myPrice, product.getMyprice());
        this.updatedMyPrice = myPrice;
    }

    @Test
    @Order(3)
    @DisplayName("회원이 등록한 모든 관심상품 조회")
    void test3() {
        // given
        
        // when
        List<Product> productList = productService.getProducts(userId);

        // then
        // 1. 전체 상품에서 테스트에 의해 생성된 상품 찾아오기 (상품의 id 로 찾음)
        Long createdProductId = this.createdProduct.getId();
        Product foundProduct = productList.stream() // 전체 돌면서
                .filter(product ->  product.getId().equals(createdProductId)) // createdProductId랑 같은 거 찾아온다. (그것이 foundProduct에 들어옴)
                .findFirst()
                .orElse(null); // 이게 null이면 제대로 저장되지 않은 것 -> 에러!(테스트 코드 깨지는 것)
        
        // 2. Order(1) 테스트에 의해 생성된 상품과 일치하는지 검증
        assertNotNull(foundProduct);
        assertEquals(userId, foundProduct.getUserId());
        assertEquals(this.createdProduct.getId(), foundProduct.getId());
        assertEquals(this.createdProduct.getTitle(), foundProduct.getTitle());
        assertEquals(this.createdProduct.getImage(), foundProduct.getImage());
        assertEquals(this.createdProduct.getLink(), foundProduct.getLink());
        assertEquals(this.createdProduct.getLprice(), foundProduct.getLprice());
        
        // 3. Order(2) 테스트에 의해 myPrice 가격이 정상적으로 업데이트되었는지 검증
        assertEquals(this.updatedMyPrice, foundProduct.getMyprice());
    }
}

10. 스프링 MVC 테스트

스프링 MVC 테스트

  • build.gradle
testImplementation 'org.springframework.security:spring-security-test'
  • test > mvc > MockSpringSecurityFilter
    MVC 패키지 안에 가짜 사용자 만들기 위함
    (스프링의 필터 역할)
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

public class MockSpringSecurityFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) {}

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        SecurityContextHolder.getContext()
                .setAuthentication((Authentication) ((HttpServletRequest) req).getUserPrincipal());
        chain.doFilter(req, res);
    }

    @Override
    public void destroy() {
        SecurityContextHolder.clearContext();
    }
}
  • test > mvc > UserProductMvcTest
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sparta.springcore.controller.ProductController;
import com.sparta.springcore.controller.UserController;
import com.sparta.springcore.dto.ProductRequestDto;
import com.sparta.springcore.model.User;
import com.sparta.springcore.model.UserRoleEnum;
import com.sparta.springcore.security.UserDetailsImpl;
import com.sparta.springcore.security.WebSecurityConfig;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.FilterType;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.context.WebApplicationContext;

import java.security.Principal;

import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;

// Controller를 테스트하는 것, 이를 위해 HTTP 통신이 필요함
// (client 요청 결과 확인하는 것)
// View, Controller 테스트한다.
@WebMvcTest(
		// UserController, ProductController 따로 나눠도 됨
        controllers = {UserController.class, ProductController.class},
        excludeFilters = {
                @ComponentScan.Filter(
                        type = FilterType.ASSIGNABLE_TYPE,
                        classes = WebSecurityConfig.class
                )
        }
)
class UserProductMvcTest {
	// 스프링에서 제공해주는 MockMvc 사용해서 테스트를 진행할 것.
    private MockMvc mvc;

    private Principal mockPrincipal;

    @Autowired
    private WebApplicationContext context;

    @Autowired
    private ObjectMapper objectMapper;

	// 가짜 빈 DI (dependency 끊어내는 것)
    @MockBean
    UserService userService;

    @MockBean
    KakaoUserService kakaoUserService;

    @MockBean
    ProductService productService;

    @BeforeEach
    public void setup() {
    	// MockSpringSecurityFilter 사용해서 세팅
        // SpringSecurity에 가짜 사용자 정보를 넣어주기 위해 Filter 사용해서  Authentication 
        mvc = MockMvcBuilders.webAppContextSetup(context)
                .apply(springSecurity(new MockSpringSecurityFilter()))
                .build();
    }

    private void mockUserSetup() {
        // Mock 테스트 샘플 유저 생성
        String username = "제이홉";
        String password = "hope!@#";
        String email = "[email protected]";
        UserRoleEnum role = UserRoleEnum.USER;
        User testUser = new User(username, password, email, role);
        UserDetailsImpl testUserDetails = new UserDetailsImpl(testUser);
        // UsernamePasswordAuthenticationToken 안에 UserDetailsImpl이 있었고 그 안에 User가 있음
        // 최종적으로 만들어진 것을 mockPrincipal 보낼 때 담아서 보내줌(아래 test 3)
        // 테스트동안만 스프링에 로그인 되어있다고 이야기해주기 위해 사용
        // 이를 위해 MockSpringSecurityFilter 해주고, 모듈도 추가한 것
        mockPrincipal = new UsernamePasswordAuthenticationToken(testUserDetails, "", testUserDetails.getAuthorities());
    }

    @Test
    @DisplayName("로그인 view")
    void test1() throws Exception {
        // when - then
        mvc.perform(get("/user/login")) // 스프링 mvc 실행하는 데 이 주소로 실행하고 이 메소드로 실행한다.(HTTP 통신을 실제로 보내는 것, Controller로 들어감)
                .andExpect(status().isOk()) // HTTP의 status, isOk()면 200을 의미(HttpStatus.OK(200) -> HTTP 코드) 
                // 따라서 status code는 200이 나와야함
                .andExpect(view().name("login")) // 스프링 mvc에서 View를 넘겨줌 (return 에서 나온 View와 정확해야함)
                .andDo(print()); // HTTP의 header와 body를 프린트 해줌
    }

    @Test
    @DisplayName("회원 가입 요청 처리")
    void test2() throws Exception {
        // given
        // 회원가입 요청을 보내기 위해서는 @RequestParam -> 이것을 만들어주기 위해 폼 만들어줘야함(요청을 보내는 클라이언트 입장에서는 폼데이터)
        // 폼을 만들기 위해 signupRequestForm
        MultiValueMap<String, String> signupRequestForm = new LinkedMultiValueMap<>();
        signupRequestForm.add("username", "제이홉");
        signupRequestForm.add("password", "hope!@#");
        signupRequestForm.add("email", "[email protected]");
        signupRequestForm.add("admin", "false");

        // when - then
        mvc.perform(post("/user/signup")
                        .params(signupRequestForm) // 위에 만든 폼을 넘겨줌 (파라미터, 그것을 UserController에서 requestDto로 받아짐)
                )
                .andExpect(status().is3xxRedirection()) // redirect로 하는 경우 isOk HTTP 코드가 isOk가 아니라 redirect에 맞는 3으로 시작하는 코드로 타서 이 범위에 들어오면 redirection이라고 보는 것
                .andExpect(view().name("redirect:/user/login")) // View name 똑같은 거 넣는다
                .andDo(print());
    }

    @Test
    @DisplayName("신규 관심상품 등록")
    void test3() throws Exception {
        // given
        // 스프링이 떴는데 로그인 하지 않는 상태에서 신규 상품 등록 안됨 스프링 시큐리티 때문에 막혀서 
        // 따라서 mockUserSetup으로 가짜 사용자를 만듦.
        this.mockUserSetup(); // POST /api/products
        String title = "Apple <b>에어팟</b> 2세대 유선충전 모델 (MV7N2KH/A)";
        String imageUrl = "https://shopping-phinf.pstatic.net/main_1862208/18622086330.20200831140839.jpg";
        String linkUrl = "https://search.shopping.naver.com/gate.nhn?id=18622086330";
        int lPrice = 77000;
        ProductRequestDto requestDto = new ProductRequestDto(
                title,
                imageUrl,
                linkUrl,
                lPrice
        );

		// POST 요청 시에 RequestBody json으로 받아야함 (폼형태가 아님)
        String postInfo = objectMapper.writeValueAsString(requestDto);

        // when - then
        mvc.perform(post("/api/products")
                        .content(postInfo) // json 형태를 담은 string을 content에 넣음(HTTP Body 부분)
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaType.APPLICATION_JSON) // JSON 형태로 받을 수 있어를 서버에게 알려줌
                        .principal(mockPrincipal)
                )
                .andExpect(status().isOk()) // 가짜로 ProductController 돌았다 치고 응답 온 것 가지고 isOk로 200 나오면 정상
                .andDo(print()); 
    }
}

그 외 다양한 Spring Boot 테스트 Annotaiton


11. 기존 관심상품 통합테스트에 회원가입 과정도 포함시키기

  • 기존 코드) ProductIntegrationTest
  1. 관심상품 등록
  2. 관심상품 업데이트
  3. 관심상품 조회
  • UserProductionIntegrationTest
  1. 회원 가입 전 관심상품 등록 (실패)
  2. 회원 가입
  3. 가입된 회원으로 관심상품 등록
  4. 관심상품 업데이트
  5. 관심상품 조회
  • test > integration > UserProductIntegrationTest
import com.sparta.springcore.dto.ProductMypriceRequestDto;
import com.sparta.springcore.dto.ProductRequestDto;
import com.sparta.springcore.dto.SignupRequestDto;
import com.sparta.springcore.model.Product;
import com.sparta.springcore.model.User;
import com.sparta.springcore.model.UserRoleEnum;
import com.sparta.springcore.service.ProductService;
import com.sparta.springcore.service.UserService;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.crypto.password.PasswordEncoder;

import java.util.List;

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

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class UserProductIntegrationTest {

    @Autowired
    UserService userService;

    @Autowired
    PasswordEncoder passwordEncoder;

    @Autowired
    ProductService productService;

    Long userId = null;
    Product createdProduct = null;
    int updatedMyPrice = -1;

    @Test
    @Order(1)
    @DisplayName("회원 가입 정보 없이 상품 등록 시 에러발생")
    void test1() {
        // given
        String title = "Apple <b>에어팟</b> 2세대 유선충전 모델 (MV7N2KH/A)";
        String imageUrl = "https://shopping-phinf.pstatic.net/main_1862208/18622086330.20200831140839.jpg";
        String linkUrl = "https://search.shopping.naver.com/gate.nhn?id=18622086330";
        int lPrice = 77000;
        ProductRequestDto requestDto = new ProductRequestDto(
                title,
                imageUrl,
                linkUrl,
                lPrice
        );

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

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

    @Test
    @Order(2)
    @DisplayName("회원 가입")
    void test2() {
        // given
        String username = "르탄이";
        String password = "nobodynoboy";
        String email = "[email protected]";
        boolean admin = false;

        SignupRequestDto signupRequestDto = new SignupRequestDto();
        signupRequestDto.setUsername(username);
        signupRequestDto.setPassword(password);
        signupRequestDto.setEmail(email);
        signupRequestDto.setAdmin(admin);

        // when
        User user = userService.registerUser(signupRequestDto);

        // then
        assertNotNull(user.getId());
        assertEquals(username, user.getUsername());
        assertTrue(passwordEncoder.matches(password, user.getPassword()));
        assertEquals(email, user.getEmail());
        assertEquals(UserRoleEnum.USER, user.getRole());

        userId = user.getId();
    }

    @Test
    @Order(3)
    @DisplayName("가입한 회원 Id 로 신규 관심상품 등록")
    void test3() {
        // given
        String title = "Apple <b>에어팟</b> 2세대 유선충전 모델 (MV7N2KH/A)";
        String imageUrl = "https://shopping-phinf.pstatic.net/main_1862208/18622086330.20200831140839.jpg";
        String linkUrl = "https://search.shopping.naver.com/gate.nhn?id=18622086330";
        int lPrice = 77000;
        ProductRequestDto requestDto = new ProductRequestDto(
                title,
                imageUrl,
                linkUrl,
                lPrice
        );

        // when
        Product product = productService.createProduct(requestDto, userId);

        // then
        assertNotNull(product.getId());
        assertEquals(userId, product.getUserId());
        assertEquals(title, product.getTitle());
        assertEquals(imageUrl, product.getImage());
        assertEquals(linkUrl, product.getLink());
        assertEquals(lPrice, product.getLprice());
        assertEquals(0, product.getMyprice());
        createdProduct = product;
    }

    @Test
    @Order(4)
    @DisplayName("신규 등록된 관심상품의 희망 최저가 변경")
    void test4() {
        // given
        Long productId = this.createdProduct.getId();
        int myPrice = 70000;
        ProductMypriceRequestDto requestDto = new ProductMypriceRequestDto(myPrice);
        // when
        Product product = productService.updateProduct(productId, requestDto);
        // then
        assertNotNull(product.getId());
        assertEquals(userId, product.getUserId());
        assertEquals(this.createdProduct.getTitle(), product.getTitle());
        assertEquals(this.createdProduct.getImage(), product.getImage());
        assertEquals(this.createdProduct.getLink(), product.getLink());
        assertEquals(this.createdProduct.getLprice(), product.getLprice());
        assertEquals(myPrice, product.getMyprice());
        this.updatedMyPrice = myPrice;
    }

    @Test
    @Order(5)
    @DisplayName("회원이 등록한 모든 관심상품 조회")
    void test5() {
        // given
        // when
        List<Product> productList = productService.getProducts(userId);
        // then
        // 1. 전체 상품에서 테스트에 의해 생성된 상품 찾아오기 (상품의 id 로 찾음)
        Long createdProductId = this.createdProduct.getId();
        Product foundProduct = productList.stream()
                .filter(product -> product.getId().equals(createdProductId))
                .findFirst()
                .orElse(null);
        // 2. Order(1) 테스트에 의해 생성된 상품과 일치하는지 검증
        assertNotNull(foundProduct);
        assertEquals(userId, foundProduct.getUserId());
        assertEquals(this.createdProduct.getId(), foundProduct.getId());
        assertEquals(this.createdProduct.getTitle(), foundProduct.getTitle());
        assertEquals(this.createdProduct.getImage(), foundProduct.getImage());
        assertEquals(this.createdProduct.getLink(), foundProduct.getLink());
        assertEquals(this.createdProduct.getLprice(), foundProduct.getLprice());
        // 3. Order(2) 테스트에 의해 myPrice 가격이 정상적으로 업데이트되었는지 검증
        assertEquals(this.updatedMyPrice, foundProduct.getMyprice());
    }
}
  • service > UserService
import com.sparta.springcore.dto.SignupRequestDto;
import com.sparta.springcore.model.User;
import com.sparta.springcore.model.UserRoleEnum;
import com.sparta.springcore.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
public class UserService {
    private final PasswordEncoder passwordEncoder;
    private final UserRepository userRepository;
    private static final String ADMIN_TOKEN = "AAABnv/xRVklrnYxKZ0aHgTBcXukeZygoC";

    @Autowired
    public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
    }

    public User registerUser(SignupRequestDto requestDto) {
        // 회원 ID 중복 확인
        String username = requestDto.getUsername();
        Optional<User> found = userRepository.findByUsername(username);
        if (found.isPresent()) {
            throw new IllegalArgumentException("중복된 사용자 ID 가 존재합니다.");
        }

        // 패스워드 암호화
        String password = passwordEncoder.encode(requestDto.getPassword());
        String email = requestDto.getEmail();

        // 사용자 ROLE 확인
        UserRoleEnum role = UserRoleEnum.USER;
        if (requestDto.isAdmin()) {
            if (!requestDto.getAdminToken().equals(ADMIN_TOKEN)) {
                throw new IllegalArgumentException("관리자 암호가 틀려 등록이 불가능합니다.");
            }
            role = UserRoleEnum.ADMIN;
        }

        User user = new User(username, password, email, role);
        userRepository.save(user);
        return user;
    }
}

12. 정리

  1. 블랙 박스 테스팅
  2. 개발자 테스트
    a. UI 테스팅
    b. API 호출앱 사용 (ex. Advanced REST Client, Postman)
    c. 스프링의 테스트 프레임워크 사용

마치며

실제 현업에서는 테스트 코드가 매우 중요하다고 한다. 내가 프로젝트할 때도 써먹을 때가 있었으면 한다. 그리고 강의는 다 들었는데 정리를 미루고 있다. 빨리 정리하자!!!! 아즈아😬
출처: 스파르타코딩클럽


4주차

[스파르타코딩클럽] Spring 심화반 - 4주차


좋은 웹페이지 즐겨찾기