-
✅ 관련 PR
-
✅ 목표
-
1. 유저기능: 회원가입 웹 계층 구현 + rest docs 이용해서 api 문서 만들어보기
-
2. 공통기능: 예외(에러) 상황에 대한 공통 응답 만들기
-
✅ 회원가입 웹 계층 요구사항 분석
-
✅ 웹 계층 생성
-
✅ 예외 상황에 대한 공통 응답 만들기
-
✅ Spring Rest Docs를 통한 API 문서 자동화 및 테스트
-
Spring-restdocs 공식문서 참조 및 블로그 참조를 통해 build.gradle 만들기
-
빌드 과정
-
RestAssuredMockMvc 사용 - 왜 이걸 사용할까?
-
RestAssuredMockMvc 사용 - 테스트 환경 세팅
-
RestAssuredMockMvc 사용 - 유저 회원가입 테스트 작성
-
Spring Rest Docs 만들기
-
Spring Rest Docs을 통한 API 문서 생성 완료
-
❗️ 트러블 슈팅
-
유저 회원가입시 휴대폰 번호 null 입력 오류
✅ 관련 PR
(오름차순)
feat: 유저 로그인 웹계층 개발 및 api 명세 작성 #7
fix: 유저 로그인시 휴대폰 번호 null 입력 오류 수정 #9
docs: 잘못된 아이디 형식으로 회원가입시 오류 - api 명세 추가 #10
✅ 목표
1. 유저기능: 회원가입 웹 계층 구현 + rest docs 이용해서 api 문서 만들어보기
2. 공통기능: 예외(에러) 상황에 대한 공통 응답 만들기
✅ 회원가입 웹 계층 요구사항 분석
웹 계층
- 기능 사항
- 예외 발생에 대한 공통적인 응답 처리를 해주어야 한다.
- 회원가입은 201 Created로 응답한다.
- API docs
- rest docs를 통한 테스트 및, API문서를 생성한다.
✅ 웹 계층 생성
@RestController
@RequestMapping("/api/v1")
public class MemberController {
private final MemberService memberService;
public MemberController(final MemberService memberService) {
this.memberService = memberService;
}
@PostMapping("/members")
public ResponseEntity<Void> signUp(@Valid @RequestBody final SignUpRequest signUpRequest) {
memberService.signUp(signUpRequest);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
}
사실 회원가입에 대한 응답은 HttpStatus로 마무리되므로 웹 계층은 간단하다.
✅ 예외 상황에 대한 공통 응답 만들기
@Getter
@RestControllerAdvice
public class ControllerAdvice {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(final BindingResult bindingResult) {
String message = bindingResult.getFieldErrors()
.get(0)
.getDefaultMessage();
return ResponseEntity.badRequest().body(new ErrorResponse(message));
}
@ExceptionHandler(BadRequestException.class)
public ResponseEntity<ErrorResponse> handleBadRequestException(final BadRequestException e) {
return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage()));
}
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorResponse> handleRuntimeException(RuntimeException e) {
log.error(e.getMessage(), e);
return ResponseEntity.internalServerError().body(new ErrorResponse("서버에서 알 수 없는 오류가 발생했습니다."));
}
}
@Getter
public class ErrorResponse {
private String message;
public ErrorResponse(final String message) {
this.message = message;
}
}
ExceptionHandler를 통해 예외에 대한 공통 응답처리를 해주었다.
비즈니스 로직에서 발생하는 모든 예외는 BusinessException
을 상속받고 있으며, 거기서도 BadRequestException
, NotFoundException
(추가예정)과 같은 대표적인 4xx
대 코드들이 비즈니스 예외를 상속받고 있다.
이후 각 도메인의 비즈니스 로직에서 발생하는 예외들이 HttpStatus 예외들을 상속받아 모든 예외들을 계층적으로 관리한다.
추가적으로 회원가입시 SignUpRequest의 경우 @RequestBody를 통해 바인딩 될 때 검증하는 부분들이 존재한다.
따라서 @Valid를 통해 1차적으로 검증하고 있으며, 검증이 실패하게 되면MethodArgumentNotValidException
이 발생하므로 해당 예외에 대한 공통 응답 처리도 해주어야 한다.
이때 파라미터로 받는 BindingResult
인스턴스에서 첫번째 필드 에러 메시지를 꺼내면 아래와 같이 우리가 커스텀하게 입력한 에러 메시지를 가져오게 된다.
✅ Spring Rest Docs를 통한 API 문서 자동화 및 테스트
공식문서
Spring REST Docs
Document RESTful services by combining hand-written documentation with auto-generated snippets produced with Spring MVC Test.
docs.spring.io
참고 게시글들
[속닥속닥] 🚀 우당탕탕 Spring REST Docs 적용기
🥺 RestAssured + MockMvc ?!
velog.io
Spring Rest Docs 적용 | 우아한형제들 기술블로그
{{item.name}} 안녕하세요? 우아한형제들에서 정산시스템을 개발하고 있는 이호진입니다. 지금부터 정산시스템 API 문서를 wiki 에서 Spring Rest Docs 로 전환한 이야기를 해보려고 합니다. 1. 전환하는
techblog.woowahan.com
Spring REST Docs 적용 및 최적화 하기
해당 포스팅의 코드는 Github{:target="\_blank"} 를 참고해주세요. 테스트 코드 기반으로 Restful API 문서를 돕는 도구입니다.Asciidoctor를 이용해서 HTML 등등 다양한 포맷으로 문서를 자동으로 출력할 수
velog.io
Spring-restdocs 공식문서 참조 및 블로그 참조를 통해 build.gradle 만들기
build.gradle
plugins {
// (1)
id 'org.asciidoctor.jvm.convert' version "3.3.2"
}
dependencies {
// (2)
asciidoctor 'org.springframework.restdocs:spring-restdocs-asciidoctor'
// (3)
testCompile 'org.springframework.restdocs:spring-restdocs-mockmvc'
}
// (4)
ext {
snippetsDir = file('build/generated-snippets')
}
// (5)
test {
outputs.dir snippetsDir
}
asciidoctor { // (6)
inputs.dir snippetsDir // (7)
dependsOn test // (8)
}
- (1) asciidoctor 플러그인을 적용한다. 단, gradle 7버전 이상부터는 org.asciidoctor.jvm.converter를 이용한다.
- (2) asciidoctor: build/generated-snippets에 생성된 adoc 조각들을 프로젝트 내 .adoc 파일에서 읽어들이도록 연동한다.
- 해당 의존성은
operation::
이라는 명령어를 지원하는데 이는 특정 작업에 대해 생성된 전체 혹은 일부 스니펫을 가져올 수 있다.
https://docs.spring.io/spring-restdocs/docs/2.0.4.RELEASE/reference/html5/#working-with-asciidoctor-including-snippets-operation - (3) MockMvc를 사용하는 spring-restdocs-mockmvc 의존성 추가 (REST Assured를 사용하려면 spring-restdocs-restassured에 종속성을 추가해야 한다.)
- MockMvc vs RestAssured
- MockMvc VS RestAssured
- (4) 생성된 snipperts의 출력 위치를 정의하는 속성
- (5) snippetDir을 출력으로 추가하도록 테스트 작업을 구성한다.
- (6) asciidoctor 작업 구성
- (7) asciidoctor 작업 구성에서 입력받을 경로를 snippetsDir로 설정한다.
- (8) asciidoctor 문서 작정 전에 테스트가 실행되도록하여 테스트 작업에 문서 생성 작업이 종속되도록 한다.
빌드 과정
속닥속닥 기술 블로그 글을 참조해 완성한 gradle은 다음과 같다.
plugins {
id 'java'
id 'org.springframework.boot' version '2.7.10'
id 'io.spring.dependency-management' version '1.0.15.RELEASE'
id 'org.asciidoctor.jvm.convert' version "3.3.2"
}
group = 'com.project'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
configurations {
asciidoctorExtensions
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
ext {
set('snippetsDir', file("build/generated-snippets"))
}
dependencies {
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'
// (1) asciidoctor: build/generated-snippets에 생성된 adoc 조각들을 프로젝트 내 .adoc 파일에서 읽어들이도록 연동한다.
asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'com.mysql:mysql-connector-j'
testImplementation 'io.rest-assured:rest-assured:4.4.0'
testImplementation 'io.rest-assured:spring-mock-mvc:4.4.0'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
}
// (1) 테스트 실행
test {
outputs.dir snippetsDir // (2) 테스트 결과 디렉토리 지정
useJUnitPlatform()
}
asciidoctor {
// 위에서 작성한 설정 적용
configurations 'asciidoctorExtensions'
// (4) src/docs/asciidoc/index.adoc을 통해 build/docs/asciidoc/에 index.html 생성
// 특정 .adoc 파일만 html로 생성(sources가 없다면 모든 파일을 html로 만든다.)
sources {
include("**/index.adoc", "**/common/*.adoc")
}
// 특정 .adoc에 다른 adoc 파일을 가져와서(include), 사용하고 싶을 경우 경로를 baseDir로 맞춰준다.
// (개별 adoc으로 운영한다면 필요 없는 옵션)
baseDirFollowsSourceDir()
// snippetDir를 입력으로 구성
inputs.dir snippetsDir
// asciidoctor 전 test 실행
dependsOn test
}
// (3) 이전 static/index.html 비우기
// 테스트를 실행하게되면 기존 static/docs/ 비우기
asciidoctor.doFirst {
delete file('src/man/resources/static/docs')
}
// (5) 생성된 HTML 파일을 build/에서 src/main/resources/static/으로 이동
// asciidoctor 작업 이후 생성된 HTML파일을 static/docs/로 카피
task createDocument(type: Copy) {
dependsOn asciidoctor
from file("build/docs/asciidoc")
into file("src/main/resources/static")
}
// build 전 createDocument(REST Docs 문서화) 실행
build {
dependsOn createDocument
}
- 테스트 실행
- 테스트 결과를
build/generated-snippets
에 저장 - 이전
static/index.html
비우기 src/docs/asciidoc/index.adoc
을 통해build/docs/asciidoc/
에index.html
생성- 생성된
HTML
파일을build/
에서src/main/resources/static/
으로 이동한다.
RestAssuredMockMvc 사용 - 왜 이걸 사용할까?
Controller layer를 테스트 하는 방법은 통합적으로 테스트하는 방식인 Rest Assured와
Controller layer만 독립적으로 테스트하는 MockMvc 방식이 있다.
1의 경우 BDD 기반의 테스트를 통해 가독성이 좋은 테스트를 작성할 수 있지만, 별도의 의존성 추가가 필요하며 테스트시 @SpringBootTest를 사용하므로 Spring Bean을 전부 로드해야 한다.
2의 경우 테스트의 가독성이 1보다 떨어지지만 별도의 구성 없이도 @WebMvcTest로 테스트를 수행할 수 있다. 또한 컨트롤러 레이어만 격리하여 테스트를 하므로 상대적으로 속도가 빠르다.
RestAssuredMockMvc는 Rest Assured 기반의 테스트이지만 @WebMvcTest로 테스트를 수행할 수 있다.
Gradle을 이용한다면 다음과 같은 의존성을 추가해야 한다.
io.rest-assured:spring-mock-mvc:4.4.0
RestAssuredMockMvc 사용 - 테스트 환경 세팅
Spring Rest Docs 공식문서를 살펴보면 @ExtendWith(RestDocumentationExtension.class)
를 달아주어야 한다. 따라서 다음과 같이 테스트 환경을 구성할 수 있다.
@WebMvcTest
@ExtendWith(RestDocumentationExtension.class)
class MemberControllerTest {
@MockBean
MemberService memberService;
MockMvcRequestSpecification restDocs;
@BeforeEach
void setUp(WebApplicationContext webApplicationContext, RestDocumentationContextProvider restDocumentation) {
restDocs = RestAssuredMockMvc.given()
.mockMvc(MockMvcBuilders.webAppContextSetup(webApplicationContext)
.apply(documentationConfiguration(restDocumentation)
.operationPreprocessors()
.withRequestDefaults(prettyPrint())
.withResponseDefaults(prettyPrint()))
.build())
.log().all();
}
@DisplayName("회원가입을 하면 201 반환")
@Test
void signUp() {
}
}
@BeforeEach를 통한 setUp()메소드 구성시, @WebMvcTest 애노테이션을 붙이게 되면
JUnit5의 ParameterResolver 구현체를 통해 파라미터를 주입받을 수 있다.
(@WebMvcTest를 주석처리하고 테스트를 돌렸을때 예외 메시지를 통해서도 알 수 있다.)JUnit 공식문서:https://junit.org/junit5/docs/5.2.0/api/org/junit/jupiter/api/BeforeEach.html
RestAssuredMockMvc 사용 - 유저 회원가입 테스트 작성
@DisplayName("회원가입을 하면 201 반환")
@Test
void signUp() {
SignUpRequest signUpRequest = SignUpRequest.builder()
.loginId("test1234")
.username("이호석")
.nickname("키다리")
.password("!Asdf1234")
.passwordConfirmation("!Asdf1234")
.email("test1234@test.com")
.phone("010-0000-0000")
.build();
restDocs
.contentType(MediaType.APPLICATION_JSON_VALUE)
.body(signUpRequest) //(1)
.when().post("/api/v1/members") //(2)
.then().log().all() //(3)
.assertThat() //(4)
.apply(document("member/signup/success")) //(5)
.statusCode(HttpStatus.CREATED.value());
}
- Content-Type은 application/json, 요청 바디에 회원가입 정보를 넘겨준다.
- 컨트롤러에 매핑된 회원가입 URI에 POST 요청을 전송한다.
- 요청, 응답에 대한 결과를 로깅함
- 검증시작
build/generated-snippets/
하위에 document로 지정한 경로에 생성된 snippet(.adoc 파일)들이 저장된다.
Spring Rest Docs 만들기
src/docs/asciidoc/index.adoc
을 만든다
이후 adoc 문법을 통해 api 명세를 작성한다.
= MY Little Blog API 명세 // 제목
:doctype: book
:icons: font
:source-highlighter: highlightjs // 코드들의 하이라이팅을 highlightjs를 사용
:toc: left // Table Of Contents(목차)를 문서의 좌측에 두기
:toclevels: 2 // 목차 레벨 설정
:sectlinks:
:sectnums: // 분류별 자동으로 숫자를 달아줌
:docinfo: shared-head
== 회원가입
=== 회원가입
==== 성공
operation::member/signup/success[snippets='http-request,http-response']
asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor'
의존성을 추가했기 때문에 operation 명령어를 사용할 수 있다.
[, ] 사이에는 가져올 snippets을 지정하는데, 응답과 요청 스니펫만 불러온다.
Spring Rest Docs을 통한 API 문서 생성 완료
❗️ 트러블 슈팅
유저 회원가입시 휴대폰 번호 null 입력 오류
관련 PR
fix: 유저 회원가입시 휴대폰 번호 null 입력 오류 수정 by HiiWee · Pull Request #9 · MyToy-Project/my-little-b
✅ 관련 이슈 #5 ✅ 문제 상황 postman으로 회원가입 테스트 후 MySQL workbench로 조회했을때 phone 컬럼이 null로 조회됨 해결 dto -> entity로 변경 되는 service코드에서 phone 필드가 누락되어 있었음 ✅ 배운
github.com
api 테스트 까지 마치고 postman을 통해 실제 유저에 대한 회원가입을 다음과 같이 요청했다.

이후 실제 삽입된 DB를 조회했는데 phone 컬럼에 null값이 입력되는것을 확인했다.

사실 문제는 정말 간단했다. 서비스 레이어에서 dto -> entity로 변환할때 Phone 필드가 누락되어 있었고 이로인해 참조형 필드의 기본값인 null이 entity에 들어가고 그대로 DB에 저장을 했다.
사실 트러블 슈팅이라고 하기에도 민망하다고 생각되지만, 저 문제를 발견했을 당시 전체 코드 라인 테스트 커버리지가 86이었고 서비스는 100프로를 커버하고 있었지만, 테스트 코드를 통해 해당 오류를 발견할 수 없었다.
이는 내가 작성한 테스트 코드에 문제점이 있다는걸 의미했다.
기존 테스트 코드
기존 테스트 코드는 다음과 같다. 단순히 아이디, 비밀번호 필드만 검증하여 객체의 실제 DB 저장 사실 여부만 확인한다.
모든 값들이 정상적으로 등록되었는지 판단하기에는 빈약하다.
@DisplayName("회원가입 조건을 만족하면 회원가입을 성공한다.")
@Test
void signUp() {
memberService.signUp(signUpRequest);
assertThat(
memberRepository.findByLoginIdAndPasswordValue("test123", encryptor.encrypt("!Abc123123"))).isPresent();
}
개선한 회원가입 테스트
회원가입은 당연히 통과해야 하며, 모든 필드에 대한 실제 값이 제대로 저장되었는지 검증함으로써 유저 데이터가 정상적으로 생성되고 저장 됐는지 확인한다.
@DisplayName("회원가입 조건을 만족하면 회원가입을 성공한다.")
@Test
void signUp() {
memberService.signUp(signUpRequest);
Member findMember = memberRepository.findByLoginIdValueAndPasswordValue("test123", encryptor.encrypt("!Abc123123")).get();
Assertions.assertAll(
() -> assertThat(findMember.getLoginId().getValue()).isEqualTo("test123"),
() -> assertThat(findMember.getUsername().getValue()).isEqualTo(encryptor.encrypt("이호석")),
() -> assertThat(findMember.getNickname()).isEqualTo("키다리"),
() -> assertThat(findMember.getPassword().getValue()).isEqualTo(encryptor.encrypt("!Abc123123")),
() -> assertThat(findMember.getEmail()).isEqualTo("asdf@test.com"),
() -> assertThat(findMember.getPhone()).isEqualTo("010-0000-0000")
);
}
단순히 테스트 커버리지만 높이는 것이 좋은 테스트 코드가 아님을 배웠다.
테스트의 주 목적은 내가 작성한 로직들이 기대한대로 동작하고 정확한 데이터를 만드는가를 검증하는 것이 1순위다.
내가 작성했던 회원가입 로직은 그런 기대를 벗어났다.
만약 postman이 아닌 실제 서비스에 대한 배포였고, 이 사실을 배포한지 한참 뒤에 알게되어 사용자들의 데이터가 모두 null로 저장되었다면, 정말 많은 손해를 보게 된다... 사람은 결국 실수를 항상 만들어 내고, 테스트 코드는 그런 실수를 검증하는데 필수 요소이지 않을까?
이런 문제를 지금 겪은것이 아주 다행이다라는 생각이 듭니다. 아주 적은 리소스로 매우 중요한 것을 배웠다..!
✅ 관련 PR
(오름차순)
feat: 유저 로그인 웹계층 개발 및 api 명세 작성 #7
fix: 유저 로그인시 휴대폰 번호 null 입력 오류 수정 #9
docs: 잘못된 아이디 형식으로 회원가입시 오류 - api 명세 추가 #10
✅ 목표
1. 유저기능: 회원가입 웹 계층 구현 + rest docs 이용해서 api 문서 만들어보기
2. 공통기능: 예외(에러) 상황에 대한 공통 응답 만들기
✅ 회원가입 웹 계층 요구사항 분석
웹 계층
- 기능 사항
- 예외 발생에 대한 공통적인 응답 처리를 해주어야 한다.
- 회원가입은 201 Created로 응답한다.
- API docs
- rest docs를 통한 테스트 및, API문서를 생성한다.
✅ 웹 계층 생성
@RestController
@RequestMapping("/api/v1")
public class MemberController {
private final MemberService memberService;
public MemberController(final MemberService memberService) {
this.memberService = memberService;
}
@PostMapping("/members")
public ResponseEntity<Void> signUp(@Valid @RequestBody final SignUpRequest signUpRequest) {
memberService.signUp(signUpRequest);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
}
사실 회원가입에 대한 응답은 HttpStatus로 마무리되므로 웹 계층은 간단하다.
✅ 예외 상황에 대한 공통 응답 만들기
@Getter
@RestControllerAdvice
public class ControllerAdvice {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(final BindingResult bindingResult) {
String message = bindingResult.getFieldErrors()
.get(0)
.getDefaultMessage();
return ResponseEntity.badRequest().body(new ErrorResponse(message));
}
@ExceptionHandler(BadRequestException.class)
public ResponseEntity<ErrorResponse> handleBadRequestException(final BadRequestException e) {
return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage()));
}
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorResponse> handleRuntimeException(RuntimeException e) {
log.error(e.getMessage(), e);
return ResponseEntity.internalServerError().body(new ErrorResponse("서버에서 알 수 없는 오류가 발생했습니다."));
}
}
@Getter
public class ErrorResponse {
private String message;
public ErrorResponse(final String message) {
this.message = message;
}
}
ExceptionHandler를 통해 예외에 대한 공통 응답처리를 해주었다.
비즈니스 로직에서 발생하는 모든 예외는 BusinessException
을 상속받고 있으며, 거기서도 BadRequestException
, NotFoundException
(추가예정)과 같은 대표적인 4xx
대 코드들이 비즈니스 예외를 상속받고 있다.
이후 각 도메인의 비즈니스 로직에서 발생하는 예외들이 HttpStatus 예외들을 상속받아 모든 예외들을 계층적으로 관리한다.
추가적으로 회원가입시 SignUpRequest의 경우 @RequestBody를 통해 바인딩 될 때 검증하는 부분들이 존재한다.
따라서 @Valid를 통해 1차적으로 검증하고 있으며, 검증이 실패하게 되면MethodArgumentNotValidException
이 발생하므로 해당 예외에 대한 공통 응답 처리도 해주어야 한다.
이때 파라미터로 받는 BindingResult
인스턴스에서 첫번째 필드 에러 메시지를 꺼내면 아래와 같이 우리가 커스텀하게 입력한 에러 메시지를 가져오게 된다.
✅ Spring Rest Docs를 통한 API 문서 자동화 및 테스트
공식문서
Spring REST Docs
Document RESTful services by combining hand-written documentation with auto-generated snippets produced with Spring MVC Test.
docs.spring.io
참고 게시글들
[속닥속닥] 🚀 우당탕탕 Spring REST Docs 적용기
🥺 RestAssured + MockMvc ?!
velog.io
Spring Rest Docs 적용 | 우아한형제들 기술블로그
{{item.name}} 안녕하세요? 우아한형제들에서 정산시스템을 개발하고 있는 이호진입니다. 지금부터 정산시스템 API 문서를 wiki 에서 Spring Rest Docs 로 전환한 이야기를 해보려고 합니다. 1. 전환하는
techblog.woowahan.com
Spring REST Docs 적용 및 최적화 하기
해당 포스팅의 코드는 Github{:target="\_blank"} 를 참고해주세요. 테스트 코드 기반으로 Restful API 문서를 돕는 도구입니다.Asciidoctor를 이용해서 HTML 등등 다양한 포맷으로 문서를 자동으로 출력할 수
velog.io
Spring-restdocs 공식문서 참조 및 블로그 참조를 통해 build.gradle 만들기
build.gradle
plugins {
// (1)
id 'org.asciidoctor.jvm.convert' version "3.3.2"
}
dependencies {
// (2)
asciidoctor 'org.springframework.restdocs:spring-restdocs-asciidoctor'
// (3)
testCompile 'org.springframework.restdocs:spring-restdocs-mockmvc'
}
// (4)
ext {
snippetsDir = file('build/generated-snippets')
}
// (5)
test {
outputs.dir snippetsDir
}
asciidoctor { // (6)
inputs.dir snippetsDir // (7)
dependsOn test // (8)
}
- (1) asciidoctor 플러그인을 적용한다. 단, gradle 7버전 이상부터는 org.asciidoctor.jvm.converter를 이용한다.
- (2) asciidoctor: build/generated-snippets에 생성된 adoc 조각들을 프로젝트 내 .adoc 파일에서 읽어들이도록 연동한다.
- 해당 의존성은
operation::
이라는 명령어를 지원하는데 이는 특정 작업에 대해 생성된 전체 혹은 일부 스니펫을 가져올 수 있다.
https://docs.spring.io/spring-restdocs/docs/2.0.4.RELEASE/reference/html5/#working-with-asciidoctor-including-snippets-operation - (3) MockMvc를 사용하는 spring-restdocs-mockmvc 의존성 추가 (REST Assured를 사용하려면 spring-restdocs-restassured에 종속성을 추가해야 한다.)
- MockMvc vs RestAssured
- MockMvc VS RestAssured
- (4) 생성된 snipperts의 출력 위치를 정의하는 속성
- (5) snippetDir을 출력으로 추가하도록 테스트 작업을 구성한다.
- (6) asciidoctor 작업 구성
- (7) asciidoctor 작업 구성에서 입력받을 경로를 snippetsDir로 설정한다.
- (8) asciidoctor 문서 작정 전에 테스트가 실행되도록하여 테스트 작업에 문서 생성 작업이 종속되도록 한다.
빌드 과정
속닥속닥 기술 블로그 글을 참조해 완성한 gradle은 다음과 같다.
plugins {
id 'java'
id 'org.springframework.boot' version '2.7.10'
id 'io.spring.dependency-management' version '1.0.15.RELEASE'
id 'org.asciidoctor.jvm.convert' version "3.3.2"
}
group = 'com.project'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
configurations {
asciidoctorExtensions
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
ext {
set('snippetsDir', file("build/generated-snippets"))
}
dependencies {
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'
// (1) asciidoctor: build/generated-snippets에 생성된 adoc 조각들을 프로젝트 내 .adoc 파일에서 읽어들이도록 연동한다.
asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'com.mysql:mysql-connector-j'
testImplementation 'io.rest-assured:rest-assured:4.4.0'
testImplementation 'io.rest-assured:spring-mock-mvc:4.4.0'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
}
// (1) 테스트 실행
test {
outputs.dir snippetsDir // (2) 테스트 결과 디렉토리 지정
useJUnitPlatform()
}
asciidoctor {
// 위에서 작성한 설정 적용
configurations 'asciidoctorExtensions'
// (4) src/docs/asciidoc/index.adoc을 통해 build/docs/asciidoc/에 index.html 생성
// 특정 .adoc 파일만 html로 생성(sources가 없다면 모든 파일을 html로 만든다.)
sources {
include("**/index.adoc", "**/common/*.adoc")
}
// 특정 .adoc에 다른 adoc 파일을 가져와서(include), 사용하고 싶을 경우 경로를 baseDir로 맞춰준다.
// (개별 adoc으로 운영한다면 필요 없는 옵션)
baseDirFollowsSourceDir()
// snippetDir를 입력으로 구성
inputs.dir snippetsDir
// asciidoctor 전 test 실행
dependsOn test
}
// (3) 이전 static/index.html 비우기
// 테스트를 실행하게되면 기존 static/docs/ 비우기
asciidoctor.doFirst {
delete file('src/man/resources/static/docs')
}
// (5) 생성된 HTML 파일을 build/에서 src/main/resources/static/으로 이동
// asciidoctor 작업 이후 생성된 HTML파일을 static/docs/로 카피
task createDocument(type: Copy) {
dependsOn asciidoctor
from file("build/docs/asciidoc")
into file("src/main/resources/static")
}
// build 전 createDocument(REST Docs 문서화) 실행
build {
dependsOn createDocument
}
- 테스트 실행
- 테스트 결과를
build/generated-snippets
에 저장 - 이전
static/index.html
비우기 src/docs/asciidoc/index.adoc
을 통해build/docs/asciidoc/
에index.html
생성- 생성된
HTML
파일을build/
에서src/main/resources/static/
으로 이동한다.
RestAssuredMockMvc 사용 - 왜 이걸 사용할까?
Controller layer를 테스트 하는 방법은 통합적으로 테스트하는 방식인 Rest Assured와
Controller layer만 독립적으로 테스트하는 MockMvc 방식이 있다.
1의 경우 BDD 기반의 테스트를 통해 가독성이 좋은 테스트를 작성할 수 있지만, 별도의 의존성 추가가 필요하며 테스트시 @SpringBootTest를 사용하므로 Spring Bean을 전부 로드해야 한다.
2의 경우 테스트의 가독성이 1보다 떨어지지만 별도의 구성 없이도 @WebMvcTest로 테스트를 수행할 수 있다. 또한 컨트롤러 레이어만 격리하여 테스트를 하므로 상대적으로 속도가 빠르다.
RestAssuredMockMvc는 Rest Assured 기반의 테스트이지만 @WebMvcTest로 테스트를 수행할 수 있다.
Gradle을 이용한다면 다음과 같은 의존성을 추가해야 한다.
io.rest-assured:spring-mock-mvc:4.4.0
RestAssuredMockMvc 사용 - 테스트 환경 세팅
Spring Rest Docs 공식문서를 살펴보면 @ExtendWith(RestDocumentationExtension.class)
를 달아주어야 한다. 따라서 다음과 같이 테스트 환경을 구성할 수 있다.
@WebMvcTest
@ExtendWith(RestDocumentationExtension.class)
class MemberControllerTest {
@MockBean
MemberService memberService;
MockMvcRequestSpecification restDocs;
@BeforeEach
void setUp(WebApplicationContext webApplicationContext, RestDocumentationContextProvider restDocumentation) {
restDocs = RestAssuredMockMvc.given()
.mockMvc(MockMvcBuilders.webAppContextSetup(webApplicationContext)
.apply(documentationConfiguration(restDocumentation)
.operationPreprocessors()
.withRequestDefaults(prettyPrint())
.withResponseDefaults(prettyPrint()))
.build())
.log().all();
}
@DisplayName("회원가입을 하면 201 반환")
@Test
void signUp() {
}
}
@BeforeEach를 통한 setUp()메소드 구성시, @WebMvcTest 애노테이션을 붙이게 되면
JUnit5의 ParameterResolver 구현체를 통해 파라미터를 주입받을 수 있다.
(@WebMvcTest를 주석처리하고 테스트를 돌렸을때 예외 메시지를 통해서도 알 수 있다.)JUnit 공식문서:https://junit.org/junit5/docs/5.2.0/api/org/junit/jupiter/api/BeforeEach.html
RestAssuredMockMvc 사용 - 유저 회원가입 테스트 작성
@DisplayName("회원가입을 하면 201 반환")
@Test
void signUp() {
SignUpRequest signUpRequest = SignUpRequest.builder()
.loginId("test1234")
.username("이호석")
.nickname("키다리")
.password("!Asdf1234")
.passwordConfirmation("!Asdf1234")
.email("test1234@test.com")
.phone("010-0000-0000")
.build();
restDocs
.contentType(MediaType.APPLICATION_JSON_VALUE)
.body(signUpRequest) //(1)
.when().post("/api/v1/members") //(2)
.then().log().all() //(3)
.assertThat() //(4)
.apply(document("member/signup/success")) //(5)
.statusCode(HttpStatus.CREATED.value());
}
- Content-Type은 application/json, 요청 바디에 회원가입 정보를 넘겨준다.
- 컨트롤러에 매핑된 회원가입 URI에 POST 요청을 전송한다.
- 요청, 응답에 대한 결과를 로깅함
- 검증시작
build/generated-snippets/
하위에 document로 지정한 경로에 생성된 snippet(.adoc 파일)들이 저장된다.
Spring Rest Docs 만들기
src/docs/asciidoc/index.adoc
을 만든다
이후 adoc 문법을 통해 api 명세를 작성한다.
= MY Little Blog API 명세 // 제목
:doctype: book
:icons: font
:source-highlighter: highlightjs // 코드들의 하이라이팅을 highlightjs를 사용
:toc: left // Table Of Contents(목차)를 문서의 좌측에 두기
:toclevels: 2 // 목차 레벨 설정
:sectlinks:
:sectnums: // 분류별 자동으로 숫자를 달아줌
:docinfo: shared-head
== 회원가입
=== 회원가입
==== 성공
operation::member/signup/success[snippets='http-request,http-response']
asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor'
의존성을 추가했기 때문에 operation 명령어를 사용할 수 있다.
[, ] 사이에는 가져올 snippets을 지정하는데, 응답과 요청 스니펫만 불러온다.
Spring Rest Docs을 통한 API 문서 생성 완료
❗️ 트러블 슈팅
유저 회원가입시 휴대폰 번호 null 입력 오류
관련 PR
fix: 유저 회원가입시 휴대폰 번호 null 입력 오류 수정 by HiiWee · Pull Request #9 · MyToy-Project/my-little-b
✅ 관련 이슈 #5 ✅ 문제 상황 postman으로 회원가입 테스트 후 MySQL workbench로 조회했을때 phone 컬럼이 null로 조회됨 해결 dto -> entity로 변경 되는 service코드에서 phone 필드가 누락되어 있었음 ✅ 배운
github.com
api 테스트 까지 마치고 postman을 통해 실제 유저에 대한 회원가입을 다음과 같이 요청했다.

이후 실제 삽입된 DB를 조회했는데 phone 컬럼에 null값이 입력되는것을 확인했다.

사실 문제는 정말 간단했다. 서비스 레이어에서 dto -> entity로 변환할때 Phone 필드가 누락되어 있었고 이로인해 참조형 필드의 기본값인 null이 entity에 들어가고 그대로 DB에 저장을 했다.
사실 트러블 슈팅이라고 하기에도 민망하다고 생각되지만, 저 문제를 발견했을 당시 전체 코드 라인 테스트 커버리지가 86이었고 서비스는 100프로를 커버하고 있었지만, 테스트 코드를 통해 해당 오류를 발견할 수 없었다.
이는 내가 작성한 테스트 코드에 문제점이 있다는걸 의미했다.
기존 테스트 코드
기존 테스트 코드는 다음과 같다. 단순히 아이디, 비밀번호 필드만 검증하여 객체의 실제 DB 저장 사실 여부만 확인한다.
모든 값들이 정상적으로 등록되었는지 판단하기에는 빈약하다.
@DisplayName("회원가입 조건을 만족하면 회원가입을 성공한다.")
@Test
void signUp() {
memberService.signUp(signUpRequest);
assertThat(
memberRepository.findByLoginIdAndPasswordValue("test123", encryptor.encrypt("!Abc123123"))).isPresent();
}
개선한 회원가입 테스트
회원가입은 당연히 통과해야 하며, 모든 필드에 대한 실제 값이 제대로 저장되었는지 검증함으로써 유저 데이터가 정상적으로 생성되고 저장 됐는지 확인한다.
@DisplayName("회원가입 조건을 만족하면 회원가입을 성공한다.")
@Test
void signUp() {
memberService.signUp(signUpRequest);
Member findMember = memberRepository.findByLoginIdValueAndPasswordValue("test123", encryptor.encrypt("!Abc123123")).get();
Assertions.assertAll(
() -> assertThat(findMember.getLoginId().getValue()).isEqualTo("test123"),
() -> assertThat(findMember.getUsername().getValue()).isEqualTo(encryptor.encrypt("이호석")),
() -> assertThat(findMember.getNickname()).isEqualTo("키다리"),
() -> assertThat(findMember.getPassword().getValue()).isEqualTo(encryptor.encrypt("!Abc123123")),
() -> assertThat(findMember.getEmail()).isEqualTo("asdf@test.com"),
() -> assertThat(findMember.getPhone()).isEqualTo("010-0000-0000")
);
}
단순히 테스트 커버리지만 높이는 것이 좋은 테스트 코드가 아님을 배웠다.
테스트의 주 목적은 내가 작성한 로직들이 기대한대로 동작하고 정확한 데이터를 만드는가를 검증하는 것이 1순위다.
내가 작성했던 회원가입 로직은 그런 기대를 벗어났다.
만약 postman이 아닌 실제 서비스에 대한 배포였고, 이 사실을 배포한지 한참 뒤에 알게되어 사용자들의 데이터가 모두 null로 저장되었다면, 정말 많은 손해를 보게 된다... 사람은 결국 실수를 항상 만들어 내고, 테스트 코드는 그런 실수를 검증하는데 필수 요소이지 않을까?
이런 문제를 지금 겪은것이 아주 다행이다라는 생각이 듭니다. 아주 적은 리소스로 매우 중요한 것을 배웠다..!