단위 테스트는 우리가 코드를 작성하는 방식에 이미 녹아있는 것이지 별도의 작업이 아니다.
테스트하지 않았다면 코드 작성을 완료했다고 할 수 없다.
단위 테스트는 코드가 제대로 구현되었는지 확인하는 가장 좋은 방법이다.
- 도서 '소프트웨어 장인' 中 -
오늘은 필자가 현재 진행 중인 프로젝트에서 구현한 방문 가게의 리뷰글을 CRUD하는 기능에 대한 몇 가지 테스트 코드를 작성하는 겸 기록해놓기 위해 이 포스팅을 시작합니다.
테스트는 Repository, Service에 대한 단위 테스트(Unit Test)를 진행할 것이고 중점을 둔 부분은 "의존성을 최대한 줄였는가?" 입니다.
Unit Test는 말그대로 단위 테스트이므로 @SpringBootTest 어노테이션을 통해 스프링을 실행시키지 않을 것이고,
테스트하려는 메서드에 필요한 부분만 최소한으로 사용하여 테스트를 실행할 것입니다.
(해당 글에서는 이를 위해 Mock 프레임워크를 사용합니다.)
하지만 Mock 객체를 무작정 사용하는 것에 대한 고민도 필요해보입니다.
해당 글은 서비스 레이어에 대한 테스트 코드에서 Mock을 사용하지 않는 것이 좋다는 이야기를 하고 있습니다.
시작하기 앞서 Spring 개발 팀에서 작성한 단위 테스트를 잘 짜기 위한 원칙인 F.I.R.S.T 원칙을 확인해보겠습니다.
F - Fast : 테스트는 빨라야 한다.
I - Independent : 독립적으로. 테스트끼리 서로 의존하면 안되고 어떤 순서로 실행해도 괜찮아야 한다.
R - Repeatable : 어떤 환경에서도 반복 가능해야 한다. 반복 가능한 테스트는 외부 서비스나 리소스같은 항상 사용 가능하지 않은 것에 의존하지 않는다.
S - Self-Validating : 자가-검증, 테스트는 성공 혹은 실패로 출력해야 한다.
T - Timely : 테스트는 적시에 작성해야 한다. 단위 테스트는 테스트하려는 실제 코드를 구현하기 직전에 구현한다. 실제 코드를 구현한 다음에 테스트 코드를 만들면 실제 코드가 테스트하기 어렵다는 사실을 발견할지도 모른다. 테스트가 불가능하도록 실제 코드를 설계할지도 모른다.
목차는 다음과 같습니다.
- 테스트를 위한 애노테이션 종류
- Mockito (Java Mocking Framework)
- Repository Layer Unit Test Code (Mockito 이용)
- Service Layer Unit Test Code (Mockito 이용)
Annotation
@SpringBootTest
- 해당 어노테이션은 전체 애플리케이션을 로드합니다.
- 하지만 단위 테스트에서는 테스트 시나리오에 참여하는 Spring 구성 요소 집합으로만 제한하는 것이 좋습니다.
(해당 글에서는 위에서 언급했듯, 사용하지 않을 것입니다.) - @SpringBootTest 애노테이션을 사용하면 스프링이 관리하는 모든 빈을 등록시키므로, 통합 테스트를 할 때에는 유용한 녀석입니다. (통합 테스트의 경우, 애플리케이션을 자신의 로컬 위에 올리고, 실제 Database와 연결되어 테스트가 진행됩니다.)
- application을 띄우기때문에 시간도 오래걸리고 무겁습니다.
@DataJpaTest
- 해당 애노테이션은 @Repository 가 붙은 Spring 구성 요소만을 로드합니다. @Service, @Controller를 로드하지 않아 성능을 향상시킵니다.
- JPA 관련된 설정만 로드하여 Repository Layer에 대한 테스트를 할 수 있습니다.
- @Transaction을 내장하고 있어, 매 테스트 코드가 종료되면 자동으로 DB가 롤백됩니다.
- 기본적으로 내장 DB를 사용합니다.
- @Entity가 선언된 클래스를 스캔하여 저장소를 구성합니다.
@WebMvcTest
@WebMvcTest(ReviewApiController.class)
- 해당 애노테이션은 MVC를 위한 테스트로, 서버 파트 구동 없이 컨트롤러(REST API)가 예상대로 동작하는지 테스트할 때 사용하여 오직 테스트되는 컨트롤러만 등록합니다.
- 웹에서 테스트하기 힘든 컨트롤러를 테스트하는 데 적합하고, 웹상에서의 요청과 응답에 대해 테스트할 수 있습니다.
- web 레이어 관련 빈들만 등록합니다.
- Controller Layer만을 테스트하기 적합한 애노테이션
- 다음과 같은 내용만 스캔하도록 제한합니다.
@Controller, @ControllerAdvice, @JsonComponent, Converter, GenericConverter, Filter, HandlerInterceptor 등..) - @SpringBootTest 보다 비교적 가볍습니다.
- 시큐리티, 필터까지 자동으로 테스트하며, 수동으로 추가/삭제 가능합니다.
Mockito
Mockito는 단위 테스트를 위한 Java mocking framework로, 가짜 객체를 의미하는 Mock를 생성하여 의존성을 최소화시켜줄 수 있는 테스트 프레임워크입니다.
Mockito를 사용하면 테스트 대상에서 필요로 하는 객체들을 임의로 생성하여 원하는 시나리오대로 테스트 환경을 쉽게 구성할 수 있습니다.
이를 통해 테스트 코드 작성 시, 기존에 연결이 필요한 실제 DB 혹은 의존하고 있는 객체를 끊어낼 수 있습니다.
@Mock : 의존하는 실제 객체 대신 목 객체로 초기화합니다.
@InjectMocks : 목 객체(앞서 @Mock으로 선언된 가짜 객체)에 의존하는 객체에 대해 목 객체로 초기화합니다.
1. Repository Test Code
Repository는 엔티티를 영속화하기 위해 사용되는데, 이러한 영속화를 요구하는 것은 서비스에서 발생한다.
서비스 계층이 요구사항을 처리하는 영역이다. 서비스 계층에서는 도메인을 이용해 비즈니스 로직을 수행하고 난 도메인을 영속화할 때 이 기능을 저장소 영역으로 위임하는 것이다.
잠깐, 엔티티의 영속화에 대해 짚고 넘어가자. "영속화"란 뭘까?
비영속 상태의 Entity 객체를 영속 상태로 변경하는 것이다. 영속성 컨텍스트에 저장하는 것이라고도 할 수 있다.
실제로 무엇이 변경되는 걸까? 영속성 컨텍스트는 뭘까?
영속성 컨텍스트는 논리적인 개념이라 어렵게 느낄 수 있지만 차근차근 이해해보면 어렵지 않다.
자바는 OOP(Object Oriented Programming) 개념을 가지고 데이터를 객체처럼 관리하는 반면, 데이터베이스(RDB)는 관계형으로 데이터를 관리한다. 그렇기 때문에 이 간극을 메우기 위해 ORM(Object Relational Mapping)개념이 등장한다.
ORM에서는 영속성 컨텍스트라는 일종의 논리적인 임시 저장소 공간을 통해 관계형(Relational) 데이터를 객체(Object, Entity)로 매핑(Mapping)해 관리한다.
영속성 컨텍스트를 사용함으로써 1차 캐시, 동일성 보장, 쓰기 지연, Dirty Checking, 플러시 등 다양한 이점이 있는데, 이는 따로 포스팅할 것이다.
영속화를 통해 엔티티의 영속 상태가 변경된다 했는데, 엔티티의 4가지 생명주기를 살펴보자.
비영속 상태
비영속 상태는 아직 엔티티를 생성만 한 상태로 영속성 컨텍스트에 집어넣지 않은 상태를 말한다.
비영속 상태의 엔티티는 영속성 컨텍스트로부터 어떠한 관리도 받지못한다.
Review review = new Review();
review.setContent("Hi");
review.setStoreId("1234");
영속 상태
persist() 메서드를 통해 비영속 상태의 엔티티를 영속성 컨텍스트 안에 집어 넣으면, 엔티티는 당연히 영속 상태가 되고 영속성 컨텍스트로부터 지속적인 관리를 받게 된다.
이때 DB에 저장되지는 않는다. 트랜잭션의 커밋 시점에서야 영속성 컨텍스트에 있는 엔티티들이 DB에 쿼리로 날라간다.
EntityManager.persist(review);
준영속 상태
엔티티가 영속성 컨텍스트에 저장되었다가 분리된 상태
EntityManager.detach(review);
삭제
엔티티가 삭제된 상태. DB에서도 날라간다.
EntityManager.remove(review);
다시 본론으로 돌아와서, Repository 단위 테스트를 살펴보겠습니다.
Repository Test Code : 리뷰 등록과 조회
테스트할 BaseReviewRepository는 @Autowired를 통해 의존성 주입하도록 합니다.
@DataJpaTest
class BaseReviewRepositoryTest {
@Autowired BaseReviewRepository reviewRepository;
List<String> imgUrl = new ArrayList<String>(Arrays.asList("http://s3-img-url-test1.com","http://s3-img-url-test2.com"));
@Test
@DisplayName("리뷰글이 DB에 잘 저장되는지 확인")
void saveReview() {
// given
Review review = new ReviewUploadRequestDto("1234", "테스트 내용", imgUrl).toEntity();
// when
Review savedReview = reviewRepository.save(review);
// then
// Assertions.assertThat(review).isSameAs(savedReview);
// savedRivew에는 Identity 전략을 사용하는 필드, 자동 생성 필드가 몇개 더 있기 때문에
// 테스트에서 해당 필드들을 검증할 수 없다.
// 만약 검증하려면 Repository를 Mock이 아니라 실제 Bean으로 사용해야 가능할 듯 싶다.
Assertions.assertThat(review.getPlaceId()).isEqualTo(savedReview.getPlaceId());
Assertions.assertThat(savedReview.getReviewId()).isNotNull();
Assertions.assertThat(reviewRepository.count()).isEqualTo(1);
}
@Test
@DisplayName("저장된 리뷰가 제대로 조회되는지 확인")
void findReview() {
// given
Review savedReview1 = reviewRepository.save(new ReviewUploadRequestDto("1234", "테스트 내용1", imgUrl).toEntity());
Review savedReview2 = reviewRepository.save(new ReviewUploadRequestDto("1234", "테스트 내용2", imgUrl).toEntity());
// when
List<Review> findReviews = reviewRepository.findAllByPlaceIdOrderByCreatedAtDesc(savedReview1.getPlaceId());
// then
Assertions.assertThat(reviewRepository.count()).isEqualTo(2);
Assertions.assertThat(findReviews.size()).isEqualTo(2);
Assertions.assertThat(findReviews.get(0).getPlaceId()).isEqualTo(savedReview2.getPlaceId());
Assertions.assertThat(findReviews.get(1).getImgUrl()).isEqualTo(savedReview1.getImgUrl());
}
}
Assertions.assertThat().isEqualTo(), isNotNull() 등으로 검증하였습니다.
2. Service Test Code
Service는 Controller, 그리고 Repository와 연관관계가 있기 때문에 두 곳과의 의존성을 모두 끊어내야 합니다.
1. Controller와의 연결 끊기
Controller는 Web모듈이기 때문에 Service Test를 진행할 때는 Web에 대한 의존성을 받으면 안됩니다. 따라서 @WebMvcTest, @SpringBootTest를 사용하면 안됩니다.
2. Repository와의 연결 끊기
Domain을 통해 비즈니스 로직은 수행해야하지만, 실제로 DB에 저장하지는 않을 것이므로
(SpringBoot 테스트가 제공하는) 특정 객체를 가짜로 대체할 Mocking을 이용합니다.
해당 테스트의 객체가 의존하는 객체에 애노테이션 @Mock을 붙이면 실제 객체 대신에 @Mock으로 선언한 객체로 바꿔치기됩니다.
따라서 의존하고 있는 Repository 객체를 @Mock으로 선언하면 실제 Repository Bean에 의존하지 않고 테스트가 가능해집니다.
그리고 @Mock으로 선언된 가짜 객체들에 의존하는 Service 객체에 @InjectMocks를 붙임으로써, Service 목 객체가 생성됩니다.
Service Layer Test Code : 리뷰 업로드, 조회, 가게 리뷰 전체 조회, 리뷰 삭제
/**
* {@Summary Service Layer 단위 테스트}
*/
@ExtendWith(MockitoExtension.class)
class ReviewServiceImplTest {
@Mock BaseReviewRepository reviewRepository;
@InjectMocks ReviewServiceImpl reviewService;
List<String> imgUrl = new ArrayList<String>(Arrays.asList("http://s3-img-url-test1.com","http://s3-img-url-test2.com"));
ReviewUploadRequestDto uploadRequestDto = new ReviewUploadRequestDto("1234", "리뷰 서비스 테스트", imgUrl);
@Test
void 리뷰_업로드() {
// when
Review testUploadedReview = reviewService.uploadReview(uploadRequestDto);
// verify
Assertions.assertThat(testUploadedReview.getContent()).isEqualTo("1234567890");
// 출력
System.out.println(testUploadedReview.toString());
}
@Test
void 리뷰_조회() {
//when
Long reviewId = 4L;
Review findOneReview = reviewService.listReview(reviewId);
// verify
Assertions.assertThat(findOneReview.getContent()).isEqualTo("리뷰 서비스 테스트");
// 출력
System.out.println(findOneReview.toString());
}
@Test
void 가게_리뷰_전체_조회() {
// when
String placeId = "1234";
List<Review> findReviews = reviewService.listAllReviews(placeId);
for (Review r : findReviews)
// verify
Assertions.assertThat(r.getPlaceId()).isEqualTo(placeId);
}
@Test
void 리뷰_수정() {
ReviewUpdateRequestDto reviewUpdateRequestDto = new ReviewUpdateRequestDto("리뷰 업데이트 서비스 테스트");
// when : 조회
Long reviewId = 4L;
Review findOneReview = reviewService.listReview(reviewId);
// verify
Assertions.assertThat(findOneReview.getContent()).isEqualTo("리뷰 서비스 테스트");
// when
Review updatedReview = reviewService.updateReview(reviewId, reviewUpdateRequestDto);
// verify
Assertions.assertThat(updatedReview.getContent()).isEqualTo(reviewUpdateRequestDto.toEntity().getContent());
}
@Test
void 리뷰_삭제() {
// when
Long reviewId = 4L;
Review findReview = reviewService.listReview(reviewId);
reviewService.deleteReview(findReview.getReviewId());
// 해당 리뷰가 없다면 ReviewNotFoundException을 던진다.
ReviewNotFoundException exception = assertThrows(ReviewNotFoundException.class,
() -> reviewService.listReview(reviewId));
// then
assertEquals("해당 게시물을 찾을 수 없습니다.", exception.getMessage());
}
}
Assertions.assertThat().isEqualTo(), assertEquals()를 이용해 검증했습니다.
사용하지는 않았지만,
assertThrows(expectedType, executable) : 발생할 예외와 예외 발생 상황을 매개변수로 주는 예외 발생 확인 메서드로, 예외를 검증합니다.
Service Test는 비즈니스 로직 처리가 제대로 되는지만 검증하면 되므로 Spring과 연관될 이유가 없습니다.
따라서 Spring Contaianer가 로드되지 않도록 SpringExtension.class를 사용하지 않고,
@ExtendWith(MockitoExtension.class) 을 추가하여 단위 테스트를 작성하였습니다.
@ExtendWith(MockitoExtension.class)
Spring Contaianer를 로드하지 않고 테스트하는 기능만 제공합니다.
Spring을 필요로 하지 않고 순수한 단위 테스트만 진행할 시 사용합니다.
@MockBean, @Mock 기능을 사용해서 목 객체를 생성합니다.
@ExtendWith(SpringExtension.class)
테스트 시에 Spring Contaianer를 로드하려면 해당 애노테이션을 사용합니다.
필요한 객체에 @Autowired를 통해 Bean 의존성을 주입시킬 수 있습니다.
공부하며 작성하여
지속적으로 업데이트가 필요한 글입니다.
피드백과 댓글은 환영입니다!
'백엔드 개발하며 작성한 > Spring' 카테고리의 다른 글
Filter와 OncePerRequestFilter (0) | 2022.02.22 |
---|---|
Spring MVC Request Lifecycle (0) | 2022.02.16 |
Spring Boot와 S3 연결하기 (0) | 2022.02.07 |
ResponseEntity 사용법 (0) | 2022.01.02 |
JAP Query로 특정 칼럼의 count 쿼리문 실행하기 (0) | 2021.11.12 |