Java/Spring & Spring Boot

Slice test

마손리 2023. 5. 1. 22:42

메서드 단위로 테스트하는 단위 테스트와 는 달리 Slice 테스트는 계층별로 테스트를 진행한다. (ex. Api 계층, 서비스 계층, 데이터베이스 계층) 

 

API 계층 테스트

Api계층의 Controller 테스트를 위한 테스트 클래스 구성

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;

@SpringBootTest       // 1.
@AutoConfigureMockMvc  // 2.
public class ControllerTestDefaultStructure {
		// 3.
    @Autowired
    private MockMvc mockMvc;
    
		// 4. 
    @Test
    public void postMemberTest() {
        // given 5. 테스트용 request body 생성
        
        // when 6. MockMvc 객체로 테스트 대상 Controller 호출
        
        // then 7. Controller 핸들러 메서드에서 응답으로 수신한 HTTP Status 및 response body 검증 
    }
}
  1. @SpringBootTest 애너테이션은 Spring Boot 기반의 애플리케이션을 테스트하기 위한 Application Context(Spring container)를 생성하며 Application Context에 등록된 Bean들을 사용할 수 있다.
  2. @AutoConfigureMockMvc 애너테이션은 Controller 테스트를 위한 애플리케이션의 자동 구성 작업을 해준다. (3)의 MockMvc 같은 기능을 사용하기 위해서는 @AutoConfigureMockMvc 애너테이션을 반드시 추가해 주어야 한다.
  3. (3)에서 DI로 주입받은 MockMvcTomcat 같은 서버를 실행하지 않고 Spring 기반 애플리케이션의 Controller를 테스트할 수 있는 완벽한 환경을 지원해 주는 일종의 Spring MVC 테스트 프레임워크이다.
    즉, MockMvc를 통해 Servlet containner없이 Servlet을 사용할 수 있다.
  4. (1), (2), (3)을 통해 Controller를 테스트할 준비가 되었다.
  5. 해당 테스트에 사용할 객체나 변수들을 생성
  6. 실제 테스트할 메서드를 호출
  7. 호출된 메서드의 결과를 가지고 테스트를 진행

 

Post 요청 테스트

@Transactional // 1.
@SpringBootTest
@AutoConfigureMockMvc
class MemberControllerTest {
    @Autowired
    private MockMvc mockMvc;
    @Autowired
    private Gson gson; // 2.
    @Autowired
    private MemberRepository memberRepository;
    
    private ResultActions postResultAction;
    private MemberDto.Post post;
    private long memberId;

    @BeforeEach
    public void init() throws Exception { // 3.
        // given
        post = new MemberDto.Post("mason@test.com",
                "Mason",
                "010-1111-1111");
        String content = gson.toJson(post);
        // when
        postResultAction =
                mockMvc.perform( // 4.
                        post("/v11/members")
                                .accept(MediaType.APPLICATION_JSON) // http 메서드에따라 설정이 필요없을 수 있음
                                .contentType(MediaType.APPLICATION_JSON)
                                .content(content)
                );
		// 5.
        String location = postResultAction.andReturn().getResponse().getHeader("Location"); // "/v11/members/1"
        memberId = Long.parseLong(location.substring(location.lastIndexOf("/") + 1));
    }

    @Test
    void postMemberTest() throws Exception { // 6.
    	// then
        postResultAction // 7.
                .andExpect(status().isCreated())
                .andExpect(header().string("Location", is(startsWith("/v11/members/"))));
    }
}

제일 먼저 Post 요청의 테스트를 살펴본다.

  1. @Transactional@Test 메서드에서 사용하면 이전 테스트에 만들어저 저장된 데이터들이 롤백이 되어 DB가 초기화 된다. 
  2. Gson api를 이용하여 객체를 Json 형태로 변환시킬 수 있다.(optional)
  3. @BeforeEach로 각 테스트마다 Post 요청을 보내 각 테스트마다 사용할 데이터를 계속 해서 만들어 주어 코드의 반복 사용을 방지한다. 이후 테스트가 끝나면 @Transactional 에너테이션에서 생성된 데이터들이 삭제되며 다음 테스트때 Post 요청이 계속 된다.
  4. MockMvcperform() 메서드와 MockMvcRequestBuilderspost()메서드로 Post 요청을 보낸다. 
    accept()와 contentType()은 Http 요청을 위한 설정이며 요청의 종류에 따라 필요 없을 수 있다.
    마지막 content()메서드를 이용하여 Json형태의 리퀘스트 바디를 보낸다.
  5. ResultActions 클래스를 이용하여 MockMvc의 요청으로 받은 응답 데이터에 접근할 수 있다.
  6. Post 요청을 보냈으면 이제 받은 응답 데이터를 가지고 테스트를 시작 한다.
  7. 마찬가지로 ResultActions 객체의 andExpect()메서드로 테스트가 가능하며 제일 먼저 MockMvcResultMatchers의  status()메서드를 이용하여 Http 응답코드에 접근하여 테스트를 시작한다.

 

Get 요청 테스트

@Transactional
@SpringBootTest
@AutoConfigureMockMvc
class MemberControllerTest {
    @Autowired
    private MockMvc mockMvc;
    @Autowired
    private Gson gson;
    @Autowired
    private MemberRepository memberRepository;
    private ResultActions postResultAction;
    private MemberDto.Post post;
    private long memberId;
    
    ...
    ...

    @Test
    void getMembersTest() throws Exception{
    	// 1.
        Member newMember = new Member("mason2@test.com","Mason2","010-2222-2222");
        newMember.setStamp(new Stamp());
        memberRepository.save(newMember);

        String page = "1";
        String size = "10";
        // 2.
        MultiValueMap<String,String> queryParams = new LinkedMultiValueMap<>();
        queryParams.add("page",page);
        queryParams.add("size",size);
        
	// 3.
        ResultActions actions = mockMvc.perform(
                get("/v11/members")
                        .params(queryParams)
        );
		
        // 4.
        MvcResult result = actions.andExpect(status().isOk())
                .andExpect(jsonPath("$.data").isArray())
                .andReturn();

	// 5.
        List<Member> list = JsonPath.parse(result.getResponse().getContentAsString()).read("$.data");

	// 6.
        assertThat(list.size(),is(2));


    }
}
  1. 위의 init()메서드로 DB에 하나의 데이터를 생성하지만 여러 데이터의 조회를 테스트하기 위해 추가 데이터를 생성해 준다.
  2. MultiValueMap을 이용해 Pagination의 정보를 위한 query parameter를 작성해준다.  (MultiValueMap은 HashMap과 달리 key의 중복을 허용)
  3. Post요청과 마찬가지로 MockMvcMockMvcRequestBuildersget()메서드를 이용하여 Get 요청을 보낸뒤 ResultActions 객체로 응답을 받아낸다.
  4. 받아낸 응답으로 마찬가지로 ResultActionsandExpect() 메서드와 MockMvcResultMatchersstatus() 메서드를 이용하여 Http 응답코드를 테스트해준다. 또한 MockMvcResultMatchers jsonPath() 메서드를 이용하면 Json 포맷의 데이터의 경로를 추적할 수 있다.
  5. 이후 JasonPath api를 이용하여 Json 데이터를 List로 변환
  6. Hamcrest를 이용하여 나머지 테스트를 진행한다.

 

데이터 엑세스 계층 테스트

데이터 엑세스 계층의 테스트 또한 비슷한 흐름을 가진다.

@DataJpaTest // 1.
public class CoffeeRepositoryHomeworkTest {
    @Autowired
    private CoffeeRepository coffeeRepository;
    private Coffee coffee;

    @BeforeEach // 2.
    public void init(){
    //given
        coffee = coffeeRepository.save(new Coffee("아메리카노","Americano",4000,"AAA"));
    }
    
    @Test // 3.
    public void findByCoffeeCodeTest() {
    //when
        Optional<Coffee> foundCoffee = coffeeRepository.findByCoffeeCode("AAA");
        
        //then
        assertAll(
                ()-> assertThat(foundCoffee.get(),is(notNullValue())),
                ()-> assertThat(foundCoffee.get().getCoffeeId(),is(equalTo(coffee.getCoffeeId()))),
                ()-> assertThat(foundCoffee.get().getKorName(),is(equalTo(coffee.getKorName()))),
                ()-> assertThat(foundCoffee.get().getEngName(),is(equalTo(coffee.getEngName()))),
                ()-> assertThat(foundCoffee.get().getPrice(),is(equalTo(coffee.getPrice())))
        );

    }
}
  1. @DataJpaTest는 Jpa 계열의 데이터 엑세스계층의 테스트에 사용되며 테스트를 위한 여러가지 설정들이 포함되 있다(Api계층에서는 @AutoConfigureMockMvc가 설정을 담당). 이전 예제에서 사용한 @Transactional 또한 포함되어 테스트마다 생성된 데이터들이 Rollback 된다.
  2. 마찬가지로 매 테스트에 사용할 데이터를 테스트 전에 생성해 준다. (테스트의 Given 단계에 해당)
  3. 테스트할 JpaRepository 메서드를 호출한 뒤(When 단계), 받은 데이터로 JUnit과 Hamcrest를 이용하여 테스트 코드 작성(Then 단계)

 

 

Mock test

슬라이스 테스트는 계층을 단위로 원하는 계층 영역에 대한 테스트에 집중 하는 것이다. 하지만 위의 API 계층 테스트는 사실 Controller의 기능을 테스트하기는 하지만 그안의 서비스계층과 데이터베이스 엑세스계층까지 다 테스트가 실행되고 있는데 Mock 테스트를 사용하면 원하는 계층만을 테스트할 수 있다.

Mock 테스트를 사용하지 않은 Slice 테스트

 

 

Mock 테스트란 Mock 객체(가짜 객체)를 만들어 사용하는 테스트이다. 즉, 내가 테스트하는 계층의 실제 객체가 아닌 가짜 객체로 만들어 DI를 하여 사용한다.

Mock 테스트의 동작 원리

위의 그림과 같이 Controller를 테스트할때 Service 계층을 Mock 객체로 만들어 사용하여 테스트 범위가 넘어가지 않게 하는 기술이다.

 

그림과 같이 Mock 객체를 사용하면 오직 테스트하고자 하는 계층에만 집중할 수 있으며, 다른 계층의 구체적인 로직을 수행하지 않아 테스트의 속도가 빠르다.

 

Mokito

Mokito는 Spring Framework에서 자체적으로 지원하고 있는 Mocking 라이브러리이며, Mock(가짜) 객체를 만들기 위해 Dynamic Proxy 기술을 이용한다. Mokito의 핵심기능인 Mocking 기능을 이용해 슬라이스 테스트를 진행할 수 있다.

 

 

Api계층의 Get 요청 테스트

@SpringBootTest
@AutoConfigureMockMvc
public class MemberControllerHomeworkTest {
    @Autowired
    private MockMvc mockMvc;
    @Autowired
    private Gson gson;
    @MockBean // 1.
    private MemberService memberService;
    @Autowired
    private MemberMapper mapper;

    @Test
    void getMembersTest() throws Exception {
    	// 2.
        Member member1 = new Member("mason1@test.com","Mason1","010-1111-1111");
        Member member2 = new Member("mason2@test.com","Mason2","010-2222-2222");
        member1.setStamp(new Stamp());
        member2.setStamp(new Stamp());

        List<Member> list = new LinkedList<>();
        list.add(member1);
        list.add(member2);

	// 3.
        given(memberService.findMembers(
        		Mockito.anyInt(),Mockito.anyInt())).willReturn(new PageImpl<Member>(list)
        	);
		
        
        // 4. 아래의 When과 Then 영역은 위의 예제와 같다.
        String page = "1";
        String size = "10";
        MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
        queryParams.add("page", page);
        queryParams.add("size", size);

        ResultActions actions = mockMvc.perform(
                get("/v11/members")
                        .params(queryParams)
        );

        MvcResult result = actions.andExpect(status().isOk())
                .andExpect(jsonPath("$.pageInfo.page").value(1))
                .andExpect(jsonPath("$.pageInfo.size").value(2))
                .andExpect(jsonPath("$.pageInfo.totalElements").value(2))
                .andExpect(jsonPath("$.pageInfo.totalPages").value(1))
                .andExpect(jsonPath("$.data").isArray())
                .andReturn();

        List<Member> resultList = JsonPath.parse(result.getResponse().getContentAsString()).read("$.data");

        MatcherAssert.assertThat(resultList.size(), CoreMatchers.is(2));
    }
}
  1. @MockBean 에너테이션을 이용하여 MemberService의 Mock 객체를 만들어 의존성 주입을 해준다.
  2. Mock 객체 memberService에서 사용할 가짜 데이터(Stub data)들을 만들어 준다.
  3. 해당 테스트중 memberService.findMembers()가 호출될시 리턴될 가짜 객체를 만들어 준다.
    필요한 파라미터들 또한 Dummy로 제공하며 memberService.findMembers() 메서드가 호출될시 willReturn()메서드에 전달된 Stub data가 반환된다.
  4. 이후의 When과 Then은 위에 Api계층 테스터의 Get 요청 테스터와 동일하게 진행된다.

즉 처음 코드는 우리가 테스트를 위해 필요한 데이터를 직접 데이터베이스에 생성시킨 반면에 Mock 테스트는 Stub 데이터를 만들어 특정한 메서드 호출시에 해당 Stub 데이터가 반환되게끔 Mokito를 이용하여 설정해 주었다. 

 

 

Service 계층 테스트

서비스 계층 또한 비슷한 흐름으로 진행한다. 

@ExtendWith(MockitoExtension.class) // 1.
public class OrderServiceHomeworkTest {
    @Mock // 2.
    OrderRepository orderRepository;
    @InjectMocks 
    OrderService orderService;

    @Test
    @DisplayName("inProcessingOrder")
    public void cancelOrderTest2() {
     // 3.
        Order order = new Order();
        order.setOrderId(1l);
        order.setMember(new Member());
        order.addOrderCoffee(new OrderCoffee());
        order.setOrderStatus(Order.OrderStatus.ORDER_CONFIRM);

        given(orderRepository.findById(Mockito.anyLong())).willReturn(Optional.of(order));

		//When과 Then
        assertThrows(BusinessLogicException.class, () -> orderService.cancelOrder(order.orderId));

    }
}
  1. @ExtendWith를 사용하여 Mock 테스트를 위한 설정을 한다. (Api계층 테스트에서는 @SpringBootTest에서 해주어 생략되었다.)
  2. @Mock에너테이션의 객체는 밑에 에너테이션인 @InjectMocks 객체로 의존성 주입이 된다. 실제 서비스와 같이 의존성 주입이 필요할경우 설정해준다.
  3. 이제부터는 위의 Get 요청 테스트와 같은 진행 방식이다. 테스트에 필요한 Stub 데이터를 만들어주고 데이터 엑세스 계층의 메서드를 Mocking 해준뒤 테스트를 진행한다.

 

해당 테스트 자료 깃헙

https://github.com/Mason3144/Java_test_practiceAndExamples

 

Mock, Dummy, Stub 등의 용어 참조

https://azderica.github.io/00-test-mock-and-stub/

 

 

마무리

API계층, Service 계층, Data Access 계층의 테스트에서 사용되는 에너테이션들이 조금씩 차이가 있다.

 

API 계층

  • @SpringBootTest
  • @AutoConfigureMockMvc
  • @MockBean

Service 계층

  • @ExtendWith
  • @Mock
  • @InjectMocks

Data Access 계층

  • @DataJpaTest

위와 같은 이유는 각각의 계층마다 사용되는 기술들이 다르기 때문이다. 

 

예를 들면 API계층은 Servlet을 기반으로 Service 계층의 객체들의 의존성을 주입받아 작동 된다. Service 계층의 객체들은 사실 Bean으로만 등록된 객체이며 Data Access 계층의 객체들은 JPA 기반으로 작동되기 때문에 테스트, 혹은 Mock 객체를 사용하기 위해 서로 다른 에너테이션을 사용한다.

 

다음 포스트에서는 다른 에너테이션을 이용하여 Controller의 테스트를 진행 해볼 예정