0. 개요
테스트의 필요성 : 개발은 어려우며 소프트웨어는 예상치 못한 결과(버그)를 냅니다. 버그는 소스 코드나 설계 과정에서의 오류에의해 발생합니다.
버그는 :
- 사용자에게 불편함을 줌 : 일부 기능 작동 X, 의도와 다르게 동작, 전체 기능 동작X
- 회사에 악영향 : 매출감소, 신뢰도 감소, 개인유출 가능성
- 개발자의 주말 없는 삶, 휴가 없는 삶, 저녁 없는 삶을 선사
버그를 (최대한 많이) 줄이는 법 :
- 블랙 박스 테스팅 : 웹 서비스 사용자 누구나 테스트를 진행. 단, 기능이 증가할 수록 테스트의 범위 증가, 테스트 퀄리티의 문제(QA)
- 개발자 테스트 : 직접 본인이 작성한 코드를 검증하기위해 테스트 코드를 작성합니다. 빠르고 정확한 테스트 가능(예상동작 VS 실제동작), 테스트 자동화(배포 절차 시 동작검증), 리팩토링 후 기존 동작을 보증. 단, 개발 시간 증가, 테스트 코드 유지보수비용
- 스프링 테스트 프레임워크 : JUNIT
1. JUnit 단위테스트 일단 만들어보기
단위테스트란? : 프로그램을 작은 단위로 쪼개어 각 단위 별로 정확하게 동작하는지 검사하고 이를 통해 문제 발생 시 어느 지점에서 잘못되었는가를 빠르게 확인합니다. 버그의 발견 시간이 늦어질수록 비용은 기하급수적으로 증가하기 때문에 단위테스트는 매우 중요합니다.
JUnit? : 자바 언어용 테스트 프레임워크
- build.gradle : 따로 추가하지 않아도 이미 들어가 있으니 우리는 테스트만 하자.
dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
테스트 파일 생성하기 :
- 테스트할 파일 찾기 : (단축키) Shift 두 번 -> 파일
- 파일 내 우클릭 -> Generate(생성) -> test(테스트)
: test 폴더에 파일과 동일한 패키지 구조를 유지해 테스트 클래스를 생성하며 클릭 몇 번으로 메소드를 포함 시킬 수 있다.
- 테스트 클래스 작성 예) ProductTest.java :
class ProductTest {
@Test
@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 product = new Product(requestDto, userId);
// then : 이러이러한 조건들을 충족해야 합니다.
// product를 검증합니다.
// assert의 사전적 뜻은 '사실임을 강하게 주장'입니다.
assertNull(product.getId()); // null이어야 에러를 발생하지 않고 정상적으로 봅니다.
assertEquals(userId, product.getUserId()); // 같아야 에러를 발생하지 않고 정상적으로 봅니다.
assertEquals(title, product.getTitle()); // expected와 actual을 비교합니다.
assertEquals(image, product.getImage());
assertEquals(link, product.getLink());
assertEquals(lprice, product.getLprice());
assertEquals(0, product.getMyprice());
// 정책상 바뀐 결정은 테스트 코드에도 반영이 되어야 하기 때문에 높은 비용을 요구합니다.
}
}
- 한글 깨짐 : 설정 > 빌드, 실행, 배포 > 빌드 도구 > Gradle > 다음을 사용하여 테스트 실행에 IntelliJ로 설정
2. Edge 케이스를 고려한 단위 테스트
입력 가능한 모든 케이스를 고려하는 것은 참으로 복잡하구나의 예 :
- 회원 ID : null 이나 음수인 경우 등록된 상품은 어떤 회원의 상품이며 어떻게 저장을 해야 하는가?
- 상품명 : null 이나 빈 문자열인 경우 어떻게 저장을 해야 하며 UI에는 어떻게 표시해야 하는가?
- 상품 이미지 URL : null 이거나 URL 형태가 아닌 경우 어떻게 저장을 해야 하며 UI는 어떻게 표시해야 하는가?
- 상품 최저가 페이지 URL : null 이거나 URL 형태가 아니라면?
- 상품 최저가 : null, 0, 음수라면?
Edge 케이스를 발견하기도, 처리하기도 참으로 어렵고 버그는 끊임이 없다.
따라서,
Edge 케이스를 발견한 경우, 개발자 독단적으로 방향을 결정하지 않고, 관련 담당자(관련 개발팀, 기획자, 디자이너, QA)들과 협의 진행 후 결정해야 한다.
예를들어,
- 에러 발생으로 결정 :
- 정확한 에러 문구 필요 (문구의 통일성, 글로벌 시스템의 경우 번역을 고려)
- 부적절한 문구의 예) "빈...문자열...을...입력하시면 아니되시와요"
- 빈 문자열을 허용하기로 결정:
- DB에 빈 문자열("")을 저장
- UI에 "알 수 없음"으로 표시 -> 프론트엔드 작업 필요
- 빈 이미지 허용
- UI에 대체할 이미지를 표시 -> 디자이너, 프론트엔드 작업 필요
그래서 엣지 케이스를 고려한 단위테스트 작성하기!!! Product 생성자에 다음과 같은 엣지 케이스를 반영했습니다.
@Entity
public class Product {
...
// 관심 상품 생성 시 이용합니다.
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 이하입니다.");
}
this.userId = userId; // 관심상품을 등록한 회원 Id 저장
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;
}
}
}
테스트 코드는 다음과 같이 작성합니다.
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());
}
@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());
}
}
@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());
}
}
}
}
}
- @Nested : 테스트할 코드들을 묶어줍니다. class로 작성합니다.
- @Test : 실제 테스트를 진행하는 코드입니다. 반환형이 void인 함수로 작성합니다.
- @DisplayName("표시할 이름") : @Nested와 @Test의 표시할 이름을 작성합니다.
- @BeforeEach : 각 @Test를 수행하기 이전에 실행합니다. 멤버변수를 새로이 초기화 하는 코드가 작성 되어 있습니다.
- 에러 케이스 테스트 : assertThrows로 에러의 클래스를 검증하고 에러 메시지를 확인하고 있습니다. 에러 메시지 검증은 불필요한 비용을 증가시키기 때문에 좋은 검증이라 볼 수는 없습니다. 따라서 "InvalidUserIdValidException" 과 같이 에러 의미를 담은 고유한 에러를 생성하고 이를 검증하는 것이 더 좋습니다.
@Test
@DisplayName("null")
void fail1() {
// given
userId = null;
ProductRequestDto requestDto = new ProductRequestDto(title, image, link, lprice);
// when
Exception exception = assertThrows(InvaildUserIdValidException.class, () -> {
new Product(requestDto, userId);
});
// then
assertEquals("회원 Id 가 유효하지 않습니다.", exception.getMessage());
}
public class InvaildUserIdValidException extends IllegalArgumentException {
public InvaildUserIdValidException(String message) {
super(message);
}
}
07-04 예외처리 (Exception)
프로그램을 만들다 보면 수없이 많은 오류가 발생한다. 물론 오류가 발생하는 이유는 프로그램이 오동작을 하지 않기 하기 위한 자바의 배려이다. 하지만 때로는 이러한 오류를 무시하고…
wikidocs.net
custom exception을 언제 써야 할까?
우아한테크코스의 두 크루인 오렌지와 우가 싸우고 있다. 왜 싸우고 있는지 알아보러 가볼까? 오렌지 : 아니 굳이 사용자 정의 예외 안 써도 됩니다!! 우 : 아닙니다!! 써야 합니다!!! 사용자 정의
tecoble.techcourse.co.kr
3. TDD (Test-Driven Development)
- AS-IS 단계 : 설계 > 개발 > 테스트(설계수정) > 설계 수정
- TO-BE 단계 : 설계 -> 테스트(설계수정) -> 개발
개발이 진행함에 따라 Edge Case가 발생할 경우 테스트 코드를 작성해 리팩토링을 진행합니다. 현업에서는 팀에 따라 TDD를 적용할수도 아닐 수도 있습니다.
4. Mock object (가짜 객체)
배경 : 단위 테스트를 진행함에 있어서 이상적으로 각 테스트 케이스가 분리되어 있는 것이 좋다. 하지만 비즈니스 로직을 처리하는 서비스 레이어에서는 Controller로부터 전달 받은 객체들의 유효성 검증과 더불어 데이터를 저장 하는 등 여러 기능들의 의존되어 있기 때문에 테스트 케이스들을 분리하기에 어려움이 있다. 예를들어,
컨트롤러의 역할만 테스트를 진행하고 싶은 경우 생성자 주입을 받고 있는 Service와 Repository의 테스트를 모두 진행 해야만 한다. 마찬가지로 서비스의 역할만 테스트를 진행하고 싶은 경우에도 레포지토리의 테스트를 함께 진행해야한다.
이러한 의존 관계에의해 테스트 범위가 비대해지는 경우 가짜객체(Mock object)를 통해 테스트를 분리 할 수 있다.
MockRepository는 실제적인 DB작업을 하지 하지 않지만 클래스 명과 함수 명이 동일한 클래스이며 테스트를 위해 필요한 반환값을 지니고 있다.
테스트할 서비스는 mock object인 mockRepository를 사용하고 있기 때문에 서비스 명 역시도 MockService로 선언하겠습니다.
Mock 구현 : MockProductService는 ProductService와 기능적으로 동일합니다. (MockProductService를 복사하고 ProductService에도 동일한 범위를 선택한 뒤 클립보드와 비교 기능을 통해 기능적 차이를 확인할 수 있습니다.)
public class MockProductService {
private final MockProductRepository mockProductRepository;
public static final int MIN_MY_PRICE = 100;
public MockProductService() {
this.mockProductRepository = new MockProductRepository();
}
public Product createProduct(ProductRequestDto requestDto, Long userId) {
// 요청받은 DTO 로 DB에 저장할 객체 만들기
Product product = new Product(requestDto, userId);
mockProductRepository.save(product);
return product;
}
public Product updateProduct(Long id, ProductMypriceRequestDto requestDto) {
int myprice = requestDto.getMyprice();
if (myprice < MIN_MY_PRICE) {
throw new IllegalArgumentException("유효하지 않은 관심 가격입니다. 최소 " + MIN_MY_PRICE + " 원 이상으로 설정해 주세요.");
}
Product product = mockProductRepository.findById(id)
.orElseThrow(() -> new NullPointerException("해당 아이디가 존재하지 않습니다."));
product.setMyprice(myprice);
mockProductRepository.save(product);
return product;
}
// 회원 ID 로 등록된 상품 조회
public List<Product> getProducts(Long userId) {
return mockProductRepository.findAllByUserId(userId);
}
// (관리자용) 상품 전체 조회
public List<Product> getAllProducts() {
return mockProductRepository.findAll();
}
}
public class MockProductRepository {
private List<Product> products = new ArrayList<>();
// 상품 테이블 ID: 1부터 시작
private Long productId = 1L;
// 상품 저장
public Product save(Product product) {
for (Product existProduct : products) {
// 이미 저장된 상품 -> 희망 최저가 업데이트
if (existProduct.getId().equals(product.getId())) {
int myprice = product.getMyprice();
existProduct.setMyprice(myprice);
return existProduct;
}
}
// 신규 상품 -> DB 에 저장
product.setId(productId);
++productId;
products.add(product);
return product;
}
// 상품 ID 로 상품 조회
public Optional<Product> findById(Long id) {
for (Product product : products) {
if (product.getId().equals(id)) {
return Optional.of(product);
}
}
return Optional.empty();
}
// 회원 ID 로 등록된 상품 조회
public List<Product> findAllByUserId(Long userId) {
List<Product> userProducts = new ArrayList<>();
for (Product product : products) {
if (product.getId().equals(userId)) {
userProducts.add(product);
}
}
return userProducts;
}
// (관리자용) 상품 전체 조회
public List<Product> findAll() {
return products;
}
}
- MockRepository는 기존의 ProductRepository의 DI, DB를 끊어 만든 가짜 레포지토리입니다. 서비스에서 사용된 동일한 이름의 메서드들을 정의하고 있으며 이를 DB대신 메모리에 저장하고 있습니다. 이렇듯 기능적으로 동일하나 가짜 클래스를 만들어 주어 의존성을 가진 레이어의 단위 테스트를 분리해 진행할 수 있습니다.
앞서 진행하려던 ProductService의 유효하지 않은 가격에대한 테스트 코드는 다음과 같습니다.
만약 ProductService에 변화가 생긴다면 이전에 "클립보드와 비교" 기능을 통해 Mock Object를 쉽게 수정해 줄 수 있을 것입니다.
public class MockProductServiceTest {
@Test
@DisplayName("관심 상품 희망가 - 최저가 이상으로 변경")
void updateProduct_Normal() {
// given
int myprice = MIN_MY_PRICE + 1000;
ProductMypriceRequestDto requestMyPriceDto = new ProductMypriceRequestDto(
myprice
);
Long userId = 777L;
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
);
MockProductService mockProductService = new MockProductService();
// 회원의 관심상품을 생성
Product product = mockProductService.createProduct(requestProductDto, userId);
// when
Product result = mockProductService.updateProduct(product.getId(), requestMyPriceDto);
// then
assertEquals(myprice, result.getMyprice());
}
@Test
@DisplayName("관심 상품 희망가 - 최저가 미만으로 변경")
void updateProduct_Failed() {
// given
int myprice = MIN_MY_PRICE - 50;
ProductMypriceRequestDto requestMyPriceDto = new ProductMypriceRequestDto(
myprice
);
Long userId = 777L;
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
);
MockProductService mockProductService = new MockProductService();
// 회원의 관심상품을 생성
Product product = mockProductService.createProduct(requestProductDto, userId);
// when
Exception exception = assertThrows(IllegalArgumentException.class, () -> {
mockProductService.updateProduct(product.getId(), requestMyPriceDto);
});
// then
assertEquals(
"유효하지 않은 관심 가격입니다. 최소 " + MIN_MY_PRICE + " 원 이상으로 설정해 주세요.",
exception.getMessage()
);
}
하지만 여전히 Mock Object를 작성해 주는 것은 번거롭고 비용이 높습니다.
그래서, Mock Object를 생성해주는 프레임워크가 있습니다.
5. Mockito (Mock Object 프레임워크)
Mockito를 사용하기위해 @ExtendWith(MockitoExtension.class) 어노테이션을 추가합니다.
이전에 DI를 대신해 생성해준 MockProductRepository 대신 간단하게도 @Mock 어노테이션을 추가하면 DB, DI 사슬을 끊고 Mock 객체를 생성해 줍니다.
이후 테스트의 과정은 동일합니다. 단, productService.updateProduct(productId, requestMyPriceDto) 에서 throws가 발생하고 있기 때문에 Mock 의 사용 케이스를 추가합니다. 사용 케이스 추가는 매우 간단하게도 Mokito의 when(),thenReturn() 한 줄로 명시해줄 수 있습니다.
@ExtendWith(MockitoExtension.class)
class ProductServiceTest {
@Mock
ProductRepository productRepository;
@Test
@DisplayName("관심 상품 희망가 - 최저가 이상으로 변경")
void updateProduct_Normal() throws SQLException {
// given
Long productId = 100L;
int myprice = MIN_MY_PRICE + 1000;
ProductMypriceRequestDto requestMyPriceDto = new ProductMypriceRequestDto(
myprice
);
Long userId = 777L;
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);
ProductService productService = new ProductService(productRepository);
// Mock 사용자 케이스 추가
when(productRepository.findById(productId))
.thenReturn(Optional.of(product));
// when
Product result = productService.updateProduct(productId, requestMyPriceDto);
// then
assertEquals(myprice, result.getMyprice());
}
@Test
@DisplayName("관심 상품 희망가 - 최저가 미만으로 변경")
void updateProduct_Failed() {
// given
Long productId = 100L;
int myprice = MIN_MY_PRICE - 50;
ProductMypriceRequestDto requestMyPriceDto = new ProductMypriceRequestDto(
myprice
);
ProductService productService = new ProductService(productRepository);
// when
Exception exception = assertThrows(IllegalArgumentException.class, () -> {
productService.updateProduct(productId, requestMyPriceDto);
});
// then
assertEquals(
"유효하지 않은 관심 가격입니다. 최소 " + MIN_MY_PRICE + " 원 이상으로 설정해 주세요.",
exception.getMessage()
);
}
}
6. 통합 테스트
테스트에는 단위 테스트 말고도 통합테스트, E2E 테스트가 있습니다. 각각에대해 하나 하나씩 살펴 보도록 하겠습니다.
- 단위 테스트 (Unit Test) : 하나의 모듈, 클래스에 대해 세밀한 부분까지 테스트를 진행합니다. 매우 높은 효율성을 가지고 있지만 모듈 간 상호 작요을 검증하지 못합니다.
- 통합 테스트 : 두 개 이상의 모듈이 연결이 된 상태에서 테스트를 진행합니다. 모듈 간 연결을 테스트할 수 있습니다.
- E2E 테스트 : 사용자와 거의 동일한 상황에서 테스트를 진행합니다. 블랙박스 테스트라고도 합니다.
6. 스프링 부트를 이용한 통합 테스트
- 통합테스트란? : 여러 단위 테스트를 하나의 통합된 테스트로 수행합니다. Controller - Service - Repository
- @SpringBootTest : 기존 테스트는 스프링이 동작하지 않습니다. 해당 어노테이션을 사용하면 스프링이 동작합니다. 즉, Spring IoC, Repository CRUD, E2E 테스트(Http 통신까지 포함)을 진행 할 수 있게 도와줍니다. (Client 요청 - Controller - Service - Repository - Client 응답)
- @Order(1) : 테스트의 순서를 정해줄 수 있습니다.
- 통합 테스트 설계 :
ProductService - ProductRepository 모듈 테스트 :
1) 관심 상품 등록 : 임의의 회원 id로 DB에 저장
2) 신규 등록된 관심 상품의 희망 최저가 변경
3) 회원 id로 등록된 모든 관심 상품 조회 : 1번에 등록한 관심 상품이 존재하는지, 2번에 업데이트한 내용이 잘 반영 되었는지 검증
- 통합 테스트 구현 :
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@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);
// 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(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))
.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());
}
}
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
- @SpringBootTest : 스프링 부트 모듈 테스트를 제공합니다. 포트 번호를 랜덤으로 설정했습니다.
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
- @TestMethodOrder : @Order 메소드를 활성화 합니다.
@Autowired
ProductService productService;
- 이제 ProductService를 DI해 줄 수 있습니다.
7. 스프링 MVC 테스트
- build.gradle에 스프링 시큐리티 testImplementation 명시
testImplementation 'org.springframework.security:spring-security-test'
- 가짜 Security Filter 추가
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();
}
}
- 테스트 코드 :
@WebMvcTest(
// 컨트롤러를 하나로 합쳤습니다. (권장되지 않습니다.)
controllers = {UserController.class, ProductController.class},
excludeFilters = {
@ComponentScan.Filter(
type = FilterType.ASSIGNABLE_TYPE,
classes = WebSecurityConfig.class
)
}
)
class UserProductMvcTest {
private MockMvc mvc; // 테스트를 진행할 객체입니다.
private Principal mockPrincipal; //로그인한 사용자로 인식할 가짜 객체입니다.
@Autowired
private WebApplicationContext context;
@Autowired
private ObjectMapper objectMapper;
// 기존 service 객체의 DI를 끊어주고 대신 Mock 객체로 전환해줍니다. "했다 치고" 기능을 제공합니다.
@MockBean
UserService userService;
@MockBean
KakaoUserService kakaoUserService;
@MockBean
ProductService productService;
@BeforeEach
public void setup() {
mvc = MockMvcBuilders.webAppContextSetup(context)
.apply(springSecurity(new MockSpringSecurityFilter())) // testImplementation springsecurity에 포함되어 있습니다.
.build();
}
private void mockUserSetup() {
// Mock 테스트 유져 생성
String username = "제이홉";
String password = "hope!@#";
String email = "hope@sparta.com";
UserRoleEnum role = UserRoleEnum.USER;
User testUser = new User(username, password, email, role);
UserDetailsImpl testUserDetails = new UserDetailsImpl(testUser);
// 가짜 로그인 사용자 정책 생성
mockPrincipal = new UsernamePasswordAuthenticationToken(testUserDetails, "", testUserDetails.getAuthorities());
}
@Test
@DisplayName("로그인 view")
void test1() throws Exception {
// when - then
// .perform은 http요청을 보내는 상황을 보여주고 있습니다.
mvc.perform(get("/user/login")) // 로그인 화면을 보여주는 get 요청
.andExpect(status().isOk()) // 200 코드가 나와야 합니다.
.andExpect(view().name("login")) // 로그인 view를 반환해야 합니다.
.andDo(print()); // 위 결과를 출력합니다.
// userController를 보면 service가 수행되고 있으나 우리는 이전에 mock객체로 전환시키면서 di를 끊어주었기 때문에 "했다 치고" 넘어갑니다.
}
@Test
@DisplayName("회원 가입 요청 처리")
void test2() throws Exception {
// given
// 회원 가입에 필요한 폼 데이터를 생성하겠습니다.
MultiValueMap<String, String> signupRequestForm = new LinkedMultiValueMap<>();
signupRequestForm.add("username", "제이홉");
signupRequestForm.add("password", "hope!@#");
signupRequestForm.add("email", "hope@sparta.com");
signupRequestForm.add("admin", "false");
// when - then
mvc.perform(post("/user/signup") // 회원가입 http 요청입니다.
.params(signupRequestForm) // 폼 데이터를 @RequestParam에 전달해줍니다.
) // 역시 USERSERVICE 부분은 mock으로 등록되어 했다 치고 넘어감
.andExpect(status().is3xxRedirection()) // redirection을 하고 있기 때문에 status code는 3xx redirection입니다.
.andExpect(view().name("redirect:/user/login")) // view name을 확인합니다.
.andDo(print()); // 결과를 출력합니다.
}
@Test
@DisplayName("신규 관심상품 등록")
void test3() throws Exception {
// given
this.mockUserSetup(); // 로그인한 유저인지 검사하고 있기 때문에 가짜 사용자 등록을 진행합니다.
// 상품 객체 입니다.
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
);
// json 형태의 문자열 데이터로 변환합니다. objectMapper를 활용합니다. java object -> json type string (json)
String postInfo = objectMapper.writeValueAsString(requestDto);
// when - then
mvc.perform(post("/api/products")
.content(postInfo)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.principal(mockPrincipal) // mock 정책을 같이 보내줘서 로그인 사용자로 인
)
.andExpect(status().isOk())
.andDo(print());
}
}
참고 : https://www.baeldung.com/spring-boot-testing
숙제 :
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class UserProductIntegrationTest {
// 관심 상품 등록, 업데이트, 조회 기능을 테스트 합니다.
// 추가되는 테스트 목록 :
// 1. 회원가입 전 관심 상품 등록이 방지됨을 테스트합니다.
// 2. 회원가입 기능 테스트
// 3. 가입된 회원의 관심 상품 등록 테스트
// 4. 관심상품 업데이트 테스트
// 5. 조회 테스트
// @SpringBootTest는 bean을 사용 가능케 해줍니다.
// DI 해주고 있습니다.
@Autowired
ProductService productService;
@Autowired
UserService userService;
@Autowired
PasswordEncoder passwordEncoder;
Long userId = null; // 가입된 유저 아이디
ProductRequestDto productRequestDto = null;
Product createdProduct = null; // 생성한 상품 객체
// 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;
// 상품 객체 - DTO
ProductRequestDto productRequestDto = new ProductRequestDto(
title,
imageUrl,
linkUrl,
lPrice
);
// when
Exception exception = assertThrows(InvalidUserIdValidException.class, () -> {
productService.createProduct(productRequestDto, userId);
});
// then
assertEquals("회원 Id 가 유효하지 않습니다.", exception.getMessage());
}
// 2. 회원가입 기능 테스트
@Test
@Order(2)
@DisplayName("회원 가입 기능 테스트")
void test2() {
// given
String email = "a@a";
String username = "a";
String password = "password";
boolean admin = false;
String adminToken = "";
SignupRequestDto requestDto = new SignupRequestDto(
username,
password,
email,
admin,
adminToken
);
// when
User user = userService.registerUser(requestDto);
// then
// user 객체 생성 여부
assertNotNull(user.getId());
// user 객체 데이터 검사 - password의 경우 암호화 확인
assertEquals(username, user.getUsername());
assertTrue(passwordEncoder.matches(password, user.getPassword()));
assertEquals(email, user.getEmail());
assertEquals(UserRoleEnum.USER, user.getRole());
assertNull(user.getKakaoId());
// 유저 아이디 업데이트
userId = user.getId();
}
// 3. 가입된 회원의 관심 상품 등록 테스트
@Test
@Order(3)
@DisplayName("신규 관심상품 등록")
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;
// 상품 객체 - DTO
ProductRequestDto productRequestDto = new ProductRequestDto(
title,
imageUrl,
linkUrl,
lPrice
);
// when
Product product = productService.createProduct(productRequestDto, userId);
// then
assertNotNull(product.getId());
assertEquals(title, product.getTitle());
assertEquals(imageUrl, product.getImage());
assertEquals(linkUrl, product.getLink());
assertEquals(lPrice, product.getLprice());
assertEquals(0, product.getMyprice());
createdProduct = product;
}
// 4. 관심상품 업데이트 테스트
@Test
@Order(4)
@DisplayName("관심 상품 업데이트")
void test4() {
// given
// userId = 100L;
Long productId = createdProduct.getId();
int myPrice = 10000;
ProductMypriceRequestDto mypriceRequestDto = new ProductMypriceRequestDto(myPrice);
// when
Product updatedProduct = productService.updateProduct(productId, mypriceRequestDto);
//then
assertNotNull(updatedProduct.getId());
assertEquals(createdProduct.getUserId(), updatedProduct.getUserId());
assertEquals(myPrice, updatedProduct.getMyprice());
createdProduct = updatedProduct;
}
// 5. 조회 테스트
@Test
@Order(5)
@DisplayName("관심 상품 조회")
void test5() {
// given
// userId = 100L;
// when
List<Product> products = productService.getProducts(userId);
// then
Long createdProductId = createdProduct.getId();
Product foundProduct = products.stream()
.filter(product -> product.getId().equals(createdProductId))
.findFirst()
.orElse(null);
// then
// 객체 여부
assertNotNull(foundProduct);
// 객체 동일성 여부
assertEquals(createdProduct.getId(), foundProduct.getId());
assertEquals(createdProduct.getTitle(), foundProduct.getTitle());
assertEquals(createdProduct.getImage(), foundProduct.getImage());
assertEquals(createdProduct.getLink(), foundProduct.getLink());
assertEquals(createdProduct.getLprice(), foundProduct.getLprice());
assertEquals(createdProduct.getMyprice(), foundProduct.getMyprice());
assertEquals(createdProduct.getUserId(), foundProduct.getUserId());
}
}
'웹 개발 > 스프링' 카테고리의 다른 글
[스프링 부트 심화] AOP, Transaction (0) | 2023.04.27 |
---|---|
[스프링 부트 심화] JPA (0) | 2023.04.25 |
[스프링 부트 심화] 스프링 시큐리티 - 로그인, 회원가입 구현 (1) | 2023.04.17 |
[스프링 부트 심화] 스프링 시큐리티 (0) | 2023.04.17 |
[스프링 부트 심화] HttpEntity, ResponseEntity, HttpStatus (0) | 2023.04.13 |