-
단위 테스트 작성Backend/TEST 2023. 3. 31. 02:05
이번에 프로젝트를 하면서 controller, service, repository 레이어에 대한 단위테스트를 진행했는데, 테스트 코드를 작성하는 것이 항상 중요하다 중요하다 생각은 하면서 실제로는 별로 작성하지 않았는데 이번 기회에 테스트 코드를 작성하며 공부한 내용들을 정리하는 시간을 갖겠습니다.
다양한 블로그들의 글을 참고했는데 하단에 링크 작성하도록 하겠습니다. 혹시 문제가 있다면 바로 수정하겠습니다.
@SpringBootTest VS @WebMvcTest
@SpringBootTest 어노테이션의 경우 스프링 어플리케이션에 등록된 모든 빈들을 가져오기 때문에 실제 환경과 유사한 환경에서 테스트가 가능합니다. 따라서 주로 통합테스트를 하는 경우 많이 사용되는데, 모든 빈들을 가져오기 때문에 상대적으로 무겁고 자주 빠르게 테스트를 실행하기 어렵다는 특징이 있습니다.
@WebMvcTest 어노테이션은 컨트롤러의 역할만을 테스트 하기 때문에 모든 빈들을 가져오지 않고 필요한 빈들은 mocking 해서 테스트를 하게 됩니다. 따라서 단위 테스트에 더욱 어울리며 빠르게 테스트를 수행할 수 있습니다.
현재는 단위테스트 위주로 작성하였기 때문에 @WebMvcTest를 이용하여 테스트를 작성했습니다.
공식문서에 따르면 @WebMvcTest 어노테이션이 가져오는 빈들의 목록은 다음과 같습니다.
To test whether Spring MVC controllers are working as expected, use the @WebMvcTest annotation. @WebMvcTest auto-configures the Spring MVC infrastructure and limits scanned beans to @Controller, @ControllerAdvice, @JsonComponent, Converter, GenericConverter, Filter,
HandlerInterceptor, WebMvcConfigurer, WebMvcRegistrations, and HandlerMethodArgumentResolver. Regular @Component and @ConfigurationProperties beans are not scanned when the @WebMvcTest annotation is used. @EnableConfigurationProperties can be used to include @ConfigurationProperties beans.
추가로 @WebMvcTest 어노테이션은 @MockBean 어노테이션과 같이 사용해야 합니다. 또한 자동으로 MockMvc를 주입받을 수 있는데 이때 어떤 컨트롤러를 주입받을 것인지 어노테이션에 작성해주면 됩니다.
1. Repository 단위 테스트
개인적으로 Repository 레이어 부터 차례로 단위 테스트를 하는 것이 좀 더 괜찮다고 느꼈기 때문에 이번 글에서도 Repository를 가장 먼저 작성하도록 하겠습니다. 기본적으로 스프링 부트의 테스트 라이브러리에서는 @DataJpaTest 어노테이션을 통해 jpa를 테스트할 수 있도록 지원하고 있습니다.
해당 어노테이션을 사용하면 JPA와 관련된 테스트만 진행 가능하며 메모리 DB를 자동으로 사용하여 테스트하도록 설정해줍니다. 만약 자동 메모리가 아닌 특정 DB를 사용하고 싶다면 @AutoConfigureTestDatabase 어노테이션을 같이 사용하면 됩니다.
@DataJpaTest @Slf4j @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) class GoodsRepositoryTest { @Autowired GoodsRepository goodsRepository; @Autowired EntityManager em; @Test @DisplayName("상품 url로 찾기") void findByGoodsUrl() { // given Goods goods = new Goods("goodsname", "test2", "test", 2000, Commerce.NAVER); goodsRepository.save(goods); em.flush(); em.clear(); // when Optional<Goods> test = goodsRepository.findByGoodsUrl("test2"); Goods foundedGoods = test.get(); // then assertThat(foundedGoods.getGoodsName()).isEqualTo("test"); assertThat(foundedGoods.getGoodsPrice()).isEqualTo(2000); } }
@DataJpaTest 어노테이션을 사용했고 메모리 DB가 아닌 직접 application.yml에 작성한 DB를 이용하여 테스트하고 싶었기 때문에 @AutoConfigureTestDatabase 의 replace 속성을 NONE으로 설정했습니다.
그후 원하는 레포지토리 클래스를 주입받아서 단위 테스트를 진행했습니다. 위 테스트 코드에는 @Transactional 어노테이션이 없는데 이는 @DataJpaTest 어노테이션 내부에 @Transactional 어노테이션이 같이 존재하기 때문입니다.
레포지토리 테스트에서는 모든 메서드를 테스트하지 않고 개인적으로 직접 jpa 메서드를 작성한 부분만 선택해서 테스트를 진행했습니다.
2. Service 단위 테스트
Service 레이어를 테스트하기 위해 Repository 레이어를 모킹하는 방법을 사용했습니다. 따라서 Mockito를 이용해 모킹을 진행하였고 해당 기능들을 사용하기 위해서는 @ExtendWith(MockitoExtension.class)를 반드시 작성해주어야 합니다.
@InjectMocks를 이용해 테스트 하고자 하는 서비스 클래스를 설정해 주고 @Mock 어노테이션으로 앞에서 설정한 서비스에 가짜 객체를 생성해서 주입시켜줍니다.
given 자리에는 필요한 인스턴스나 값들, 여기에는 목 객체가 리턴하는 값들도 포함해서 값들을 설정해주었습니다. 이후 when 단계에서 실제 인스턴스가 실행되고 then 부분에서 검증하도록 테스트 코드를 작성하였습니다.
@ExtendWith(MockitoExtension.class) @Slf4j class UsersGoodsServiceTest { @InjectMocks UsersGoodsService usersGoodsService; @Mock UsersGoodsRepository usersGoodsRepository; @BeforeEach void setting() { url = "testUrl"; userId = 1L; goods = new Goods("imgUrl", url, "goods1", 100000, Commerce.COUPANG); users = Users.builder().id(userId).build(); usersGoods = new UsersGoods(users, goods); } @Test @DisplayName("상품 이름 변경 성공") void changeUsersGoodsName() { // given assertThat(usersGoods.getUpdatedUsersGoodsName()).isEqualTo("goods1"); String changedName = "testName"; doReturn(Optional.of(usersGoods)).when(usersGoodsRepository).findByIdAndUsersId(usersGoods.getId(), users.getId()); // when usersGoodsService.updateUsersGoodsName(users.getId(), usersGoods.getId(), changedName); // then assertThat(usersGoods.getUpdatedUsersGoodsName()).isEqualTo("testName"); } @Test @DisplayName("상품 이름 변경 시 해당 상품이 없는 경우") void notFoundUsersGoods() { // given String changedName = "testName"; Long usersGoodsId = 1L; doReturn(Optional.empty()).when(usersGoodsRepository).findByIdAndUsersId(anyLong(), anyLong()); // when assertThrows(NoSuchElementException.class, () -> usersGoodsService.updateUsersGoodsName(users.getId(), usersGoodsId, changedName)); } }
3. Controller 단위 테스트
마지막으로 컨트롤러 테스트입니다. 컨트롤러 테스트도 서비스 레이어를 테스트 한 것처럼 @ExtendWith 어노테이션을 이용해서 할 수 있지만 이번에는 스프링 부트에서 제공해주는 @WebMvcTest 어노테이션을 이용하여 테스트를 진행했습니다.
이때 @WebMvcTest에 특정 컨트롤러 클래스를 설정해주면 해당 컨트롤러로 MockMvc를 생성해준다. 또한 이 부분은 스프링에서 해주는 부분이기 때문에 위에서 작성한 ExtendWith의 경우 junit 라이브러리이므로 가짜 객체를 주입하기 위해 @Mock 어노테이션과 @InjectMock 어노테이션을 사용했는데, @WebMvcTest의 경우에는 @MockBean 어노테이션을 이용해야 합니다.
해당 프로젝트에서 DB 저장 시 저장되는 시간이나, 업데이트 되는 시간을 자동으로 넣어주도록 설정하면서 application의 시작지점에 @EnableJpaAuditing 어노테이션을 사용했습니다.
문제는 @WebMvcTest를 통해 테스트를 진행하면 application 시작 지점에서부터 필요한 빈들을 주입하는데 이때 JPA와 관련된 빈들은 등록하지 않아서 에러가 발생합니다. 해당 에러를 해결하기 위해 @MockBean(JpaMetamodelMappingContext.class)를 추가로 작성해주어서 JPA관련 빈들은 목으로 등록하도록 처리하였습니다.
위 에러를 해결하기 위해 @EnableJpaAuditing 어노테이션을 시작 지점이 아니라 따로 설정 클래스에 작성해주는 방법도 있습니다. 자세한 방법은 아래 블로그를 참고했습니다.
[JPA]EnableJpaAuditing을 Application 위에 쓰면 안되는 이유
@WebMvcTest를 붙이고 테스트를 돌리니 JPA metamodel must not be empty! 와 같은 에러가 발생했다. 이유를 찾아보니 테스트를 돌릴때는 기본적으로 XApplication이 돌면서 작동한다. 따라서 @EnableJpaAuditing을 App
giron.tistory.com
마지막으로 filter까지 고려한 테스트가 아니었기 때문에 @AutoConfiguraMockMvc(addFilters = false) 옵션까지 작성해주었습니다.
@WebMvcTest(UsersGoodsController.class) @MockBean(JpaMetamodelMappingContext.class) @AutoConfigureMockMvc(addFilters = false) class UsersGoodsControllerTest { ObjectMapper objectMapper = new ObjectMapper(); @MockBean UsersGoodsService usersGoodsService; @Autowired MockMvc mockMvc; @Test @DisplayName("상품 URL 등록 성공") void postUsersGoods() throws Exception { // given Long userId = 1L; String url = "testUrl"; PostUsersGoodsRequestVo requestVo = new PostUsersGoodsRequestVo(url); UsersGoodsPostResponseDto usersGoodsPostResponseDto = new UsersGoodsPostResponseDto(); BDDMockito.given(usersGoodsService.postUsersGoods(userId, url)).willReturn(usersGoodsPostResponseDto); // when ResultActions resultActions = mockMvc.perform(post("/usersgoods/add/{userId}", 1) .content(objectMapper.writeValueAsString(requestVo)) .contentType(MediaType.APPLICATION_JSON) ); // given resultActions.andExpect(status().isOk()) .andExpect(jsonPath("status").value(201)) .andDo(print()); } @Test @DisplayName("상품 URL 등록 실패") void postUsersGoodsFail() throws Exception { // given Long userId = 1L; String url = "testUrl"; PostUsersGoodsRequestVo requestVo = new PostUsersGoodsRequestVo(url); UsersGoodsPostResponseDto usersGoodsPostResponseDto = new UsersGoodsPostResponseDto(); when(usersGoodsService.postUsersGoods(userId, url)).thenThrow(new IllegalArgumentException("잘못된 url 정보입니다.")); // when ResultActions resultActions = mockMvc.perform(post("/usersgoods/add/{userId}", 1) .content(objectMapper.writeValueAsString(requestVo)) .contentType(MediaType.APPLICATION_JSON) ); // given resultActions.andExpect(status().isOk()) .andExpect(jsonPath("success").value(false)) .andExpect(jsonPath("status").value(400)) .andExpect(jsonPath("message").value("잘못된 url 정보입니다.")) .andDo(print()); } }
저는 프로젝트에서 응답을 DTO 클래스 그대로 보내주었기 때문에 모든 응답은 200으로 보내주었고 그 내부에 success, status message, data 등의 값으로 실패 응답인지 성공 응답인지를 체크했기 때문에, 위 코드에서 실패 사례임에도 status().isOk() 로 테스트작성했습니다.
마지막으로 when, jsonPath 등의 메서드를 이용하기 위해 static으로 임포트한 클래스 목록입니다. 나중에 다시 사용할 때 편하도록 아래 작성해 두겠습니다.
import static org.mockito.Mockito.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
마지막으로 실제 실무에서는 어떻게 단위 테스트를 작성하는지, 아니면 @SpringBootTest 를 통해 통합테스트를 위주로 작성하는지 정확하지 않기 때문에 위에 작성한 글들은 참고용으로만 보시면 될것 같습니다.
개인적으로 실무에서도 아쉽게 테스트 코드를 따로 작성하지 않는 환경이다 보니 이번에 개인 프로젝트를 진행하며 혼자 열심히 찾아보며 단위 테스트만이라도 해보자 하고 시작했는데 확실히 빡세게 테스트 코드를 작성하고 모든 테스트가 통과하는 모습을 보면 기분이 좋으면서 코드에 대한 자신도 생기고 후에 리팩토링 과정에서도 잘 활용할 수 있을 것 같다고 느꼈습니다.
클린 코드 책을 읽으며 결국 테스트 코드도 꾸준히 리팩토링을 해주어야 한다는 얘기가 있었는데 앞으로 코드가 바뀌면서 테스트 코드 또한 주기적으로 리팩토링을 진행할 예정입니다.
이후에 @SpringBootTest 어노테이션을 통한 통합 테스트도 진행해 본다면 추가로 글을 작성하도록 해보겠습니다.
아래 블로그들은 테스트 코드를 작성하면서 많이 참고한 블로그들입니다.
[Spring] JUnit과 Mockito 기반의 Spring 단위 테스트 코드 작성법 (3/3)
이번에는 Spring 기반의 웹 애플리케이션에서 테스트를 작성하는 방법에 대해 알아보도록 하겠습니다. 1. Mockito 소개 및 사용법 [ Mockito란? ] Mockito는 개발자가 동작을 직접 제어할 수 있는 가짜 객
mangkyu.tistory.com
[Mokito] Mock 객체 Stubbing
Mock 객체 Stubbing Mock 객체의 행동이란, 리턴 값이 있는 메소드는 모두 Null 을 리턴하고 있다. Optional 타입인 경우 Optional.empty로 리턴 Primitive 타입은 모두 Primitive 값을 따르고 있다. Ex. Boolean인 경우
it-mesung.tistory.com
완벽정리! Junit5로 예외 테스트하는 방법
환경 구성 testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeClasspath - Runtime classpath of source set 'test'. +--- org.springframework.boot:spring-boot-starter-web -> 2.5.6 \--- org.springframework.boot:spring-boot-sta
covenant.tistory.com
[Error] @DataJpaTest DataSource 설정 오류
Error 상황 프로젝트 환경 설정 application.yml spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/awss3?serverTimezone=UTC&characterEncoding=UTF-8 username: root password: 1234 jpa: database-platform:
charliezip.tistory.com
[JUnit] 스프링부트 + junit5 환경에서 MockMvc로 컨트롤러 테스트하기
Mock이란? 사전적 의미로 '테스트를 위해 만든 모형'을 의미하고, 테스트를 위해 실제 객체와 비슷한 모의 객체를 만드는 것을 모킹(Mocking), 모킹한 객체를 메모리에서 얻어내는 과정을 목업(Mock-up
scshim.tistory.com
[Spring] MockMVC Test
Web API를 많이 작성하다보면 웹 애플리케이션을 실행하고 브라우저를 열어서 테스트할 URI를 입력하고 다시 코드를 작성하고 웹 애플리케이션을 재시작하는 등을 반복하게 된다. 이때 Web API를 실
doongjun.tistory.com
'Backend > TEST' 카테고리의 다른 글
인프런, 실용적인 테스트 가이드 강의 정리 (0) 2023.07.16