[실습] Mockito 실습
Mockito 실습 개요
이번 실습은 슬라이스 테스트와 비즈니스 로직 테스트에 Mockito를 적용해 보는 실습입니다.
- 지난 챕터까지 학습했던 구현 코드들이 기본적으로 포함이 되어 있으며, 이를 기반으로 요구 사항에 맞게 Mockito를 사용하여 테스트 케이스를 작성하면 됩니다.
- 실습용 프로젝트 패키지는 ‘com.springboot’ 패키지 하위에 아래와 같이 구성되어 있습니다.
- advice
- audit
- coffee
- config
- dto
- exception
- member
- order
- response
- stamp
- validator
- 단위 테스트를 위한 테스트 케이스는 일반적으로 Gradle 기반 프로젝트에서 ‘src/test/java/**’ 경로에 작성합니다.
- 여러분들이 작성할 테스트 케이스 역시 ‘src/test/java/com/springboot/homework’의 실습 과제용 테스트 클래스(MemberControllerHomeworkTest , MemberServiceHomeworkTest) 내부에 포함되어 있습니다.
실습 사전 준비
- 실습용 샘플 프로젝트 복제
- 아래 github 링크에서 실습용 repository를 fork합니다.
- fork한 repository를 여러분의 PC에서 git clone 명령으로 local repository에 복제합니다.
- IntelliJ IDE로 clone 받은 forked local repository 디렉토리의 프로젝트를 Open합니다.
- 아래 실습 요구 사항에 따라 실습을 진행합니다.
- 작성한 코드는 main branch에 작성해 주세요.
- main branch가 아닌 별도의 branch를 생성해서 작업을 했다면 작업이 끝난 후, 반드시 main branch로 merge 해야 합니다.
실습 과제 내용
실습 1: MemberController 클래스에 대한 슬라이스(Slice) 테스트 케이스 작성(Mockito 사용)
- 테스트 케이스 작성을 위한 설명
- MemberController의 각 핸들러 메서드가 클라이언트의 요청을 잘 전달받고, 응답을 잘 전달하는지 테스트하세요.
- 현재 여러분들이 완성해야 할 MemberControllerHomeworkTest 테스트 클래스가 아래 경로에 비어 있는 채로 작성되어 있습니다.
- src/test/java/com/springboot/homework/MemberControllerHomeworkTest.java
- MemberControllerHomeworkTest 테스트 클래스에서 비어 있는 아래 테스트 케이스를 작성해 주세요.
- patchMemberTest()
- MemberController 클래스의 patchMember() 핸들러 메서드를 테스트하기 위한 테스트 케이스입니다.
- getMemberTest()
- MemberController 클래스의 getMember() 핸들러 메서드를 테스트하기 위한 테스트 케이스입니다.
- getMembersTest()
- MemberController 클래스의 getMembers() 핸들러 메서드를 테스트하기 위한 테스트 케이스입니다.
- deleteMemberTest()
- MemberController 클래스의 deleteMember() 핸들러 메서드를 테스트하기 위한 테스트 케이스입니다.
- patchMemberTest()
- 제한 사항
- ⭐ Mockito를 사용한 Mocking을 통해 비즈니스 로직과 데이터 액세스 계층을 거쳐 DB에서 데이터를 가져오는 로직을 단절시켜야 합니다.
- 즉, Controller의 로직에 대한 테스트에 집중할 수 있도록 테스트 케이스를 작성해야 합니다.
- 이외에 테스트에 대한 별도의 제한 사항은 없습니다. 여러분들이 해당 Controller 클래스의 핸들러 메서드를 원하는 방법대로 테스트할 수 있도록 테스트 케이스를 작성하면 됩니다.
- 테스트 입력 값에 별도의 제한 사항은 없습니다.
- 여러분이 다양한 상황을 테스트해보세요.
- ⭐ Mockito를 사용한 Mocking을 통해 비즈니스 로직과 데이터 액세스 계층을 거쳐 DB에서 데이터를 가져오는 로직을 단절시켜야 합니다.
실습 2: OrderService 클래스에 대한 비즈니스 로직 테스트 케이스 작성(Mockito 사용)
- 테스트 케이스 작성을 위한 설명
- OrderService 클래스의 cancelOrder() 메서드의 비즈니스 로직이 잘 동작하는지 테스트하세요.
public void cancelOrder(long orderId) { Order findOrder = findVerifiedOrder(orderId); int step = findOrder.getOrderStatus().getStepNumber(); // OrderStatus의 step이 2 이상일 경우(ORDER_CONFIRM)에는 주문 취소가 되지 않도록한다. if (step >= 2) { throw new BusinessLogicException(ExceptionCode.CANNOT_CHANGE_ORDER); } findOrder.setOrderStatus(Order.OrderStatus.ORDER_CANCEL); orderRepository.save(findOrder); }
- cancelOrder() 메서드의 로직은 다음과 같습니다.
- calcelOrder() 메서드 내의 로직 중에서 OrderStatus의 step이 2 이상일 경우 BusinessLogicException을 발생(throw)시키는지를 테스트하면 됩니다.
- 현재 여러분들이 완성해야 할 OrderServiceHomeworkTest 테스트 클래스가 아래 경로에 비어 있는 채로 작성되어 있습니다.
- src/test/java/com/springboot/homework/OrderServiceHomeworkTest.java
- OrderServiceHomeworkTest 테스트 클래스에서 비어 있는 아래 테스트 케이스를 작성해 주세요.
- cancelOrderTest()
- OrderSevice 클래스의 cancelOrder() 메서드를 테스트하기 위한 테스트 케이스입니다.
- cancelOrderTest()
- 제한 사항
- ⭐ Mockito를 사용한 Mocking을 통해 데이터 액세스 계층을 거쳐 DB에서 데이터를 가져오는 로직을 단절시켜야 합니다.
- 즉, 서비스 클래스 로직에 대한 테스트에 집중할 수 있도록 테스트 케이스를 작성해야 합니다.
- 이외에 테스트에 대한 별도의 제한 사항은 없습니다. 여러분들이 해당 Controller 클래스의 핸들러 메서드를 원하는 방법대로 테스트할 수 있도록 테스트 케이스를 작성하면 됩니다.
- 테스트 입력 값에 별도의 제한 사항은 없습니다.
- 여러분이 다양한 상황을 테스트해보세요.
- ⭐ Mockito를 사용한 Mocking을 통해 데이터 액세스 계층을 거쳐 DB에서 데이터를 가져오는 로직을 단절시켜야 합니다.
MemberControllerHomeworkTest
package com.springboot.homework;
import com.google.gson.Gson;
import com.jayway.jsonpath.JsonPath;
import com.springboot.member.dto.MemberDto;
import com.springboot.member.entity.Member;
import com.springboot.member.mapper.MemberMapper;
import com.springboot.member.service.MemberService;
import com.springboot.stamp.Stamp;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
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.boot.test.mock.mockito.MockBean;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.util.UriComponentsBuilder;
import java.net.URI;
import java.util.List;
import static com.springboot.member.entity.Member.MemberStatus.*;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.doNothing;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Transactional
@SpringBootTest
@AutoConfigureMockMvc
public class MemberControllerHomeworkTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private Gson gson;
@MockBean
private MemberService memberService;
@MockBean
private MemberMapper mapper;
@Test
void patchMemberTest() throws Exception {
// given
long memberId = 1;
MemberDto.Patch patch = MemberDto.Patch.builder()
.memberId(memberId)
.name("김코딩")
.phone("010-1234-6787")
.memberStatus(MEMBER_SLEEP)
.build();
MemberDto.response responseDto = new MemberDto.response(1L,
"coding@google.com",
"김코딩",
"010-1234-6787",
MEMBER_SLEEP,
new Stamp());
given(mapper.memberPatchToMember(Mockito.any(MemberDto.Patch.class))).willReturn(new Member());
given(memberService.updateMember(Mockito.any(Member.class))).willReturn(new Member());
given(mapper.memberToMemberResponse(Mockito.any(Member.class))).willReturn(responseDto);
String content = gson.toJson(patch);
URI uri = UriComponentsBuilder.newInstance().path("/v11/members/{memberId}").buildAndExpand(memberId).toUri();
// when
ResultActions actions = mockMvc.perform(
MockMvcRequestBuilders
.patch(uri)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(content)
);
// then
actions.andExpect(status().isOk())
.andExpect(jsonPath("$.data.phone").value(patch.getPhone()))
.andExpect(jsonPath("$.data.name").value(patch.getName()));
}
@Test
void getMemberTest() throws Exception {
// TODO MemberController의 getMember() 핸들러 메서드를 테스트하는 테스트 케이스를 여기에 작성하세요.
// TODO Mockito를 사용해야 합니다. ^^
// given
long memberId = 1L;
MemberDto.response response = new MemberDto.response(1L,
"lucky@cat.house",
"김러키",
"010-1111-2222",
MEMBER_ACTIVE,
new Stamp());
given(memberService.findMember(Mockito.anyLong())).willReturn(new Member());
given(mapper.memberToMemberResponse(Mockito.any(Member.class))).willReturn(response);
URI uri = UriComponentsBuilder.newInstance().path("/v11/members/{memberId}").buildAndExpand(memberId).toUri();
// when
ResultActions actions = mockMvc.perform(
MockMvcRequestBuilders
.get(uri)
.accept(MediaType.APPLICATION_JSON)
);
// then
actions.andExpect(status().isOk())
.andExpect(jsonPath("$.data.email").value("lucky@cat.house"))
.andExpect(jsonPath("$.data.name").value("김러키"));
}
@Test
void getMembersTest() throws Exception {
// TODO MemberController의 getMembers() 핸들러 메서드를 테스트하는 테스트 케이스를 여기에 작성하세요.
// TODO Mockito를 사용해야 합니다. ^^
// given
Member member1 = new Member("lucky@cat.house", "김러키", "010-1234-5678");
member1.setMemberStatus(MEMBER_ACTIVE);
member1.setStamp(new Stamp());
Member member2 = new Member("latte@cat.house", "김라떼", "010-1111-2222");
member2.setMemberStatus(MEMBER_ACTIVE);
member2.setStamp(new Stamp());
Page<Member> pageMembers = new PageImpl<>(List.of(member1, member2), PageRequest.of(0, 10, Sort.by("memberId").descending()), 2);
List<MemberDto.response> response = List.of(
new MemberDto.response(1L,
"lucky@cat.house",
"김러키",
"010-1111-2222",
MEMBER_ACTIVE,
new Stamp()),
new MemberDto.response(2L,
"latte@cat.house",
"김라떼",
"010-3333-4444",
MEMBER_ACTIVE,
new Stamp())
);
given(memberService.findMembers(Mockito.anyInt(), Mockito.anyInt())).willReturn(pageMembers);
given(mapper.membersToMemberResponses(Mockito.anyList())).willReturn(response);
String page = "1";
String size = "10";
MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
queryParams.add("page", page);
queryParams.add("size", size);
URI uri = UriComponentsBuilder.newInstance().path("/v11/members").build().toUri();
// when
ResultActions actions = mockMvc.perform(
MockMvcRequestBuilders
.get(uri)
.params(queryParams)
.accept(MediaType.APPLICATION_JSON)
);
// then
MvcResult result = actions.andExpect(status().isOk())
.andExpect(jsonPath("$.data").isArray())
.andReturn();
List list = JsonPath.parse(result.getResponse().getContentAsString()).read("$.data");
assertThat(list.size(), is(2));
}
@Test
void deleteMemberTest() throws Exception {
// TODO MemberController의 deleteMember() 핸들러 메서드를 테스트하는 테스트 케이스를 여기에 작성하세요.
// TODO Mockito를 사용해야 합니다. ^^
// given
long memberId = 1L;
URI uri = UriComponentsBuilder.newInstance().path("/v11/members/{memberId}").buildAndExpand(memberId).toUri();
doNothing().when(memberService).deleteMember(memberId);
// when
ResultActions actions = mockMvc.perform(
MockMvcRequestBuilders
.delete(uri)
);
// then
actions.andExpect(status().isNoContent());
}
}
OrderServiceHomeworkTest
package com.springboot.homework;
import com.springboot.exception.BusinessLogicException;
import com.springboot.order.entity.Order;
import com.springboot.order.repository.OrderRepository;
import com.springboot.order.service.OrderService;
import org.aspectj.weaver.ast.Or;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.BDDMockito.given;
@ExtendWith(MockitoExtension.class)
public class OrderServiceHomeworkTest {
// 단위 테스트에는 Mock
@Mock
private OrderRepository orderRepository;
@InjectMocks
private OrderService orderService;
@Test
public void cancelOrderTest() {
// TODO OrderService의 cancelOrder() 메서드를 테스트하는 테스트 케이스를 여기에 작성하세요.
// TODO Mockito를 사용해야 합니다. ^^
// given
long orderId = 1L;
Order order = new Order();
order.setOrderStatus(Order.OrderStatus.ORDER_CONFIRM);
given(orderRepository.findById(Mockito.anyLong()))
.willReturn(Optional.of(order));
// when, then
assertThrows(BusinessLogicException.class, () -> orderService.cancelOrder(orderId));
}
}
Chapter - TDD
이번 시간에는 TDD가 무엇인지 알아보고 TDD의 개발 흐름을 대략적으로 살펴보겠습니다.
그리고 TDD의 장/단점을 살펴보면서 TDD를 적용하려면 어떻게 하는 게 좋을지 생각해 보는 시간을 가져 보겠습니다.
학습 목표
- TDD가 무엇인지 이해할 수 있다.
- TDD의 개발 흐름을 이해할 수 있다.
- TDD의 장단점을 이해할 수 있다.
[기본] TDD란?
TDD(Test Driven Development)란?
- TDD(Test Driven Development, 테스트 주도 개발)란 무엇일까요?
테스트 주도 개발이라는 용어에서 알 수 있듯이 대략적으로 의미를 생각해 봐도 개발을 진행하는 데 있어서 테스트가 왠지 중요한 역할을 할 것이라는 생각이 듭니다.
우리가 Spring Data JDBC에서 대략적으로 학습했던 DDD(Domain Driven Design)를 떠올려 보세요.
DDD는 도메인 중심의 설계 기법입니다. 도메인 모델이 애플리케이션 개발에 있어 핵심적인 역할을 하기 때문에 도메인 모델 없이는 애플리케이션도 있을 수 없습니다.
도메인 모델이 DDD의 중심에 서 있다면 TDD에는 테스트가 그 중심에 서 있습니다.
그런데 테스트 주도 개발이라는 건 도대체 어떤 의미일까요?
TDD의 개념을 한마디로 요약하자면 ‘테스트를 먼저 하고 구현은 그다음에 한다’로 요약할 수 있습니다.
구현도 안 했는데 테스트를 어떻게 할 수 있는지 참 신기하기도 합니다.
TDD가 아닌 전통적인 개발 방식
우리가 어떤 서비스 애플리케이션을 개발할 때 개발 절차는 일반적으로 다음과 같습니다. (고객의 요청으로 고객이 원하는 시스템을 구축하는 것이 아니라 서비스 제공 기업에서 불특정 다수의 회원에게 제공하는 서비스 애플리케이션을 의미합니다.)
- 서비스 제작에 관여하는 이해 당사자(기획자, 프론트엔드 개발자, 백엔드 개발자, 웹 디자이너 등)가 모여 서비스에 대한 콘셉트과 해당 콘셉트에 따른 요구 사항을 지속적으로 수집합니다.
- 수집된 요구 사항에 맞춰 서비스를 화면으로 제공하기 위한 UI(User Interface)를 설계하면서 구체적인 기능 요구 사항들을 정의합니다.
- 프론트엔드 개발자는 기능 요구 사항과 UI를 통해 프론트엔드 측 개발을 진행하고, 웹 디자이너는 화면을 디자인하며, 백엔드 개발자는 역시 기능 요구 사항에 맞춰 백엔드 애플리케이션을 디자인합니다.
1번, 2번, 3번 중에서 일반적으로 3번의 과정을 진행하면서 요구 사항이 수정되기도 하고 그에 따라 UI가 변경되기도 하며, 프론트엔드와 백엔드 측 설계가 변경되는 경우가 많습니다.
앞에서 얘기한 개발 절차는 일반적인 흐름이긴 하지만 애자일 방식으로 1주 ~ 3주 단위로 기획, 설계, 구현을 반복적으로 빠르게 진행하면서 애플리케이션을 완성하는 방식을 도입하는 기업도 많이 있음을 참고하면 좋을 것 같습니다.
애자일 개발 방식에 대해서 더 알아보고 싶다면 아래 \[심화 학습]을 참고하세요.
3번 과정에서 백엔드 개발자의 개발 흐름은 일반적으로 다음과 같습니다.
- 이해 당사자들 간에 수집된 요구 사항과 설계된 화면(UI 설계서 등) 등을 기반으로 도메인 모델을 도출합니다.
- 도출된 도메인 모델을 통해 클라이언트의 요청을 받아들이는 엔드포인트와 비즈니스 로직, 데이터 액세스를 위한 클래스와 인터페이스 등을 설계해서 큰 그림을 그려봅니다.
- 클래스 설계를 통해 애플리케이션에 대한 큰 그림을 그려보았다면 클래스와 인터페이스의 큰 틀을 작성합니다.
- 클래스와 인터페이스의 큰 틀이 작성되었다면 클래스와 인터페이스 내에 메서드를 정의하면서 세부 동작을 고민하고, 코드로 구현합니다.
- 해당 메서드의 기능 구현이 끝났다면 구현한 기능이 잘 동작하는지 테스트합니다.
- 테스트에 문제가 발생한다면 구현한 코드를 디버깅하면서 문제의 원인을 찾습니다.
위 백엔드 개발자의 개발 흐름 중, TDD 관점에서 두드러지는 점 한 가지는 3번 ~ 6번의 과정에서 구현이 먼저고, 테스트가 나중이라는 점입니다.
3번, 4번 과정에서 구현을 먼저 하고 5번 과정에서 테스트를 진행하는 것을 볼 수 있습니다.
여기서 백엔드 개발자의 개발 흐름을 예로 들었지만 프론트엔드 개발자든 백엔드 개발자든 간에 애플리케이션 개발 흐름은 ‘선 구현, 후 테스트’가 일반적인 흐름입니다.
구현도 하지 않았는데 테스트를 한다는 것 자체가 말이 안 되는 상황이라고 생각되는 건 어쩌면 당연한 걸 테니까요.
이처럼 말이 안 되는 걸 말이 되게 하는 개발 방식이 바로 TDD입니다.
TDD 방식으로 개발하며 TDD의 특성 알아보기
그렇다면 도대체 TDD 방식으로 개발을 어떻게 진행하는 걸까요?
하나의 기능을 구현해 보면서 TDD의 개발 방식이 어떤 형태로 이루어지는지 살펴보도록 하겠습니다.
‘⭐’가 붙은 내용은 우리가 하나의 기능을 TDD 방식으로 구현해 보면서 발견되는 TDD의 특성이라는 걸 기억하세요.
마지막에 한꺼번에 정리하도록 하겠습니다.
우리가 애플리케이션 보안에 대한 학습은 아직 하지 않았기 때문에 커피 주문 샘플 애플리케이션에서 회원 등록 시, 로그인 인증을 위한 패스워드 정보는 빠져 있긴 하지만 어쨌든 회원 등록 시 입력하는 로그인 인증용 패스워드의 유효성을 검증하는 기능을 TDD 방식으로 개발해 보겠습니다.
먼저 간단히 우리가 구현할 패스워드 유효성 검증에 통과하는 조건은 다음과 같습니다.
- 패스워드 길이는 8 ~ 20 사이의 길이(length)여야 한다.
- 패스워드는 알파벳 소문자 + 알파벳 대문자 + 숫자 + 특수 문자 형태로 구성되어야 한다.
- 알파벳 대/소문자와 숫자를 제외한 모든 문자는 특수문자라고 가정합니다.
위 조건을 모두 만족해야지만 패스워드 유효성 검증에서 통과할 수 있습니다.
먼저 패스워드 유효성 검증을 수행할 테스트 클래스와 테스트 케이스의 이름을 정합니다.
package com.springboot.tdd;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
public class PasswordValidatorTest {
@DisplayName("패스워드 유효성 검증 테스트: 모든 조건에 만족")
@Test
public void validatePassword() {
}
}
[코드 3-200] 테스트 클래스와 테스트 케이스 이름 정하기
패스워드의 유효성 검증을 테스트할 테스트 클래스의 이름과 테스트를 수행할 메서드의 이름을 적절하게 정했습니다.
이 상태에서 테스트 케이스를 실행해 봅니다.
구체적인 테스트 코드가 전혀 없기 때문에 일단 테스트 케이스의 실행 결과는 “passed”입니다.
우리는 이제 테스트를 본격적으로 수행할 수 있는 적절한 환경을 갖추게 되었습니다.
✅ 모든 유효성 검증 조건을 만족하는 테스트
다음 단계로 넘어가보겠습니다.
[그림 3-79] 테스트 케이스의 컴파일 에러 단계
그림 3-79는 완성된 테스트 케이스입니다.
테스트 데이터는 패스워드 유효성 검증에 통과하는 문자열로 먼저 입력했습니다.
이렇게 하면 유효성 검증에 통과하지 못하는 실패한 테스트 케이스를 성공하는 테스트로 단계적으로 수정하기 때문에 모든 조건에 만족하기 위해 한꺼번에 너무 많은 기능을 구현하지 않고 점진적으로 수정해 갈 수 있습니다.
⭐ 모든 조건에 만족하는 테스트를 먼저 진행한 뒤에 조건에 만족하지 않는 테스트를 단계적으로 진행하면서 실패하는 테스트를 점진적으로 성공시켜 갑니다.
그런데 테스트 케이스를 실행해 봤자 테스트에 실패할 것은 뻔한 사실입니다.
아직 존재하지 않는 PasswordValidator 클래스 때문에 컴파일 에러가 날 테니까요.
그래도 테스트 케이스를 실행해 봅니다. 실행 결과는 공식적으로 “failed”가 뜨는 건 아니지만 컴파일 에러가 나므로 “failed”나 마찬가지입니다.
우선 컴파일 에러부터 해결해 봅시다.
해결하는 방법은 단순합니다. 존재하지 않는 PasswordValidator 클래스를 생성하면 됩니다.
package com.springboot.tdd;
public class PasswordValidator {
}
[코드 3-201] 아직 비어 있는 PasswordValidator 클래스
[그림 3-80] 테스트 케이스의 컴파일 에러 단계
코드 3-201과 같이 존재하지 않는 PasswordValidator 클래스를 생성함으로써 클래스가 존재하지 않아서 발생하는 컴파일 에러는 해결될 것입니다.
그런데 이 상태에서 테스트 케이스를 실행해 봤자 \[그림 3-80]과 같이 아직 빨간 글자가 사라지지 않았기 때문에 당연히 컴파일 에러가 발생할 것이라고 예상할 수 있습니다.
하지만 그래도 테스트 케이스를 실행해 봅니다.
PasswordValidator 클래스에 validate() 메서드가 없어서 발생하는 에러를 먼저 해결해 보겠습니다.
package com.springboot.tdd;
public class PasswordValidator {
public void validate(String password) {
}
}
[코드 3-202] PasswordValidator 클래스에 validate() 메서드 추가
IntelliJ의 메서드 생성 기능을 이용해서 코드 3-202와 같이 메서드 body가 비어있는 validate() 메서드를 추가했습니다.
[그림 3-81] 테스트 케이스의 컴파일 에러 수정 완료
PasswordValidator 클래스에 validate() 메서드를 추가한 뒤에 \[그림 3-81]과 같이 드디어 모든 빨간 줄과 빨간색 표시가 사라졌습니다.
이 상태에서 테스트 케이스를 실행하면 이제 드디어 첫 번째 “passed”를 볼 수 있습니다.
TDD의 개발 방식을 어렴풋이나마 짐작할 수 있을까요?
⭐ “failed”인 테스트 케이스를 지속적으로 그리고 단계적으로 수정하면서 테스트 케이스 실행 결과가 “passed”가 되도록 만들고 있습니다.
⭐ 지금껏 작성한 코드의 양은 많지 않습니다. 여기서 TDD의 또 하나의 특성을 알 수 있습니다. TDD에서는 테스트가 “passed” 될 만큼의 코드만 우선 작성합니다.
✅ (알파벳 소문자 + 알파벳 대문자 + 숫자 + 특수 문자) 조건에서 특수 문자가 빠진 경우 테스트
package com.springboot.tdd;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.function.Executable;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
public class PasswordValidatorTest {
@DisplayName("모든 조건에 만족")
@Test
public void validatePasswordAllCriteria() {
// given
String password = "Abcd1234!";
// when
PasswordValidator validator = new PasswordValidator();
Executable executable = () -> validator.validate(password);
// then
assertDoesNotThrow(executable);
}
// (1)
@DisplayName("특수 문자 포함 안됨 테스트")
@Test
public void validatePasswordWithoutSpecialCharacter() {
// given
String password = "Abcd1234";
// when
PasswordValidator validator = new PasswordValidator();
Executable executable = () -> validator.validate(password);
// then
assertDoesNotThrow(executable);
}
}
[코드 3-203] 특수문자가 없는 패스워드 테스트
이제 코드 3-203처럼 테스트 케이스 하나를 더 추가합니다.
이번에는 유효한 패스워드가 되는 조건 중에서 ‘특수 문자 포함’이라는 조건에 만족하지 않는 패스워드를 테스트하겠습니다.
현재 상태에서 코드 3-203의 두 개의 테스트 케이스를 모두 실행하면 실행 결과는 “passed”일 것입니다.
PasswordValidator 클래스에 패스워드를 검증하는 조건이 하나도 없으니까요.
이 상태에서 모든 테스트 케이스가 실패하는 조건은 무엇일까요?
패스워드가 어떤 조건에 만족하든지 간에 무조건 Exception을 던지게 하면 될 것입니다.
package com.springboot.tdd;
public class PasswordValidator {
public void validate(String password) {
throw new RuntimeException("Invalid password");
}
}
[코드 3-204] PasswordValidator의 validate()에 Exception을 던지도록 수정
PasswordValidator.validate() 메서드가 무조건 RuntimeException을 던지도록 수정했습니다.
이 상태에서 코드 3-203의 모든 테스트 케이스를 실행하면 두 개의 테스트 케이스 모두 “failed” 됩니다.
이제 패스워드에 특수 문자를 포함하지 않는 경우에만 “failed” 되도록 기능을 수정해 봅시다.
package com.springboot.tdd;
public class PasswordValidator {
public void validate(String password) {
// (1)
boolean containSpecialCharacter =
password.chars()
.anyMatch(ch -> !(Character.isDigit(ch) || Character.isAlphabetic(ch)));
// (2)
if (!containSpecialCharacter) {
throw new RuntimeException("Invalid password");
}
}
}
[코드 3-205] 특수 문자를 포함하지 않는 경우만 예외를 던지도록 수정
코드 3-205에서는 (1)과 같이 특수 문자를 포함하고 있는지의 여부를 체크한 뒤에, (2)에서 특수 문자를 포함하고 있지 않을 경우에만 예외를 던지도록 기능을 수정했습니다.
테스트를 다시 실행하면 validatePasswordWithoutSpecialCharacter() 테스트 케이스는 특수 문자가 없기 때문에 “failed”가 됩니다.
validatePasswordWithoutSpecialCharacter()의 실행 결과를 “passed”가 되도록 해 봅시다.
package com.springboot.tdd;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.function.Executable;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
public class PasswordValidatorTest {
...
...
@DisplayName("특수 문자 포함 안됨")
@Test
public void validatePasswordWithoutSpecialCharacter() {
// given
String password = "Abcd1234&!"; // (1)
// when
PasswordValidator validator = new PasswordValidator();
Executable executable = () -> validator.validate(password);
// then
assertDoesNotThrow(executable);
}
}
[코드 3-206] 특수 문자가 포함되도록 테스트 데이터 수정
코드 3-206의 (1)과 같이 특수 문자가 포함이 되도록 패스워드를 수정했습니다.
이제 클래스 레벨에서 테스트 케이스를 실행시키면 모든 테스트의 실행 결과는 “passed”입니다.
✅ PasswordValidator 클래스 리팩토링
모든 테스트 케이스는 성공을 했는데, 다른 조건을 만족하는 로직이 추가되면 PasswordValidator 클래스의 유효성 검증 로직 코드가 깔끔하지 않을 것 같다는 생각이 들었습니다.
정규 표현식을 사용하면 더 깔끔할 것 같아서 정규 표현식을 사용하여 패스워드의 유효성 검사를 진행하도록 리팩토링 하기로 결정했습니다.
package com.springboot.tdd;
import java.util.regex.Pattern;
public class PasswordValidator {
public void validate(String password) {
// (1)
if (!Pattern.matches("(?=.*\\\\W)(?=\\\\S+$).+", password)) {
throw new RuntimeException("Invalid password");
}
}
}
[코드 3-207] 특수 문자가 포함 여부를 정규 표현식으로 검증하도록 수정
코드 3-207과 같이 정규 표현식으로 체크하도록 수정하니 코드가 깔끔해졌습니다.
수정된 코드가 잘 동작하는지를 체크하기 위해서 전체 테스트 케이스를 다시 실행합니다.
“passed”입니다.
그런데 수정된 코드가 잘 동작하는지 여부를 정확하게 테스트하기 위해서는 특수 문자를 포함하지 않은 패스워드를 한번 더 테스트해서 “failed”인지 확인하는 것이 좋습니다.
package com.springboot.tdd;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.function.Executable;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
public class PasswordValidatorTest {
...
...
@DisplayName("특수 문자 포함 안됨 테스트")
@Test
public void validatePasswordWithoutSpecialCharacter() {
// given
String password1 = "Abcd1234&!";
String password2 = "Abcd1234"; // (1)
// when
PasswordValidator validator = new PasswordValidator();
Executable executable1 = () -> validator.validate(password1);
Executable executable2 = () -> validator.validate(password2); // (2)
// then
assertDoesNotThrow(executable1);
assertDoesNotThrow(executable2); // (3)
}
}
[코드 3-208] 특수 문자가 포함되지 않은 테스트 데이터를 추가한 후 재검증
코드 3-208의 (1)과 같이 특수 문자를 포함하지 않은 패스워드를 테스트 데이터로 추가한 후, (2)에서 동작을 정의하고 (3)에서 추가된 테스트 데이터에 대한 검증을 실시합니다.
테스트 결과는 “failed”입니다.
이제 특수 문자가 포함되지 않은 패스워드는 유효성 검증에서 실패한다는 것을 확실히 검증할 수 있게 되었습니다.
이제 아래와 같이 유효한 패스워드인지를 검증하는 다음 조건에 대해 앞에서 특수 문자 포함 여부를 검증한 방식과 마찬가지로 테스트와 검증, 리팩토링 단계를 반복해서 진행하면 됩니다.
- 패스워드가 특수문자를 포함하면서 알파벳 소문자를 포함하는지 테스트, 검증, 리팩토링 단계 반복
- 패스워드가 특수문자 + 알파벳 소문자 + 알파벳 대문자를 포함하는지 테스트, 검증, 리팩토링 단계 반복
- 패스워드가 특수문자 + 알파벳 소문자 + 알파벳 대문자 + 숫자를 포함하는지 테스트, 검증, 리팩토링 단계 반복
- 패스워드가 특수문자 + 알파벳 소문자 + 알파벳 대문자 + 숫자를 포함하면서 8 ~ 20 길이(length)를 만족하는지 테스트, 검증, 리팩토링 단계 반복
TDD의 개발 방식이 조금은 이해가 될까요?
⭐ TDD의 개발 방식은 ‘실패하는 테스트 → 실패하는 테스트를 성공할 만큼의 기능 구현 → 성공하는 테스트 → 리팩토링 → 실패하는 테스트와 성공하는 테스트 확인’이라는 흐름을 반복합니다.
TDD의 특징 정리
앞에서 간단한 기능을 구현해 보면서 살펴보았던 TDD의 특징을 다시 한번 정리해 보겠습니다.
- TDD는 모든 조건에 만족하는 테스트를 먼저 진행한 뒤에 조건에 만족하지 않는 테스트를 단계적으로 진행하면서 실패하는 테스트를 점진적으로 성공시켜 갑니다.
- TDD는 테스트 실행 결과가 “failed”인 테스트 케이스를 지속적으로 그리고 단계적으로 수정하면서 테스트 케이스 실행 결과가 “passed”가 되도록 만들고 있습니다.
- TDD는 테스트가 “passed” 될 만큼의 코드만 우선 작성합니다.
- TDD는 ‘실패하는 테스트 → 실패하는 테스트를 성공할 만큼의 기능 구현 → 성공하는 테스트 → 리팩토링 → 실패하는 테스트와 성공하는 테스트 확인’이라는 흐름을 반복합니다.
앞에서 TDD 방식으로 진행한 패스워드 유효성 검증 기능의 구현에 대한 설명 자체는 길지만 실제 TDD 방식으로 잘 진행된다면 테스트와 기능 구현, 리팩토링까지 빠르게 진행이 가능합니다.
TDD의 장점과 단점
✔ TDD의 장점
TDD의 장점을 정리하자면 다음과 같습니다.
- 테스트를 통과할 만큼의 기능을 구현하므로 한 번에 너무 많은 기능을 구현할 필요가 없습니다.
- 테스트의 코드가 추가되면서 검증하는 범위가 넓어질수록 기능 구현도 점진적으로 완성되어 갑니다.
- 즉, 단순한 기능에서 복잡한 기능으로 확장되면서 그때그때 검증을 빼먹지 않고 할 수 있습니다.
- 리팩토링 할 부분이 눈에 보이면 그때그때 리팩토링을 빠르게 진행하기 때문에 리팩토링의 비용이 상대적으로 적어집니다.
- 우리가 실무에서 하나의 기능을 완성해놓고 보면 리팩토링이 필요한 부분이 눈에 들어오는 경우가 많습니다. 그런데 일정 상의 이유로 리팩토링을 하지 않고, 대충 넘어가는 경우가 상당히 많은데, TDD에 익숙해지면 그때그때 리팩토링을 진행하게 되므로 리팩토링에 대한 비용이 상대적으로 적어질 가능성이 높습니다.
- 이미 잘 동작하는 코드를 수정하는 일은 부담스러운 게 사실입니다. 왜냐고요? ‘수정을 했는데 잘 동작하지 않으면 어떻게하지?’라는 심리적인 불안감이 발생할 수 있기 때문이니까요.
- 그런데 TDD 방식에서는 항상 테스트 케이스가 존재하기 때문에 기존 코드를 수정하더라도 상대적으로 심리적 불안감이 줄어들 수 있습니다.
- 리팩토링을 통해 꾸준히 코드를 개선하므로 코드의 품질을 일정 부분 유지할 수 있습니다.
- 리팩토링이 전혀 이루어지지 않은 상태에서 코드 품질이 점점 나빠져가는 상태로 애플리케이션이 구현되고 있는 모습을 상상해 보세요. 그만큼의 유지 보수 비용이 높아진다는 사실을 알 수 있을 겁니다.
- 코드 수정 이후, 바로 테스트를 진행할 수 있으므로 코드 수정 결과를 빠르게 피드백받을 수 있습니다.
- 수정 결과를 그때그때 확인할 수 있으므로 잘못된 코드가 남아있을 가능성이 상대적으로 줄어듭니다.
✔ TDD의 단점
그렇다면 TDD의 단점은 무엇일까요?
- 가장 큰 단점은 TDD의 개발 방식이 익숙하지 않다는 것입니다.
- 우리가 일반적으로 진행했던 익숙한 개발 방식은 단번에 버리고 TDD 방식으로 개발을 진행한다는 건 상당히 어려운 일입니다.
- 테스트 코드의 작성에 익숙하지 않은 사람, 테스트 코드를 작성하길 원치 않는 사람들에게는 부정적인 방식일 수 있습니다.
- 개발할 시간도 부족한데 테스트 코드를 언제 작성하냐는 분들이 많습니다.
- 어느 정도 일리 있는 말이지만 기능 구현이 끝나더라도 테스트 없이 하나의 애플리케이션이 릴리스 되지 않습니다.
- 개발자 선에서 자체 테스트가 이루어지지 않는다면 QA로 테스트 주도권이 넘어가는 순간 아마 야근의 연속이 될 가능성이 높을 것입니다.
- 따라서 어떤 식으로든 개발자가 테스트를 할 수밖에 없고, 기능 구현이 다 끝난 상태에서 테스트를 수동으로 하게 되면 전체적으로 개발 완료 시간이 더 늘어날 가능성이 높습니다.
- 결국 테스트를 미리미리 하는 게 좋으며, 테스트 시간을 줄이기 위해서는 테스트를 자동화할 수밖에 없습니다. 테스트를 자동화하려면 결국 테스트 코드를 만들 수밖에 없습니다.
- 팀 단위로 개발을 진행해야 하므로 팀원들 간 사전에 협의가 되어야 합니다.
- 개발을 혼자서 할 수는 없습니다.
- TDD 방식을 적용하기 위해서는 팀 차원에서 TDD 방식에 대한 논의가 필요하고, 팀원들 각자가 TDD를 수용하고, 적용해 보려는 합의가 필요합니다.
기존의 개발 방식을 버리고 한 번에 TDD 개발 방식으로 바꾸기는 상당히 어렵습니다.
따라서 작은 기능 구현에 TDD를 점진적으로 도입해 보면서 TDD로 개발하는 방식이 익숙해질 때까지 연습이 필요하며, 이렇게 TDD로 개발을 진행했을 때의 장점에 대한 확신이 든다면 팀이나 부서 차원에서 납득할 만한 이유를 통해 알리는 시간이 필요할 것입니다.
TDD가 분명히 만능은 아닐 것입니다.
하지만 지금부터 아주 조금씩의 시간을 투자해서 TDD를 연습해 보세요.
미래에 여러분들의 야근 시간이 줄어들지도 모르니까요.
핵심 포인트
- TDD는 테스트가 개발을 주도하는 방식이다.
- TDD는 테스트 코드를 먼저 작성하고, 그다음에 기능을 구현한다.
- TDD 특징
- TDD는 모든 조건에 만족하는 테스트를 먼저 진행한 뒤에 조건에 만족하지 않는 테스트를 단계적으로 진행하면서 실패하는 테스트를 점진적으로 성공시켜 나간다.
- TDD는 테스트 실행 결과가 “failed”인 테스트 케이스를 지속적으로 그리고 단계적으로 수정하면서 테스트 케이스 실행 결과가 “passed”가 되도록 만든다.
- TDD는 테스트가 “passed” 될 만큼의 코드만 우선 작성한다.
- TDD는 ‘실패하는 테스트 → 실패하는 테스트를 성공할 만큼의 기능 구현 → 성공하는 테스트 → 리팩토링 → 실패하는 테스트와 성공하는 테스트 확인’이라는 흐름을 반복한다.
심화 학습
- 애자일 소프트웨어 개발에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
- TDD에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
[Spring MVC] API 문서화
여러분은 이제 커피 주문 샘플 애플리케이션에 단위 테스트와 슬라이스 테스트까지 적용할 수 있게 됨으로써 애플리케이션에서 발생할 가능성이 있는 버그의 해결 또는 애플리케이션 기능 추가 및 변경 작업에 있어 심리적인 안정감을 갖게 해주는 든든한 지원군을 얻게 되었습니다.
아직 보안이 적용되어 있지는 않지만 만약 여러분들이 만든 애플리케이션의 API를 프론트엔드 개발팀이 사용을 해보고 싶다는 연락이 오면 어떻게 해야 될까요?
여러분들이 만든 애플리케이션의 API를 어떻게든 사용할 수 있도록 방법을 제공해 주어야 할 것입니다.
이처럼 여러분들이 만드는 애플리케이션을 사용할 수 있도록 해주는 방법이 바로 API 문서화(Documentation)입니다.
이번 시간에는 애플리케이션에 구현되어 있는 Controller에서 클라이언트가 사용할 수 있는 API 정보를 어떻게 문서화할 수 있는지 알아보도록 하겠습니다.
[Spring MVC] API 문서화 학습을 위한 사전 준비 사항
이번 유닛의 학습을 원활하게 진행하기 위해 지금까지 여러분들이 구현해 본 Controller 및 Service , Repository 등의 클래스들이 포함된 템플릿 프로젝트를 사용하도록 하겠습니다.
데이터 액세스 계층은 Spring Data JPA의 리포지토리로 구성이 되어 있습니다.
- 템플릿 프로젝트 복제
- 아래 github 링크에서 실습용 repository를 clone합니다.
- IntelliJ IDE로 clone 받은 local repository 디렉토리의 프로젝트를 Open합니다.
- 학습을 진행하며 학습 내용에 따라 예제 코드를 타이핑해봅니다.
[Spring MVC] API 문서화 학습 참고용 레퍼런스 코드
이번 유닛에서 학습한 예제 코드는 아래 github에서 확인할 수 있습니다.
챕터에서 사용한 예제 코드는 챕터에 있는 코드들을 직접 타이핑해본 후, 학습 내용을 조금 더 구체적으로 이해하기 위한 용도로만 활용해 주세요.
- API 문서화 유닛에 사용한 예제 코드
학습 목표
- API 문서화가 왜 필요한지 이해할 수 있다.
- Spring Rest Docs의 사용법을 이해할 수 있다.
- Spring Rest Docs와 Swagger의 차이점을 이해할 수 있다.
- Spring Rest Docs를 이용해서 API를 문서화할 수 있다.
- 문서화된 API를 외부 사용자에게 제공할 수 있다.
Chapter - API 문서화
이번 시간에는 API 문서화가 무엇이고, API 문서화가 필요한 이유에 대해서 알아보도록 하겠습니다.
그리고 Java 기반의 애플리케이션에서 사용할 수 있는 대표적인 API 문서화 툴인 Spring Rest Docs와 Swagger를 비교해 보면서 둘 사이의 장단점을 살펴보도록 하겠습니다.
학습 목표
- API 문서화가 무엇인지 이해할 수 있다.
- API 문서화가 필요한 이유에 대해서 이해할 수 있다.
- Spring Rest Docs와 Swagger의 차이점을 이해할 수 있다.
API 문서화(Documentation)가 필요한 이유
API 문서화란?
API의 문서화란 무엇일까요?
여러분들이 API 계층에서 학습한 Controller는 클라이언트 쪽에서 HTTP request URL(또는 URI)을 통해 클라이언트의 요청을 전달받습니다.
그런데 백엔드 애플리케이션, 특히 우리가 학습하고 있는 백엔드 애플리케이션은 REST API 방식의 애플리케이션입니다.
따라서 이번 유닛에서 의미하는 API 문서화란 클라이언트가 REST API 백엔드 애플리케이션에 요청을 전송하기 위해서 알아야 되는 요청 정보(요청 URL(또는 URI), request body, query parameter 등)를 문서로 잘 정리하는 것을 의미합니다.
이처럼 API 요청을 위해 필요한 정보들을 문서로 잘 정리해야 하는 이유는 무엇일까요?
바로 여러분들이 만들어 놓은 REST API 기반의 백엔드 애플리케이션을 클라이언트 쪽에서 사용하려면 API 사용을 위한 어떤 정보가 필요하기 때문입니다.
API 사용을 위한 어떤 정보가 담겨 있는 문서를 API 문서 또는 API 스펙(사양, Specification)이라고 합니다.
API 문서는 개발자가 요청 URL(또는 URI) 등의 API 정보를 직접 수기로 작성할 수도 있고, 애플리케이션 빌드를 통해 API 문서를 자동 생성할 수도 있습니다.
API 문서 생성의 자동화가 필요한 이유
여러분들이 이번 코스를 끝내고 프로젝트를 진행하여 REST API 기반의 백엔드 애플리케이션을 개발하고 있고, 프론트엔드 쪽을 담당하고 있는 수강생들은 여러분들이 개발한 애플리케이션의 API 정보를 문서로 제공해 줄 것을 요청했다고 생각해 봅시다.
그런데 여러분들이 API 문서를 워드 프로세서나 노션, 에버노트 등의 문서를 이용해서 직접 수기로 작성한 후에 제공한다고 생각해 보세요.
그리고 여러분들이 기업에 입사한 후, 어떤 프로젝트에 참여했을 때 애플리케이션 구현 단계 전/후로 문서 작업을 해야 되는 경우가 굉장히 많습니다.
그런데 API 문서마저도 수기로 직접 작성해야 된다고 생각해 보세요.
문서 작업의 중요성은 더 설명할 필요는 없겠지만 API 문서도 수기로 직접 작성해야 되는 것은 너무나 비효율적입니다.
또한 한번 작성된 API 문서에 기능이 추가되거나 수정되면 API 문서 역시 함께 수정되어야 하는데 아무래도 사람이 직접 하는 일이다 보니 깜빡하고 API 문서에 추가된 기능을 빠뜨릴 수도 있고, 클라이언트에게 제공된 API 정보와 수기로 작성한 API 문서의 정보가 다를 수도 있습니다.
실제로 클라이언트에게 제공한 API 문서와 백엔드 애플리케이션의 API 스펙 정보가 달라서 프론트엔드 쪽에서 API 문서를 기반으로 백엔드 애플리케이션 쪽에 요청을 전송했을 때 에러가 발생하는 경우가 빈번합니다.
API 문서 자동화를 통해서 여러분의 작업 시간을 단축하고, 애플리케이션의 완성도를 높여줄 필요가 있을 것 같습니다.
Spring Rest Docs vs Swagger
✔ Swagger의 API 문서화 방식
Java 기반의 애플리케이션에서는 전통적으로 Swagger라는 API 문서 자동화 오픈 소스를 많이 사용해 왔습니다.
Swagger는 어떤 방식으로 API 문서를 자동으로 생성해 주는지 간단하게 살펴보겠습니다.
@ApiOperation(value = "회원 정보 API", tags = {"Member Controller"}) // (1)
@RestController
@RequestMapping("/v11/swagger/members")
@Validated
@Slf4j
public class MemberControllerSwaggerExample {
private final MemberService memberService;
private final MemberMapper mapper;
public MemberControllerSwaggerExample(MemberService memberService, MemberMapper mapper) {
this.memberService = memberService;
this.mapper = mapper;
}
// (2)
@ApiOperation(value = "회원 정보 등록", notes = "회원 정보를 등록합니다.")
// (3)
@ApiResponses(value = {
@ApiResponse(code = 201, message = "회원 등록 완료"),
@ApiResponse(code = 404, message = "Member not found")
})
@PostMapping
public ResponseEntity postMember(@Valid @RequestBody MemberDto.Post memberDto) {
Member member = mapper.memberPostToMember(memberDto);
member.setStamp(new Stamp()); // homework solution 추가
Member createdMember = memberService.createMember(member);
return new ResponseEntity<>(
new SingleResponseDto<>(mapper.memberToMemberResponse(createdMember)),
HttpStatus.CREATED);
}
...
...
// (4)
@ApiOperation(value = "회원 정보 조회", notes = "회원 식별자(memberId)에 해당하는 회원을 조회합니다.")
@GetMapping("/{member-id}")
public ResponseEntity getMember(
@ApiParam(name = "member-id", value = "회원 식별자", example = "1") // (5)
@PathVariable("member-id") @Positive long memberId) {
Member member = memberService.findMember(memberId);
return new ResponseEntity<>(
new SingleResponseDto<>(mapper.memberToMemberResponse(member))
, HttpStatus.OK);
}
...
...
}
[코드 3-209] MemberController에 Swagger 적용
코드 3-209는 우리가 만든 MemberController의 API 정보를 문서화하기 위해서 Swagger를 적용한 코드 일부입니다.
보다시피 Swagger를 사용하면 (1) ~ (5)와 같이 API 문서를 만들기 위한 무수히 많은 애너테이션들이 애플리케이션 코드에 추가되어야 합니다.
코드 3-209와 같이 애플리케이션 기능을 구현하기 위한 코드에 API 문서를 생성하기 위한 애너테이션이 추가되는 것은 코드의 간결함을 추구하는 개발자라면 무언가 불편한 구조로 보일 가능성이 높습니다.
기능 구현과 전혀 상관이 없는 애너테이션이 대량으로 추가 되는 건 개발자 입장에서는 바람직하지 않다고 생각하는 개발자들이 꽤 있을 거라는 생각이 듭니다.
지금이야 학습을 위한 기능 구현이니까 코드 양이 많지 않지만 기능이 늘어나면 늘어날수록 API 문서를 위한 코드 역시 엄청나게 늘어납니다.(실제로 Swagger 관련 코드가 전체 코드의 반 이상을 차지하는 경우도 보았습니다.)
따라서 Swagger를 사용하면 API 엔드포인트를 위한 기능 구현 코드가 한눈에 잘 들어오지 않습니다.
그런데 Controller가 끝이 아니라 Request Body나 Response Body 같은 DTO 클래스에도 아래의 코드 3-210과 같이 Swagger의 애너테이션을 일일이 추가해 주어야 합니다.
@ApiModel("Member Post") // (1)
@Getter
public class MemberPostDto {
// (2)
@ApiModelProperty(notes = "회원 이메일", example = "hgd@gmail.com", required = true)
@NotBlank
@Email
private String email;
// (3)
@ApiModelProperty(notes = "회원 이름", example = "홍길동", required = true)
@NotBlank(message = "이름은 공백이 아니어야 합니다.")
private String name;
// (4)
@ApiModelProperty(notes = "회원 휴대폰 번호", example = "010-1111-1111", required = true)
@Pattern(regexp = "^010-\\\\d{3,4}-\\\\d{4}$",
message = "휴대폰 번호는 010으로 시작하는 11자리 숫자와 '-'로 구성되어야 합니다.")
private String phone;
}
[코드 3-210] MemberPostDto 클래스에 Swagger 적용
그런데 Swagger의 대표적인 장점을 얘기하지 않고 넘어가면 Swagger를 선호하는 분들에게 왠지 질타를 당할 것 같군요.
[그림 3-82] Swagger API 문서 화면 예
[그림 3-82]는 애플리케이션에 추가된 Swagger 애너테이션을 기반으로 만들어진 API 문서의 모습입니다.
[그림 3-82]에서 아래쪽에 [Execute]이라는 버튼이 보이는데, 우리가 Postman에서 MemberControlle에 HTTP 요청을 전송하듯이 [Execute] 버튼을 누르면 MemberController에 요청을 전송할 수 있습니다.
Postman처럼 API 요청 툴로써의 기능을 사용할 수 있다는 것이 Swagger의 대표적인 장점입니다.
✔ Spring Rest Docs의 API 문서화 방식
그럼 이제 이번 유닛에서 우리가 학습하게 될 Spring Rest Docs를 이용한 API 문서화 방식을 살펴보겠습니다.
우선 Spring Rest Docs와 Swagger의 가장 큰 차이점은 Spring Rest Docs의 경우 애플리케이션 기능 구현과 관련된 코드에는 API 문서 생성을 위한 애너테이션 같은 어떠한 정보도 추가되지 않는다는 것입니다.
대신에 여러분들이 [테스팅] 유닛에서 학습한 슬라이스 테스트를 위한 Controller의 테스트 클래스에 API 문서를 위한 정보가 추가됩니다.
package com.springboot.restdocs.member;
import com.springboot.member.controller.MemberController;
import com.springboot.member.dto.MemberDto;
import com.springboot.member.entity.Member;
import com.springboot.member.mapper.MemberMapper;
import com.springboot.member.service.MemberService;
import com.springboot.stamp.Stamp;
import com.google.gson.Gson;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.restdocs.payload.JsonFieldType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import java.util.List;
import static com.springboot.util.ApiDocumentUtils.getRequestPreProcessor;
import static com.springboot.util.ApiDocumentUtils.getResponsePreProcessor;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.startsWith;
import static org.mockito.BDDMockito.given;
import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;
import static org.springframework.restdocs.headers.HeaderDocumentation.responseHeaders;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post;
import static org.springframework.restdocs.payload.PayloadDocumentation.*;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.pathParameters;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(MemberController.class)
@MockBean(JpaMetamodelMappingContext.class)
@AutoConfigureRestDocs
public class MemberControllerRestDocsTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private MemberService memberService;
@MockBean
private MemberMapper mapper;
@Autowired
private Gson gson;
@Test
public void postMemberTest() throws Exception {
// given
MemberDto.Post post = new MemberDto.Post("hgd@gmail.com",
"홍길동",
"010-1234-5678");
String content = gson.toJson(post);
// willReturn()이 최소 null은 아니어야 한다.
given(mapper.memberPostToMember(Mockito.any(MemberDto.Post.class)))
.willReturn(new Member());
Member mockResultMember = new Member();
mockResultMember.setMemberId(1L);
given(memberService.createMember(Mockito.any(Member.class))).willReturn(mockResultMember);
// when
ResultActions actions =
mockMvc.perform(
post("/v11/members")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(content)
);
// then
actions
.andExpect(status().isCreated())
.andExpect(header().string("Location", is(startsWith("/v11/members/"))))
.andDo(document("post-member", // =========== (1) API 문서화 관련 코드 시작 ========
getRequestPreProcessor(),
getResponsePreProcessor(),
requestFields(
List.of(
fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"),
fieldWithPath("name").type(JsonFieldType.STRING).description("이름"),
fieldWithPath("phone").type(JsonFieldType.STRING).description("휴대폰 번호")
)
),
responseHeaders(
headerWithName(HttpHeaders.LOCATION).description("Location header. 등록된 리소스의 URI")
)
)); // =========== (2) API 문서화 관련 코드 끝========
}
}
[코드 3-211] MemberController의 postMember()를 테스트하는 테스트 코드에 API 문서 정보 추가
코드 3-211은 우리가 [테스팅] 유닛에서 학습했던 Controller의 테스트 코드에 Spring Rest Docs의 API 문서화 코드가 추가된 모습입니다.
코드가 상당히 길어 보이는데 겁먹지 않아도 됩니다.
여러분들이 [테스팅] 유닛에서 학습했던 Controller 테스트 코드에 API 문서화를 위한 테스트 코드가 조금 더 추가된 것뿐일 테니까요.
Spring Rest Docs를 사용한 API 문서화의 대표적인 장점은 테스트 케이스에서 전송하는 API 문서 정보와 Controller에서 구현한 Request Body, Response Body, Query Parmeter 등의 정보가 하나라도 일치하지 않으면 테스트 케이스의 실행 결과가 “failed” 되면서 API 문서가 정상적으로 생성이 되지 않는다는 것입니다.
즉, 여러분들이 테스트 케이스의 실행 결과를 “passed”로 만들지 않으면 API 문서 생성이 완료되지 않습니다.
이 말을 달리 표현하자면, 테스트 케이스의 실행 결과가 “passed”이면 Controller에 정의되어 있는 Request Body나 Response Body 등의 API 스펙 정보와 일치하는 API 문서가 만들어진다는 것입니다.
따라서 우리는 애플리케이션에 정의되어 있는 API 스펙 정보와 API 문서 정보의 불일치로 인해 발생하는 문제를 방지할 수 있습니다.
Swagger는 애너테이션 내에 API 스펙 정보를 문자열로 입력하는 경우가 많기 때문에 애플리케이션에 정의되어 있는 API 스펙 정보와 API 문서 내의 정보가 100퍼센트 일치한다는 보장을 할 수 없습니다.
Swagger처럼 API를 호출해볼 수 있는 툴의 역할은 하지 못하지만 API 문서 자체로써의 기능은 충분히 해준다고 볼 수 있습니다.
그리고 테스트에 성공해야지만 API 문서가 생성되는 부분은 정말 개발자 친화적인 특징이라고 말할 수 있습니다.
Spring Rest Docs의 대표적인 단점이라면 테스트 케이스를 일일이 작성해야 되고, Controller에 대한 모든 테스트 케이스를 “passed”로 만들어야 한다는 점입니다.
테스트 케이스를 작성하기 꺼려하는 개발자들에게 Spring Rest Docs는 아주 꺼려지는 기술일 거라는 생각이 드는군요.
마지막으로 여러분들이 Controller에 대한 테스트 케이스를 잘 작성해서 테스트 실행 결과가 “passed”이면 아래의 [그림 3-83] 같은 예의 API 문서가 만들어집니다.
Spring Rest Docs를 이용한 API 문서 예를 확인하면서 이번 챕터는 마치도록 하겠습니다.
[그림 3-83] Spring Rest Docs로 생성된 API 문서 예
핵심 포인트
- API 문서화란 클라이언트가 REST API 백엔드 애플리케이션에 요청을 전송하기 위해서 알아야 되는 요청 정보(요청 URL(또는 URI), request body, query parameter 등)를 문서로 잘 정리하는 것을 의미한다.
- API 사용을 위한 어떤 정보가 담겨 있는 문서를 API 문서 또는 API 스펙(사양, Specification)이라고 한다.
- API 문서 생성의 자동화가 필요한 이유
- API 문서를 수기로 직접 작성하는 것은 너무 비효율적이다.
- API 문서에 기능이 추가되거나 수정되면 API 문서 역시 함께 수정되어야 하는데, API 문서를 수기로 작성하면 API 문서에 추가된 기능을 빠뜨릴 수도 있고, 클라이언트에게 제공된 API 정보와 수기로 작성한 API 문서의 정보가 다를 수도 있다.
- Swagger의 API 문서화 방식
- 애터네이션 기반의 API 문서화 방식
- 애플리케이션 코드에 문서화를 위한 애너테이션들이 포함된다.
- 가독성 및 유지 보수성이 떨어진다.
- API 문서와 API 코드 간의 정보 불일치 문제가 발생할 수 있다.
- API 툴로써의 기능을 활용할 수 있다.
- Spring Rest Docs의 API 문서화 방식
- 테스트 코드 기반의 API 문서화 방식
- 애플리케이션 코드에 문서화를 위한 정보들이 포함되지 않는다.
- 테스트 케이스의 실행이 “passed”여야 API 문서가 생성된다.
- 테스트 케이스를 반드시 작성해야 된다.
- API 툴로써의 기능은 제공하지 않는다.
심화 학습
- Swagger에 대해서 더 알아보고 싶다면 아래 링크를 참고해 주세요.
- SpringDoc에 대해서 더 알아보고 싶다면 아래 링크를 참고해 주세요.
Chapter - Spring Rest Docs
이번 챕터에서는 Spring Rest Docs로 API 문서를 자동화하는 방법에 대해서 알아보도록 하겠습니다.
Spring Rest Docs로 API 문서를 자동화하기 위해서는 Controller에 대한 슬라이스 테스트가 함께 진행되어야 하며, 테스트 결과가 “passed”일 경우 정해진 절차에 따라서 API 문서가 생성됩니다.
따라서 이번 챕터에서는 Spring Rest Docs를 사용하기 위한 기본 설정, API 문서 생성, 생성된 API 문서를 웹 브라우저로 확인하는 방법까지 단계적으로 살펴보도록 하겠습니다.
학습 목표
- Spring Rest Docs가 무엇인지 이해할 수 있다.
- Spring Rest Docs를 위한 기본 설정 방법을 이해할 수 있다.
- Spring Rest Docs를 사용해서 API 문서를 생성할 수 있다.
- Spring Rest Docs를 통해 생성된 API 문서를 웹 브라우저에서 확인할 수 있다.
Spring Rest Docs란?
[API 문서화가 필요한 이유] 챕터에서 언급했다시피 Spring Rest Docs는 REST API 문서를 자동으로 생성해 주는 Spring 하위 프로젝트입니다.
Spring Rest Docs의 가장 큰 특징은 Controller의 슬라이스 테스트를 통해 테스트가 통과되어야지만 API 문서가 정상적으로 만들어진다는 것입니다.
이러한 특징으로 인해 Spring Rest Docs는 테스트를 중요하게 생각하는 개발자들에게 각광받는 기술 중 하나입니다.
이번 시간부터 우리가 만든 커피 주문 애플리케이션의 API 문서를 Spring Rest Docs를 통해 만들어 보도록 하겠습니다.
이번 유닛이 끝나면 여러분들이 만든 API 문서를 클라이언트 쪽에 자신 있게 제공할 수 있을 거라고 생각합니다.
Spring Rest Docs의 API 문서 생성 흐름
Spring Rest Docs를 이용해서 API 문서를 생성하기 위해서는 [그림 3-84]와 같이 Spring Rest Docs가 API 문서를 생성하는 흐름을 이해하고 있어야 합니다.
[그림 3-84] Spring Rest Docs의 API 문서 생성 흐름
- 테스트 코드 작성
- 슬라이스 테스트 코드 작성 ⅰ. Spring Rest Docs는 Controller의 슬라이스 테스트와 밀접한 관련이 있다고 했습니다. 여러분들이 학습한 Controller에 대한 슬라이스 테스트 코드를 먼저 작성합니다.
- API 스펙 정보 코드 작성 ⅰ. 슬라이스 테스트 코드 다음에 Controller에 정의되어 있는 API 스펙 정보(Request Body, Response Body, Query Parameter 등)를 코드로 작성합니다.
- test 태스크(task) 실행
- 작성된 슬라이스 테스트 코드를 실행합니다. ⅰ. 하나의 테스트 클래스를 실행시켜도 되지만 일반적으로 Gradle의 빌드 태스크(task)중 하나인 test task를 실행시켜서 API 문서 스니펫(snippet)을 일괄 생성합니다. (스니펫에 대해서는 아래에서 설명하겠습니다)
- 테스트 실행 결과가 “passed”이면 다음 작업을 진행하고, “failed”이면 문제를 해결하기 위해 테스트 케이스를 수정한 후, 다시 테스트를 진행해야 합니다.
- API 문서 스니펫( .adoc 파일) 생성
- 테스트 케이스의 테스트 실행 결과가 “passed”이면 테스트 코드에 포함된 API 스펙 정보 코드를 기반으로 API 문서 스니펫이 .adoc 확장자를 가진 파일로 생성됩니다.
스니펫(snippet)은 일반적으로 코드의 일부 조각을 의미하는 경우가 많은데 여기서는 문서의 일부 조각을 의미합니다.
스니펫은 테스트 케이스 하나당 하나의 스니펫이 생성되며, 여러 개의 스니펫을 모아서 하나의 API 문서를 생성할 수 있습니다.
- API 문서 생성
- 생성된 API 문서 스니펫을 모아서 하나의 API 문서로 생성합니다.
- API 문서를 HTML로 변환
- 생성된 API 문서를 HTML 파일로 변환합니다.
- HTML로 변환된 API 문서는 HTML 파일 자체를 공유할 수도 있고, URL을 통해 해당 HTML에 접속해서 확인할 수 있습니다.
Spring Rest Docs 설정
Spring Rest Docs가 API 문서 생성 작업을 정상적으로 수행할 수 있도록 기본적인 설정 작업을 먼저 해 주어야 합니다.
- build.gradle 설정
- API 문서 스니펫을 사용하기 위한 템플릿 API 문서 생성
✔ build.gradle 설정
plugins {
id 'org.springframework.boot' version '2.7.1'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id "org.asciidoctor.jvm.convert" version "3.3.2" // (1)
id 'java'
}
group = 'com.springboot'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
repositories {
mavenCentral()
}
// (2)
ext {
set('snippetsDir', file("build/generated-snippets"))
}
// (3)
configurations {
asciidoctorExtensions
}
dependencies {
// (4)
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
// (5)
asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation 'org.mapstruct:mapstruct:1.5.1.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.1.Final'
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'com.google.code.gson:gson'
}
// (6)
tasks.named('test') {
outputs.dir snippetsDir
useJUnitPlatform()
}
// (7)
tasks.named('asciidoctor') {
configurations "asciidoctorExtensions"
inputs.dir snippetsDir
dependsOn test
}
// (8)
task copyDocument(type: Copy) {
dependsOn asciidoctor // (8-1)
from file("${asciidoctor.outputDir}") // (8-2)
into file("src/main/resources/static/docs") // (8-3)
}
build {
dependsOn copyDocument // (9)
}
// (10)
bootJar {
dependsOn copyDocument // (10-1)
from ("${asciidoctor.outputDir}") { // (10-2)
into 'static/docs' // (10-3)
}
}
[코드 3-212] Spring Rest Docs 설정이 추가된 build.gradle 전체 코드
- (1)에서는 .adoc 파일 확장자를 가지는 AsciiDoc 문서를 생성해 주는 Asciidoctor를 사용하기 위한 플러그인을 추가합니다.
- (2)에서는 ext 변수의 set() 메서드를 이용해서 API 문서 스니펫이 생성될 경로를 지정합니다.
- (3)에서는 AsciiDoctor에서 사용되는 의존 그룹을 지정하고 있습니다. :asciidoctor task가 실행되면 내부적으로 (3)에서 지정한 ‘asciidoctorExtensions’라는 그룹을 지정합니다.
- (4)에서 'org.springframework.restdocs:spring-restdocs-mockmvc'를 추가함으로써 spring-restdocs-core와 spring-restdocs-mockmvc 의존 라이브러리가 추가됩니다.
- (5)에서 spring-restdocs-asciidoctor 의존 라이브러리를 추가합니다. (3)에서 지정한 asciidoctorExtensions 그룹에 의존 라이브러리가 포함이 됩니다.
- (6)에서는 :test task 실행 시, API 문서 생성 스니펫 디렉토리 경로를 설정합니다.
- (7)에서는 :asciidoctor task 실행 시, Asciidoctor 기능을 사용하기 위해 :asciidoctor task에 asciidoctorExtensions을 설정합니다.
- (8)은 :build task 실행 전에 실행되는 task입니다. :copyDocument task가 수행되면 index.html 파일이 src/main/resources/static/docs 에 copy 되며, copy 된 index.html 파일은 API 문서를 파일 형태로 외부에 제공하기 위한 용도로 사용할 수 있습니다.
- (8-1)에서는 :asciidoctor task가 실행된 후에 task가 실행되도록 의존성을 설정합니다.
- (8-2)에서는 "build/docs/asciidoc/" 경로에 생성되는 index.html을 copy한 후,
- (8-3)의 "src/main/resources/static/docs" 경로로 index.html을 추가해 줍니다.
- (9)에서는 :build task가 실행되기 전에 :copyDocument task가 먼저 수행되도록 합니다.
- (10)에서는 애플리케이션 실행 파일이 생성하는 :bootJar task 설정입니다.
- (10-1)에서는 :bootJar task 실행 전에 :copyDocument task가 실행되도록 의존성을 설정합니다.
- (10-2)와 (10-3)에서는 Asciidoctor 실행으로 생성되는 index.html 파일을 jar 파일 안에 추가해 줍니다. jar 파일에 index.html을 추가해 줌으로써 웹 브라우저에서 접속(http://localhost:8080/docs/index.html) 후, API 문서를 확인할 수 있습니다.
(8)에서 copy되는 index.html은 외부에 제공하기 위한 용도이고, (10)에서는 index.html을 애플리케이션 실행 파일인 jar 파일에 포함해서 웹 브라우저에서 API 문서를 확인하기 위한 용도라는 것을 기억하세요.
✔ API 문서 스니펫을 사용하기 위한 템플릿(또는 source 파일) 생성
build.gradle에 API 문서 생성을 위한 설정이 완료되었습니다.
마지막으로 할 일은 API 문서 스니펫이 생성되었을 때 이 스니펫을 사용해서 최종 API 문서로 만들어 주는 템플릿 문서(index.adoc)를 생성하는 것입니다.
- Gradle 기반 프로젝트에서는 아래 경로에 해당하는 디렉토리를 생성해 주어야 합니다.
- src/docs/asciidoc/
- 다음으로 src/docs/asciidoc/ 디렉토리 내에 비어있는 템플릿 문서(index.adoc)를 생성해 주면 됩니다. (뒤에서 한번 더 언급합니다)
드디어 Spring Rest Docs를 사용하기 위한 사전 준비는 끝났습니다
이제 Controller를 테스트할 테스트 케이스를 작성하고, 해당 Controller에 대한 API 스펙 정보를 테스트 케이스에 추가해 주면 API 문서 스니펫을 생성할 수 있습니다.
API 문서화를 위한 테스트 케이스 작성 방법은 다음 챕터에서 이어집니다.
핵심 포인트
- Spring Rest Docs의 API 문서 생성 흐름은 다음과 같습니다.
- 슬라이스 테스트 코드 작성 →
- API 스펙 정보 코드 작성 →
- test 태스크 실행 →
- API 문서 스니펫 생성
- 스니펫을 포함한 API 문서 생성
- .adoc 파일의 API 문서를 HTML로 변환
- Spring Rest Docs를 사용해서 API 문서를 생성하기 위해서는 .adoc 문서 스니펫을 생성해 주는 Asciidoctor가 필요하다.
- HTML 파일로 변환된 API 문서는 외부에 제공할 수도 있고, 웹브라우저에 접속해서 API 문서를 확인할 수도 있다.
심화 학습
- Gradle에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
- ext 변수에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
Controller 테스트 케이스에 Spring RestDocs 적용하기
API 문서 생성을 위한 사전 준비 작업은 끝났습니다.
이제 Controller에 대한 테스트 케이스를 작성하고, API 문서화를 위한 API 스펙 정보를 테스트 케이스에 추가해 봅시다.
API 문서 생성을 위한 슬라이스 테스트 케이스 작성
✔ API 문서 생성을 위한 테스트 케이스 기본 구조
package com.springboot.restdocs.member;
import com.springboot.member.controller.MemberController;
import com.springboot.member.mapper.MemberMapper;
import com.springboot.member.service.MemberService;
import com.google.gson.Gson;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
@WebMvcTest(MemberController.class) // (1)
@MockBean(JpaMetamodelMappingContext.class) // (2)
@AutoConfigureRestDocs // (3)
public class MemberControllerRestDocsTest {
@Autowired
private MockMvc mockMvc; // (4)
@MockBean
// (5) 테스트 대상 Controller 클래스가 의존하는 객체를 Mock Bean 객체로 주입받기
@Test
public void postMemberTest() throws Exception {
// given
// (6) 테스트 데이터
// (7) Mock 객체를 이용한 Stubbing
// when
ResultActions actions =
mockMvc.perform(
// (8) request 전송
);
// then
actions
.andExpect(// (9) response에 대한 기대 값 검증)
.andDo(document(
// (10) API 문서 스펙 정보 추가
));
}
}
[코드 3-213] API 문서 생성을 위한 테스트 케이스 기본 구조
코드 3-213은 Spring Rest Docs를 이용해 API 문서를 생성하기 위한 테스트 케이스의 기본 구조입니다.
- (1)에서는 @SpringBootTest 애너테이션을 사용하지 않고, @WebMvcTest 애너테이션을 사용했습니다. @WebMvcTest 애너테이션은 Controller를 테스트하기 위한 전용 애너테이션입니다. @WebMvcTest 애너테이션의 괄호 안에는 테스트 대상 Controller 클래스를 지정합니다.
- (2)는 JPA에서 사용하는 Bean 들을 Mock 객체로 주입해 주는 설정입니다. Spring Boot 기반의 테스트는 항상 최상위 패키지 경로에 있는 xxxxxxxApplication 클래스를 찾아서 실행합니다.
@EnableJpaAuditing
@SpringBootApplication
public class Section3Week3RestDocsApplication {
public static void main(String[] args) {
SpringApplication.run(Section3Week3RestDocsApplication.class, args);
}
}
@EnableJpaAuditing 애너테이션이 추가되어 있는 것 보이나요?
이처럼 @EnableJpaAuditing을 xxxxxxApplication 클래스에 추가하게 되면 JPA와 관련된 Bean을 필요로 하기 때문에 @WebMvcTest 애너테이션을 사용해서 테스트를 진행할 경우에는 코드 3-213의 (2)와 같이 JpaMetamodelMappingContext를 Mock 객체로 주입해 주어야 합니다.
- (3)에서는 Spring Rest Docs에 대한 자동 구성을 위해 @AutoConfigureRestDocs를 추가해 줍니다.
- (4)에서 MockMvc 객체를 주입받습니다.
- (5)에서는 Controller 클래스가 의존하는 객체(주로 서비스 클래스, Mapper)의 의존성을 제거하기 위해 @MockBean 애너테이션을 사용해서 Mock 객체를 주입받습니다.
- (6)에서는 HTTP request에 필요한 request body나 query parmeter, path variable 등의 데이터를 추가합니다.
- (7)에서는 (5)에서 주입받은 Mock 객체가 동작하도록 Mockito에서 지원하는 given() 등의 메서드로 Stubbing 해 줍니다.
- (8)에서는 MockMvc의 perform() 메서드로 request를 전송합니다. MockMvc의 perform() 메서드는 슬라이스 테스트에서 여러분이 사용했던 방법과 동일합니다.
- (9)에서는 response를 검증합니다. ResultActions의 .andExpect() 역시 슬라이스 테스트에서 사용했던 방법과 동일하게 검증을 진행하면 됩니다.
- 마지막으로 (10)에서 테스트 수행 이후, API 문서를 자동 생성하기 위한 해당 Controller 핸들러 메서드의 API 스펙 정보를 document(…)에 추가해 줍니다..andDo(…) 메서드는 andExpect()처럼 어떤 검증 작업을 하는 것이 아니라 일반적인 동작을 정의하고자 할 때 사용됩니다.
- document(…) 메서드는 API 문서를 생성하기 위해 Spring Rest Docs에서 지원하는 메서드입니다.
@SpringBootTest vs @WebMvcTest
우리가 [테스팅] 유닛에서는 @SpringBootTest + @AutoConfigureMockMvc 애너테이션으로 Controller의 테스트를 진행했었습니다.
@SpringBootTest와 @WebMvcTest의 차이점은 무엇일까요? 먼저 @SpringBootTest 애너테이션은 @AutoConfigureMockMvc과 함께 사용되어 Controller를 테스트할 수 있는데, 프로젝트에서 사용하는 전체 Bean을 ApplicationContext에 등록하여 사용합니다. 한마디로 테스트 환경을 구성하는 것은 편리하긴 한데 실행 속도가 상대적으로 느립니다.
@WebMvcTest 애너테이션의 경우 Controller 테스트에 필요한 Bean만 ApplicationContext에 등록하기 때문에 실행 속도는 상대적으로 빠릅니다.
다만, Controller에서 의존하고 있는 객체가 있다면 해당 객체에 대해서 Mock 객체를 사용하여 의존성을 일일이 제거해 주어야 합니다.
결과적으로 @SpringBootTest는 데이터베이스까지 요청 프로세스가 이어지는 통합 테스트에 주로 사용되고, @WebMvcTest는 Controller를 위한 슬라이스 테스트에 주로 사용합니다.
처음부터 슬라이스 테스트에 @WebMvcTest 애너테이션을 사용하면 되는데 왜 @SpringBootTest 애너테이션을 사용했냐고 의아해 할 수도 있습니다.
여러분들이 여러 가지 방식을 한꺼번에 학습하게 되면 머릿속이 더 혼란스러워질 거 같아서였습니다.
단계적인 기술 습득을 위해서라고 생각하고, Controller를 슬라이스 테스팅 하기 위해서는 지금부터 @WebMvcTest를 사용하면 되겠습니다.
Spring Rest Docs를 이용해 API 문서를 생성하기 위한 대략적인 기본 구조는 파악했으니, 현재 우리가 구현한 커피 주문 애플리케이션의 MemberController를 테스트하기 위한 테스트 케이스에 API 문서 생성을 위한 API 스펙 정보를 추가해 봅시다.
API 문서 생성을 위한 API 스펙 정보 추가
MemberController 테스트 케이스에 API 스펙 정보 추가
✔ MemberController의 postMember() 핸들러 메서드에 대한 API 스펙 정보 추가
package com.springboot.restdocs.member;
import com.springboot.member.controller.MemberController;
import com.springboot.member.dto.MemberDto;
import com.springboot.member.entity.Member;
import com.springboot.member.mapper.MemberMapper;
import com.springboot.member.service.MemberService;
import com.springboot.stamp.Stamp;
import com.google.gson.Gson;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.restdocs.payload.JsonFieldType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import java.util.List;
import static com.springboot.util.ApiDocumentUtils.getRequestPreProcessor;
import static com.springboot.util.ApiDocumentUtils.getResponsePreProcessor;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.startsWith;
import static org.mockito.BDDMockito.given;
import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;
import static org.springframework.restdocs.headers.HeaderDocumentation.responseHeaders;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post;
import static org.springframework.restdocs.payload.PayloadDocumentation.*;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.pathParameters;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(MemberController.class)
@MockBean(JpaMetamodelMappingContext.class)
@AutoConfigureRestDocs
public class MemberControllerRestDocsTest {
@Autowired
private MockMvc mockMvc;
// (1)
@MockBean
private MemberService memberService;
// (2)
@MockBean
private MemberMapper mapper;
@Autowired
private Gson gson;
@Test
public void postMemberTest() throws Exception {
// (3) given
MemberDto.Post post = new MemberDto.Post("hgd@gmail.com", "홍길동", "010-1234-5678");
String content = gson.toJson(post);
// (4)
given(mapper.memberPostToMember(Mockito.any(MemberDto.Post.class))).willReturn(new Member());
// (5)
Member mockResultMember = new Member();
mockResultMember.setMemberId(1L);
given(memberService.createMember(Mockito.any(Member.class))).willReturn(mockResultMember);
// (6) when
ResultActions actions =
mockMvc.perform(
post("/v11/members")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(content)
);
// then
actions
.andExpect(status().isCreated())
.andExpect(header().string("Location", is(startsWith("/v11/members/"))))
.andDo(document( // (7)
"post-member", // (7-1)
getRequestPreProcessor(), // (7-2)
getResponsePreProcessor(), // (7-3)
requestFields( // (7-4)
List.of(
fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"), // (7-5)
fieldWithPath("name").type(JsonFieldType.STRING).description("이름"),
fieldWithPath("phone").type(JsonFieldType.STRING).description("휴대폰 번호")
)
),
responseHeaders( // (7-6)
headerWithName(HttpHeaders.LOCATION).description("Location header. 등록된 리소스의 URI")
)
));
}
}
[코드 3-214] MemberController 클래스의 postMember() 핸들러 메서드에 대한 API 스펙 정보 추가
코드 3-214는 MemberController 클래스의 postMember() 핸들러 메서드에 대한 API 스펙 정보를 추가하기 위한 테스트 케이스입니다.
코드 길이가 꽤 길지만 앞에서 살펴본 ‘API 문서 생성을 위한 테스트 케이스의 기본 구조’에 구체적인 로직들이 조금 추가되었습니다.
- MemberController 클래스의 코드를 확인해 보면 MemberService 클래스와 MemberMapper를 핸들러 메서드 안에서 사용하고 있습니다.또한 MemberService 객체를 통해 createMember() 메서드를 호출함으로써 실제 비즈니스 로직을 수행하고 데이터 액세스 계층의 코드까지 호출할 것입니다.따라서 MemberController가 MemberService와 MemberMapper의 메서드를 호출하지 않도록 관계를 단절시킬 필요가 있습니다.두 Mock 객체는 테스트 케이스에서 가짜 메서드를 호출하는 데 사용됩니다(Stubbing).
- MemberController가 의존하는 객체와의 관계를 단절하기 위해 (1)과 (2)에서 MemberService와 MemberMapper의 Mock Bean을 주입받습니다.
- 우리에게 필요한 핵심 관심사는 MemberController가 요청을 잘 전달받고, 응답을 잘 전송하며 요청과 응답이 정상적으로 수행되면 API 문서 스펙 정보를 잘 읽어 들여서 적절한 문서를 잘 생성하느냐 하는 것입니다.
- 즉, 코드 3-214의 테스트 케이스가 MemberController의 postMember() 핸들러 메서드에 요청을 전송하면 MemberMapper를 이용해 MemberDto.Post 객체와 Member 객체 간의 실제 매핑 작업을 진행합니다.
- (3)은 postMember() 핸들러 메서드에 전송하는 request body입니다.
- (4), (5)는 여러분들이 Mockito 챕터에서 학습했던 내용입니다. MemberController의 postMember()에서 의존하는 객체의 메서드 호출을 (1)과 (2)에서 주입받은 Mock 객체를 사용해서 Stubbing하고 있습니다.
- (6)은 Controller 슬라이스 테스트 챕터에서 학습했던 내용입니다. MockMvc의 perform() 메서드로 POST 요청을 전송하고 있습니다.
많이 돌아왔지만 (7)의 document(…) 메서드가 API 문서를 생성하기 위해서 알아야 될 내용입니다.
- (7)의 document(…) 메서드는 API 스펙 정보를 전달받아서 실질적인 문서화 작업을 수행하는 RestDocumentationResultHandler 클래스에서 가장 핵심 기능을 하는 메서드입니다.
- document() 메서드의 첫 번째 파라미터인 (7-1)은 API 문서 스니펫의 식별자 역할을 하며, (7-1)에서 “post-member”로 지정했기 때문에 문서 스니펫은 post-member 디렉토리 하위에 생성됩니다.
- (7-2)와 (7-3)은 문서 스니펫을 생성하기 전에 request와 response에 해당하는 문서 영역을 전처리하는 역할을 하는데 [코드 3-215]와 같이 공통화한 후, 모든 테스트 케이스에서 재사용할 수 있도록 했습니다.
package com.springboot.util;
import org.springframework.restdocs.operation.preprocess.OperationRequestPreprocessor;
import org.springframework.restdocs.operation.preprocess.OperationResponsePreprocessor;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
public interface ApiDocumentUtils {
static OperationRequestPreprocessor getRequestPreProcessor() {
return preprocessRequest(prettyPrint());
}
static OperationResponsePreprocessor getResponsePreProcessor() {
return preprocessResponse(prettyPrint());
}
}
[코드 3-215] ApiDocumentUtils 인터페이스
- preprocessRequest(prettyPrint())는 문서에 표시되는 JSON 포맷의 request body를 예쁘게 표현해 줍니다.
- preprocessResponse(prettyPrint())는 문서에 표시되는 JSON 포맷의 response body를 예쁘게 표현해 줍니다.
c. (7-4)의 requestFields(…)는 문서로 표현될 request body를 의미하며, 파라미터로 전달되는 List<FieldDescriptor>의 원소인 FieldDescriptor 객체가 request body에 포함된 데이터를 표현합니다.
d. (7-5)는 request body를 JSON 포맷으로 표현했을 때, 하나의 프로퍼티를 의미하는 FieldDescriptor입니다. type(JsonFieldType.STRING)은 JSON 프로퍼티의 값이 문자열임을 의미합니다.
e. (7-6)의 responseHeaders(…)는 문서로 표현될 response header를 의미하며, 파라미터로 전달되는 HeaderDescriptor 객체가 response header를 표현합니다.
- HttpHeaders.LOCATION : HTTP response의 Location header를 의미합니다.
드디어 MemberController의 postMember() 핸들러 메서드에 대한 API 스펙 정보가 테스트 케이스에 포함되었습니다.
이제 테스트 케이스를 실행하고, 실행 결과가 “passed”이면 우리가 작성한 API 스펙 정보를 기반으로 문서 스니펫이 만들어질 것입니다.
[그림 3-85] 테스트 케이스 실행 후, 생성된 문서 스니펫
테스트 케이스 실행 후, [그림 3-85]와 같이 문서 스니펫이 생성되었습니다.
[그림 3-86] http-request.adoc 문서 스니펫 내용 및 문서로 렌더링 된 모습
[그림 3-86]은 생성된 문서 스니펫 중에서 http-request.adoc 파일을 오픈한 모습입니다.
왼쪽은 http-request.adoc에 작성된 내용이며, 오른쪽은 Asciidoc 형태로 작성된 내용이 문서로 렌더링 된 모습입니다.
Asciidoc에 대해서는 뒤에서 학습하게 됩니다. ✔ MemberController의 patchMember() 핸들러 메서드에 대한 API 스펙 정보 추가
이번에는 MemberController의 patchMember() 핸들러 메서드에 대한 API 스펙 정보를 테스트 케이스에 추가해 보겠습니다.
package com.springboot.restdocs.member;
import com.springboot.member.controller.MemberController;
import com.springboot.member.dto.MemberDto;
import com.springboot.member.entity.Member;
import com.springboot.member.mapper.MemberMapper;
import com.springboot.member.service.MemberService;
import com.springboot.stamp.Stamp;
import com.google.gson.Gson;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext;
import org.springframework.http.MediaType;
import org.springframework.restdocs.payload.JsonFieldType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import java.util.List;
import static com.springboot.util.ApiDocumentUtils.getRequestPreProcessor;
import static com.springboot.util.ApiDocumentUtils.getResponsePreProcessor;
import static org.mockito.BDDMockito.given;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post;
import static org.springframework.restdocs.payload.PayloadDocumentation.*;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.pathParameters;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(MemberController.class)
@MockBean(JpaMetamodelMappingContext.class)
@AutoConfigureRestDocs
public class MemberControllerRestDocsTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private MemberService memberService;
@MockBean
private MemberMapper mapper;
@Autowired
private Gson gson;
...
...
@Test
public void patchMemberTest() throws Exception {
// given
long memberId = 1L;
MemberDto.Patch patch = new MemberDto.Patch(memberId, "홍길동", "010-1111-1111", Member.MemberStatus.MEMBER_ACTIVE);
String content = gson.toJson(patch);
MemberDto.Response responseDto =
new MemberDto.Response(1L,
"hgd@gmail.com",
"홍길동",
"010-1111-1111",
Member.MemberStatus.MEMBER_ACTIVE,
new Stamp());
// willReturn()이 최소한 null은 아니어야 한다.
given(mapper.memberPatchToMember(Mockito.any(MemberDto.Patch.class))).willReturn(new Member());
given(memberService.updateMember(Mockito.any(Member.class))).willReturn(new Member());
given(mapper.memberToMemberResponse(Mockito.any(Member.class))).willReturn(responseDto);
// when
ResultActions actions =
mockMvc.perform(
patch("/v11/members/{member-id}", memberId)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(content)
);
// then
actions
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.memberId").value(patch.getMemberId()))
.andExpect(jsonPath("$.data.name").value(patch.getName()))
.andExpect(jsonPath("$.data.phone").value(patch.getPhone()))
.andExpect(jsonPath("$.data.memberStatus").value(patch.getMemberStatus().getStatus()))
.andDo(document("patch-member",
getRequestPreProcessor(),
getResponsePreProcessor(),
pathParameters( // (1)
parameterWithName("member-id").description("회원 식별자")
),
requestFields(
List.of(
fieldWithPath("memberId").type(JsonFieldType.NUMBER).description("회원 식별자").ignored(), // (2)
fieldWithPath("name").type(JsonFieldType.STRING).description("이름").optional(), // (3)
fieldWithPath("phone").type(JsonFieldType.STRING).description("휴대폰 번호").optional(),
fieldWithPath("memberStatus").type(JsonFieldType.STRING).description("회원 상태: MEMBER_ACTIVE / MEMBER_SLEEP / MEMBER_QUIT").optional()
)
),
responseFields( // (4)
List.of(
fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 데이터"),
fieldWithPath("data.memberId").type(JsonFieldType.NUMBER).description("회원 식별자"), // (5)
fieldWithPath("data.email").type(JsonFieldType.STRING).description("이메일"),
fieldWithPath("data.name").type(JsonFieldType.STRING).description("이름"),
fieldWithPath("data.phone").type(JsonFieldType.STRING).description("휴대폰 번호"),
fieldWithPath("data.memberStatus").type(JsonFieldType.STRING).description("회원 상태: 활동중 / 휴면 상태 / 탈퇴 상태"),
fieldWithPath("data.stamp").type(JsonFieldType.NUMBER).description("스탬프 갯수")
)
)
));
}
}
[코드 3-216] MemberController 클래스의 patchMember() 핸들러 메서드에 대한 API 스펙 정보 추가
코드 3-216에서는 MemberController 클래스의 patchMember() 핸들러 메서드에 대한 API 스펙 정보를 추가했습니다.
postMember() 핸들러 메서드에 대한 테스트 케이스와 크게 달라진 건 없지만 몇 가지 추가된 부분이 있습니다.
- (1)에서는 API 스펙 정보 중에서 URL의 path variable의 정보를 추가했습니다. MemberController의 patchMember()와 getMember()는 “/v11/members/{member-id}”와 같은 요청 URL에 path variable이 있는 사실은 우리가 이미 잘 알고 있는 내용입니다.
- memberId의 경우, path variable 정보로 memberId를 전달받기 때문에 MemberDto.Patch DTO 클래스에서 request body에 매핑되지 않는 정보입니다.
따라서 (2)와 같이 ignored()를 추가해서 API 스펙 정보에서 제외했습니다.
- 회원 정보는 모든 정보를 다 수정해야만 하는 것이 아니라 선택적으로 수정할 수 있어야 합니다. 즉, 회원 이름, 휴대폰 번호, 회원 상태 중에서 수정하고 싶은 것만 선택적으로 수정할 수 있어야 하기 때문에 (3)과 같이 optional()을 추가해서 API 스펙 정보에서 필수가 아닌 선택 정보로 설정합니다.
- (4)의 responseFields(…)는 문서로 표현될 response body를 의미하며, 파라미터로 전달되는 List<FieldDescriptor>의 원소인 FieldDescriptor 객체가 response body에 포함된 데이터를 표현합니다.
- JsonFieldType.OBJECT : JSON 포맷으로 표현된 프로퍼티의 값이 객체임을 의미합니다.
- JsonFieldType.NUMBER : JSON 포맷으로 표현된 프로퍼티의 값이 int나 long 같은 Number 임을 의미합니다.
-
- (5)에서 fieldWithPath("data.memberId")의 data.memberId 는 data 프로퍼티의 하위 프로퍼티를 의미합니다.
{
"data": {
"memberId": 1, // data.memberId
"email": "hgd@gmail.com",
"name": "홍길동1",
"phone": "010-1111-1111",
"memberStatus": "활동중",
"stamp": 0
}
}
지금까지 Controller의 테스트 케이스에 API 스펙 정보를 추가해서 API 문서 스니펫을 생성해 보았습니다.
이어지는 챕터에서는 우리가 생성한 API 문서 스니펫을 하나로 모아서 실제로 외부에 공개할 수 있는 API 문서를 만들어 보겠습니다.
MemberController의 getMember(), getMembers(), deleteMember() 핸들러 메서드의 API 문서 스니펫 생성은 실습 과제로 남겨두겠습니다.
여러분들이 Spring Rest Docs를 처음 접하기 때문에 분명 과제를 진행하면서 많은 에러를 만날 가능성이 상당히 높다고 생각합니다.
하지만 에러를 만나더라도 좌절하지 않았으면 좋겠습니다. 여러분들이 만나게 될 에러는 Spring Rest Docs에 대한 기본 개념을 이해하고, API 스펙 정보를 하나씩 추가하면서 충분히 극복할 수 있다고 믿습니다.
Spring Rest Docs가 어렵다고 ‘Swagger를 배울 걸 그랬나’라고 생각하는 분이 있다면 잘못된 생각입니다.
Swagger는 Swagger 나름대로 만나게 되는 문제들이 꽤 있습니다.
결국 개발자의 길로 들어선다는 것은 수많은 에러를 만나게 되고, 에러를 해결하면서 에러와 함께 걸어가는 운명이라는 사실을 꼭 기억하면서 마지막까지 포기하지 않길 바랍니다.
핵심 포인트
- @SpringBootTest는 데이터베이스까지 요청 프로세스가 이어지는 통합 테스트에 주로 사용되고, @WebMvcTest는 Controller를 위한 슬라이스 테스트에 주로 사용한다.
- document(…) 메서드는 API 스펙 정보를 전달받아서 실질적인 문서화 작업을 수행하는 RestDocumentationResultHandler 클래스에서 가장 핵심 기능을 하는 메서드이다.
- OperationRequestPreprocessor와 OperationResponsePreprocessor를 이용해 API 문서를 생성 전에 전처리를 수행할 수 있다.
- requestFields(…)는 문서로 표현될 request body를 의미하며, 파라미터로 전달되는 List<FieldDescriptor>의 원소인 FieldDescriptor 객체가 request body에 포함되는 데이터를 표현한다.
- responseFields(…)는 문서로 표현될 response body를 의미하며, 파라미터로 전달되는 List<FieldDescriptor>의 원소인 FieldDescriptor 객체가 response body에 포함된 데이터를 표현한다.
심화 학습
- Spring Rest Docs에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
- API 문서화 작업을 수행하는 RestDocumentationResultHandler의 document(…)에 사용할 수 있는 파라미터에 대해 더 알아보고 싶다면 아래 링크를 참고하세요.
- JSON Field Types의 종류를 더 알아보고 싶다면 아래 링크를 참고하세요.
스니펫을 이용한 API 문서화
이 전 챕터에서는 Controller의 테스트를 위한 테스트 케이스에 API 스펙 정보를 추가해서 API 문서 스니펫을 생성해 보았습니다.
이제 생성된 스니펫을 이용해서 실제로 외부에 제공할 수 있는 API 문서를 만들어 보도록 하겠습니다.
API 문서 템플릿 생성을 위한 디렉토리 및 템플릿 문서 생성
우리가 생성한 API 문서 스니펫은 문서 일부에 포함되는 조각 모음입니다.
이 조각 모음을 제대로 된 문서로 만들기 위해서는 스니펫을 포함하는 템플릿 문서가 필요합니다.
[그림 3-87] API 템플릿 문서 생성
만약 [Spring Rest Docs란?] 챕터에서 설명한 대로 "index.adoc"파일을 아직 생성하지 않았다면, [그림 3-87]과 같이 “src/docs/asciidoc” 디렉토리를 생성하고 비어 있는 “index.adoc” 파일을 생성합니다.
Gradle 프로젝트의 경우, 템플릿 문서가 위치하는 디폴트 경로가 “src/docs/asciidoc”라는 사실을 기억하기 바랍니다.
이제 index.adoc 템플릿 문서에 API 문서를 위한 기본적인 내용을 추가해 봅시다.
템플릿 문서 내용 추가
= 커피 주문 애플리케이션 // (1)
:sectnums:
:toc: left
:toclevels: 4
:toc-title: Table of Contents
:source-highlighter: prettify
v1.0.0, 2022.04.08 // (3)
// (4)
***
== MemberController
=== 회원 등록
.curl-request
include::{snippets}/post-member/curl-request.adoc[]
.http-request
include::{snippets}/post-member/http-request.adoc[]
.request-fields
include::{snippets}/post-member/request-fields.adoc[]
.http-response
include::{snippets}/post-member/http-response.adoc[]
.response-headers
include::{snippets}/post-member/response-headers.adoc[]
=== 회원 정보 수정
.curl-request
include::{snippets}/patch-member/curl-request.adoc[]
.http-request
include::{snippets}/patch-member/http-request.adoc[]
.path-parameters
include::{snippets}/patch-member/path-parameters.adoc[]
.request-fields
include::{snippets}/patch-member/request-fields.adoc[]
.http-response
include::{snippets}/patch-member/http-response.adoc[]
.response-fields
include::{snippets}/patch-member/response-fields.adoc[]
[코드 3-217] index.adoc 템플릿 문서
코드 3-217은 Asciidoc 문법으로 작성된 템플릿 문서입니다.
- (1)은 API 문서의 제목입니다.
- (1)과 (3) 사이에 있는 항목은 API 문서의 목차와 관련된 내용입니다. 자세한 내용은 Asciidoc에 대한 챕터에서 살펴보겠습니다.
- (3)은 API 문서의 생성 날짜입니다.
- (4)부터 우리가 테스트 케이스 실행을 통해 생성한 API 문서 스니펫을 사용하는 부분입니다. 템플릿 문서에서 스니펫을 사용하는 방법은 정해져 있습니다. ’include::{snippets}/스니펫 문서가 위치한 디렉토리/스니펫 문서파일명.adoc[]’
템플릿 문서에 포함되지 않은 스니펫이 있는데 필요하다면 추가해서 사용할 수 있습니다.
여러분이 직접 추가한 후, 확인해 보세요.
기본적인 템플릿 문서의 작성은 끝났습니다.
이제 Gradle의 :build 또는 :bootJar task 명령을 실행해서 index.adoc 파일을 index.html 파일로 변환하면 됩니다.
템플릿 문서를 HTML 파일로 변환
[그림 3-88] Gradle task 명령 실행
IntelliJ 우측 상단의 [Gradle] 탭을 클릭한 후, [그림 3-88]과 같이 :bootJar또는 :build task 명령을 더블 클릭합니다.
[그림 3-89] 변환된 index.html 파일 생성
:bootJar 또는 :build task 명령이 실행되고 정상적으로 빌드가 종료되면 [그림 3-89]와 같이 ‘src/main/resources/static/docs’ 디렉토리에 index.adoc 파일을 이용해 변환된 index.html 파일이 생성됩니다.
이제 마지막입니다.
IntelliJ에서 애플리케이션을 실행하고 아래 URL을 웹 브라우저에 입력합니다.
- http://localhost:8080/docs/index.html
[그림 3-90]과 같은 API 문서가 화면에 보인다면 여러분은 이제 본격적으로 Spring Rest Docs를 이용해서 API 문서를 생성할 준비가 된 것입니다.
[그림 3-90] 웹 브라우저에서 확인한 Spring Rest Docs 기반 API 문서
핵심 포인트
- Controller 테스트를 위한 테스트 케이스 실행으로 생성된 API 문서 스니펫은 템플릿 문서에 포함해서 사용할 수 있다.
- 애플리케이션 빌드를 통해 템플릿 문서를 HTML 파일로 변환할 수 있다.
- 변환된 HTML 파일을 ‘src/main/resources/static/docs/’ 디렉토리에 위치시키면 웹 브라우저로 API 문서를 확인할 수 있다.
심화 학습
- 템플릿 문서와 문서 스니펫을 이용해 API 문서를 생성하는 방법을 더 알아보고 싶다면 아래 링크를 클릭하세요.
Spring Rest Docs에서의 Asciidoc
Asciidoc이란?
Asciidoc이란 무엇일까요?
Asciidoc은 Spring Rest Docs를 통해 생성되는 텍스트 기반 문서 포맷입니다.
Asciidoc 포맷을 사용해서 메모, 문서, 기사, 서적, E-Book, 웹 페이지, 매뉴얼 페이지, 블로그 게시물 등을 작성할 수 있으며 Asciidoc 포맷으로 작성된 문서는 HTML, PDF, EPUB, 매뉴얼 페이지를 포함한 다양한 형식으로 변환될 수 있습니다.
또한 Asciidoc은 주로 기술 문서 작성을 위해 설계된 가벼운 마크업 언어이기도 합니다.
Spring Rest Docs를 통해 만들어지는 문서 스니펫과 이 문서 스니펫을 사용하는 템플릿 문서는 Asciidoc 포맷의 문서로 이루어져 있기 때문에 우리가 제공하는 API 문서를 사용하는 이들이 직관적으로 API 문서를 이해할 수 있는 수준 정도의 Asciidoc 기본 문법은 알고 있는 것이 좋습니다.
우리가 생성한 API 문서가 조금 더 세련되고, 가독성 좋은 문서로 만들어질 수 있도록 가벼운 마음으로 Asciidoc의 문법을 간단하게 살펴보겠습니다.
✔ 목차 구성
= 커피 주문 애플리케이션 // (1)
:sectnums: // (2)
:toc: left // (3)
:toclevels: 4 // (4)
:toc-title: Table of Contents // (5)
:source-highlighter: prettify // (6)
v1.0.0, 2022.07.10
[adoc-1] 목차 구성
[adoc-1]은 Asciidoc 문법으로 목차를 구성하는 방법입니다.
(1) 문서의 제목을 작성하기 위해서는 =를 추가하면 됩니다. ====와 같이 =의 개수가 늘어날수록 글자는 작아집니다.
(2) 목차에서 각 섹션에 넘버링을 해주기 위해서는 :sectnums: 를 추가하면 됩니다.
(3) :toc: 는 목차를 문서의 어느 위치에 구성할 것인지를 설정합니다. 여기서는 문서의 왼쪽에 목차가 표시되도록 left를 지정했습니다.
(4) :toclevels: 은 목차에 표시할 제목의 level을 지정합니다. 여기서는 4로 지정했기 때문에 ==== 까지의 제목만 목차에 표시됩니다.
(5) :toc-title: 은 목차의 제목을 지정할 수 있습니다.
(6) :source-highlighter: 문서에 표시되는 소스 코드 하일라이터를 지정합니다. 여기서는 prettify를 지정했습니다.
[adoc-1]에서 작성된 문서는 아래의 [그림 3-91]과 같이 화면에 표시됩니다.
[그림 3-91] 목차 표시 예
✔ 박스 문단 사용하기
*** // (1)
API 문서 개요
// (2)
이 문서는 Spring MVC 기반의 REST API 기반 애플리케이션에 대해 직접 학습하며 만들어 가는 샘플 애플리케이션입니다.
샘플 애플리케이션을 사용해보고자 하는 분들은 이 문서를 통해 API의 구체적인 사용법을 알 수 있습니다.
***
[adoc-2] 박스 문단 구성
[adoc-2]와 같이 API 문서에 박스 문단을 구성해서 API 문서에 대한 설명을 추가할 수 있습니다.
(1) * 는 단락을 구분 지을 수 있는 수평선을 추가해 줍니다.
(2) 문단의 제목 다음에 한 라인을 띄우고 한 칸 들여 쓰기의 문단을 작성하면 박스 문단을 사용할 수 있습니다.
[adoc-2]에서 작성된 문서는 아래의 [그림 3-92]와 같이 화면에 표시됩니다.
[그림 3-92] 박스 문단 화면 예
✔ 경고 문구 추가
***
API 문서 개요
이 문서는 Spring MVC 기반의 REST API 기반 애플리케이션에 대해 직접 학습하며 만들어 가는 샘플 애플리케이션입니다.
샘플 애플리케이션을 사용해보고자 하는 분들은 이 문서를 통해 API의 구체적인 사용법을 알 수 있습니다.
// (1)
CAUTION: 이 문서는 학습용으로 일부 기능에 제한이 있습니다. 기능 제한 사항에 대해 알고 싶다면 담당자에게 문의 하세요
***
[adoc-3] 경고 문구 추가 예
(1) CAUTION: 을 사용해서 경고 문구를 추가할 수 있습니다. 이 외에 NOTE: , TIP: , IMPORTANT: , WARNING: 등을 사용할 수 있습니다.
화면에 어떻게 표시되는지는 여러분이 한번 문서에 추가해 보세요.
[adoc-3]에서 작성된 경고 문구는 아래의 [그림 3-93]과 같이 화면에 표시됩니다.
[그림 3-93] 경고 문구 화면 예
✔ URL Scheme 자동 인식
다음과 같은 URL Scheme는 Asciidoc에서 자동으로 인식하여 링크가 설정됩니다.
- http
- https
- ftp
- irc
- mailto
- hgd@gmail.com
✔ 이미지 추가
API 문서에 이미지를 추가하고 싶다면 아래의 [adoc-4]와 같이 image:: 를 사용해서 추가할 수 있습니다.
image::<https://spring.io/images/spring-logo-9146a4d3298760c2e7e49595184e1975.svg[spring]>
Asciidoctor란?
Asciidoctor는 AsciiDoc 포맷의 문서를 파싱 해서 HTML 5, 매뉴얼 페이지, PDF 및 EPUB 3 등의 문서를 생성하는 툴입니다.
Spring Rest Docs에서는 Asciidoc 포맷의 문서를 HTML 파일로 변환하기 위해 내부적으로 Asciidoctor를 사용하고 있습니다.
우리가 Asciidoctor의 구체적인 사용법을 알 필요는 없지만 Spring Rest Docs를 통해 생성되는 문서 스니펫을 템플릿 문서에 포함해서 하나의 API 문서로 통합하는 방법 정도는 알고 있는 게 좋습니다.
✔ 문서 스니펫을 템플릿 문서에 포함시키기
이 전 챕터에서 테스트 케이스 실행을 통해 생성된 문서 스니펫(snippet)을 템플릿 문서(index.adoc)에 포함(include) 시켰습니다.
이렇게 템플릿 문서에 포함된 스니펫은 애플리케이션 빌드 타임에 내부적으로 Asciidoctor가 index.adoc을 index.html로 변환 후, 특정 디렉토리(src/main/resources/static/docs)에 생성해 줍니다.
지금부터 Asciidoctor가 템플릿 문서 변환 작업을 수행하기 이 전 단계인 ‘템플릿 문서에 스니펫을 포함하는 방법’을 간단히 살펴보겠습니다.
***
== MemberController
=== 회원 등록
.curl-request // (1)
include::{snippets}/post-member/http-request.adoc[] // (2)
.request-fields
include::{snippets}/post-member/request-fields.adoc[]
.http-response
include::{snippets}/post-member/http-response.adoc[]
.response-fields
include::{snippets}/post-member/response-fields.adoc[]
...
...
[adoc-4] 템플릿(index.adoc) 문서에 스니펫 포함 예
[adoc-4]는 테스트 케이스 실행을 통해 생성된 스니펫을 포함하는 템플릿 문서의 일부입니다.
- (1)의 .curl-request 에서 .은 하나의 스니펫 섹션 제목을 표현하기 위해 사용합니다. curl-request 은 섹션의 제목이며, 원하는 대로 수정하면 됩니다.
- (2)에서 include는 Asciidoctor에서 사용하는 매크로(macro) 중 하나이며, 스니펫을 템플릿 문서에 포함할 때 사용합니다. :: 은 매크로를 사용하기 위한 표기법입니다. {snippets}는 해당 스니펫이 생성되는 디폴트 경로를 의미하며, 우리가 아래의 \[adoc-5] build.gradle 파일에 설정한 snippetsDir 변수를 참조하는 데 사용할 수 있습니다.
...
...
ext {
set('snippetsDir', file("build/generated-snippets"))
}
...
...
[adoc-5] build.gradle 파일
Asciidoctor에서는 어떤 작업을 처리하기 위한 용어로 매크로(macro)라는 용어를 사용합니다.
매크로(macro)는 일반적으로 어떤 반복되는 작업을 자동화한다는 의미를 가지며, 우리가 흔히 알고 있는 매크로에는 엑셀 등의 스프레드시트에서 사용할 수 있는 매크로 기능이 있습니다.
핵심 포인트
- Asciidoc은 Spring Rest Docs를 통해 생성되는 텍스트 기반 문서 포맷이다.
- Asciidoc은 주로 기술 문서 작성을 위해 설계된 가벼운 마크업 언어이기도 하다.
- Asciidoc을 이용해서 조금 더 세련되고, 가독성 좋은 API 문서를 만들 수 있다.
- Asciidoctor는 AsciiDoc 포맷의 문서를 파싱 해서 HTML 5, 매뉴얼 페이지, PDF 및 EPUB 3 등의 문서를 생성하는 툴이다.
심화 학습
- Asciidoc에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
- Asciidoc의 목차(Table Of Contents) 구성 방법에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.