> Hello World !!!

     

@syaku

spring boot RestDocs 코드 리팩토링과 Spring RestDocs 적용

코드 리팩토링과 Spring RestDocs 적용

Github: https://github.com/syakuis/syaku-blog

모든글

지금까지 개발해온 코드를 리팩토링한다. 우선 필요없는 테스트 클래스를 제거했다. org.syaku.blog.post.service.CrudPostServiceTest 그리고 UserControllerTest 필요없는 ObjectMapper 를 제거했다. 전체 테스트를 실행한다. (소스를 변경하면 항상 테스트를 실행한다.)

Entity 접미사 *DateTime*Datetime 변경한다.

테스트 결과를 보면 1초 이상의 러닝 타임이 발생하는 테스트가 몇개 있다. 그중에서 짐작할 수 있는 테스트는 PostListControllerTestPostListTest 클래스이다. 테스트시 매번 임시 데이터를 insert 하고 있다. 그래서 테스트시 한번만 insert 되도록 변경하였다.

참고

통합 테스트(전체 테스트)는 각 테스트에서 생성된 데이터를 초기화하지 않는 다. 그래서 이전에 어떤 데이터가 생성됐는 지 알 수 없다. 이부분을 명식하고 테스트를 작성해야 한다.

< / > src/test/java/org/syaku/blog/ApplicationStartup.java

package org.syaku.blog;

@Profile("post-data-initialize")
@Component
@Transactional
public class ApplicationStartup implements ApplicationListener<ApplicationReadyEvent> {
 @Autowired
 private PostRepository postRepository;

 @Override
 public void onApplicationEvent(ApplicationReadyEvent event) {
   StringBuilder stringBuilder = new StringBuilder();
   for (int i = 0; i < 1000; i++) {
     postRepository.save(PostEntity.builder()
      .subject(stringBuilder.append("a").append(i).append(i % 2 == 0 ? "search" : "").toString())
      .contents("내용").build());
     stringBuilder.setLength(0);
  }
}
}

원래 Spring Initialize Database 를 통해 data.sql 의 쿼리를 실행하여 일괄적으로 데이터를 넣을 수 있지만 위처럼 반복적이면서 많은 데이터는 data.sql 에 쿼리로 작성하는 건 비효율적이다.

참고 @Sql : https://docs.spring.io/spring/docs/current/spring-framework-reference/testing.html#testcontext-executing-sql

ApplicationListener 은 스프링 이벤트 수신자이다. 제너릭 타입에 의해 이벤트가 실행된다. ApplicationReadyEvent 는 스프링 어플리케이션이 모든 준비 작업을 마쳤을때 해당 클래스가 실행된다.

하지만 이경우 모든 테스트에 데이터를 생성하려고 할 것이다. 모든 테스트에 임시 데이터를 생성할 필요가 없다. 그래서 ApplicationStartup 클래스에 프로파일을 설정하고 필요한 부분에만 사용할 수 있도록 @ActiveProfiles("post-data-initialize") 선언하면 된다. PostListControllerTestPostListTest 상단에 추가하고 기존에 있던 임시 데이터 생성하는 기능은 제거하고 통합 테스트를 실행한다.

PostListControllerTest 에서 오류가 발생한다. 이건 이전부터 문제가 있던 테스트이다. 귀찮아서 자세히 보지 않고 대충 맞춰서 테스트를 통과시켰었다. 절대 이러면 안된다... 아래와 같이 수정했다.

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("post-data-initialize")
public class PostListControllerTest {
 @Autowired
 private MockMvc mvc;

 @Autowired
 private PostService postService;

 @Test
 public void 페이징_테스트() throws Exception {
   Page<PostEntity> page = postService.getPostPaging();
   this.mvc.perform(get("/post"))
    .andExpect(status().isOk())
    .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8_VALUE))
    .andExpect(jsonPath("$.content", hasSize(page.getSize())))
    .andExpect(jsonPath("$.content[0].subject", is(page.getContent().get(0).getSubject())))
    .andExpect(jsonPath("$.number", is(page.getNumber())))
    .andExpect(jsonPath("$.totalElements", is((int) page.getTotalElements())))
    .andExpect(jsonPath("$.totalPages", is(page.getTotalPages())));
}

 @Test
 public void 검색_테스트() throws Exception {
   Page<PostEntity> page = postService.getSearchPostPaging("search");
   this.mvc.perform(get("/post").param("subject", "search"))
    .andExpect(status().isOk())
    .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8_VALUE))
    .andExpect(jsonPath("$.content", hasSize(page.getSize())))
    .andExpect(jsonPath("$.content[0].subject", is(page.getContent().get(0).getSubject())))
    .andExpect(jsonPath("$.number", is(page.getNumber())))
    .andExpect(jsonPath("$.totalElements", is((int) page.getTotalElements())))
    .andExpect(jsonPath("$.totalPages", is(page.getTotalPages())));
}

 @Test
 public void 검색어_없는_테스트() throws Exception {
   this.mvc.perform(get("/post").param("subject", ""))
    .andExpect(status().isOk())
    .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8_VALUE))
    .andExpect(jsonPath("$.content", hasSize(0)))
    .andExpect(jsonPath("$.totalElements", is(0)))
    .andExpect(jsonPath("$.totalPages", is(0)));
}

 @Test
 public void 페이지_변경_테스트() throws Exception {
   Page<PostEntity> page = postService.getPostPaging(50);

   this.mvc.perform(get("/post").param("page", "50"))
    .andExpect(status().isOk())
    .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8_VALUE))
    .andExpect(jsonPath("$.content", hasSize(page.getSize())))
    .andExpect(jsonPath("$.content[0].subject", is(page.getContent().get(0).getSubject())))
    .andExpect(jsonPath("$.number", is(page.getNumber())))
    .andExpect(jsonPath("$.totalElements", is((int) page.getTotalElements())))
    .andExpect(jsonPath("$.totalPages", is(page.getTotalPages())));
}

 @Test
 public void 검색_페이지_변경_테스트() throws Exception {
   Page<PostEntity> page = postService.getSearchPostPaging("search", 20);
   this.mvc.perform(get("/post")
    .param("subject", "search")
    .param("page", "20"))
    .andExpect(status().isOk())
    .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8_VALUE))
    .andExpect(jsonPath("$.content", hasSize(page.getSize())))
    .andExpect(jsonPath("$.content[0].subject", is(page.getContent().get(0).getSubject())))
    .andExpect(jsonPath("$.number", is(page.getNumber())))
    .andExpect(jsonPath("$.totalElements", is((int) page.getTotalElements())))
    .andExpect(jsonPath("$.totalPages", is(page.getTotalPages())));
}
}

하지만 위에서 사용된 데이터를 초기화하는 방벙에 문제가 있다. 어떻게 임시로 등록된 데이터를 삭제할 것인가? 그냥 놔두는 것은 다른 테스트에 영향을 줄 수 있다. 그래서 다시 원점으로 돌아가 기존에 했던 방식에서 더 좋은 방법을 모색하려고 한다.

  • 테스트 시작전에 임시 데이터를 생성한다.

  • 테스트를 진행한다.

  • 테스트 완료후에 임시 데이터를 삭제한다.

아래와 같이 작성할 수 있다.

// @Profile("post-data-initialize") 제거하기!

 private List<PostEntity> postEntities;

 @Before
 public void setup() {
   StringBuilder stringBuilder = new StringBuilder();
   postEntities = new ArrayList<>();
   for (int i = 0; i < 1000; i++) {
     this.postEntities.add(PostEntity.builder()
      .subject(stringBuilder.append("a").append(i).append(i % 2 == 0 ? "search" : "").toString())
      .contents("내용").build());
     stringBuilder.setLength(0);
  }

   this.postEntities = this.postService.save(this.postEntities);
}

 @After
 public void exit() {
   this.postService.delete(postEntities);
}

하지만 이렇게 된다면 테스트가 실행될때마다 생성하고 지우고를 반복할 것이다. 한번만 생성하고 한번만 삭제할 수 있지 않을까?

맴버변수에 설정된 값을 조건문으로 존재여부를 판단하여 데이터를 저장하면 되지 않을까 생각하겠지만 모든 테스트는 실행될때 상태 즉 설정된 값을 모두 초기화 된다. 하지만 정적인 경우 초기화되지 않았다.

데이터를 초기화할 수 있어도 제거할 수는 없게 된다. 테스트가 종료될때 데이터를 삭제해야하는 데 종료시점을 알수가 없다.

결국 테스트 클래스가 실행될때 한번 호출되고 종료될때 한번 실행되어야 한다.

그럼 Junit 의 BeforeClass ? 이건 정정 메서드만 지원해서 문제가 된다. 빈을 정적으로 주입할 수 없다.

아마 처음부터 스프링 테스트 메뉴얼을 정독했다면 답을 찾았을 것이다.

https://docs.spring.io/spring/docs/current/spring-framework-reference/testing.html#testing

TestExecutionListeners 을 사용하면 테스트 전후를 다양하게 제어할 수 있다. TestExecutionListeners 사용하기 위해 TestExecutionListener 인터페이스나 AbstractTestExecutionListener 추상 구현체를 상속받아 사용하면 된다. 각 메서드의 기능은 아래와 같다.

  • prepareTestInstance - 스프링 초기화할때 호출된다.

  • beforeTestClass - 테스트가 실행되기 전에 최초 한번 호출된다.

  • beforeTestMethod - 테스트가 실행되기 전에 호출된다.

  • beforeTestExecution - beforeTestMethod 다음에 호출된다.

  • afterTestExecution - 테스트가 종료되고 호출된다.

  • afterTestMethod - afterTestExecution 다음에 호출된다.

  • afterTestClass - 모든 테스트가 종료되고 한번 호출된다.

AbstractTestExecutionListener 상속받아 구현체를 작성한다.

< / > PostListTestListener

package org.syaku.blog.post;

public class PostListTestListener extends AbstractTestExecutionListener {
 private PostService postService;

 private List<PostEntity> postEntities;

 @Override
 public void beforeTestClass(TestContext testContext) {
   this.postService = testContext.getApplicationContext().getBean("postService", PostService.class);

   StringBuilder stringBuilder = new StringBuilder();
   postEntities = new ArrayList<>();
   for (int i = 0; i < 1000; i++) {
     postEntities.add(PostEntity.builder()
      .subject(stringBuilder.append("a").append(i).append(i % 2 == 0 ? "search" : "").toString())
      .contents("내용").build());
     stringBuilder.setLength(0);
  }

   postService.save(postEntities);
}

 @Override
 public void afterTestClass(TestContext testContext) {
   postService.delete(postEntities);
}
}

자동 주입이 되지않는 다. 그래서 TestContext 에 있는 정보를 가져와 사용했다. 이제 테스트 소스는 아래와 같고 변경된 부분만 작성했다.

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
@TestExecutionListeners(listeners = PostListTestListener.class, mergeMode = MERGE_WITH_DEFAULTS)
public class PostListControllerTest {
 @Autowired
 private MockMvc mvc;

 @Autowired
 private PostService postService;

 @Test
 public void 페이징_테스트() throws Exception {
... skip ...

스프링 테스트는 기본적으로 다양한 테스트 리스너를 기본을 제공하고 있다. 그래서 기존에 있는 리스너를 그대로 사용하려면

mergeMode = MERGE_WITH_DEFAULTS 설정해야 한다.

PostListControllerTest 클래스의 테스트를 실행한다. 성공했다면 PostListTest 클래스도 수정한다.

package org.syaku.blog.post;

@RunWith(SpringRunner.class)
@SpringBootTest
@TestExecutionListeners(listeners = PostListTestListener.class, mergeMode = MERGE_WITH_DEFAULTS)
public class PostListTest {
@Autowired
private PostService postService;

... skip ...
}

통합 테스트를 실행한다.

위와 같이 많은 데이터가 테스트에 필요할까? 이건 성능 테스트가 아니다. 우리가 만들려는 프로그램이 정상적으로 작동되는 지에 대한 가설을 만들고 있다. 테스트는 오직 현재 기능에 의한 가설을 세워야 한다고 생각한다. 불필요한 테스트 즉 사용하지 않는 가설까지 만들 필요는 없다. 우린 항상 리팩토링하고 테스트하기를 반복해야 한다. 그러니 지금에 집중하고 지금의 기능에서 생각할 수 있는 가설을 모두 테스트하면 된다. 역시 말은 쉽고 실천은 어렵다. :) 그래서 데이터를 적당한 수로 조절하고 테스트를 직접 수정해보도록한다. (깃헙 코드를 참고해도 된다.)

그리고 Hibernate Batch 를 이용하여 처리할 수도있다. 배치를 사용하면 하나식 저장하는 것이 아니라 일정한 량을 한번에 저장하기 때문에 DBMS 연결 횟수를 줄여 오버헤드 발생을 최소화 할 수 있다. myBatis 때는 확실히 효과가 있었는 데 하이버네이트는 오히려 시간이 더 걸렸다. 뭘 잘못한 건지? 구글링해도 정보가 별로 없어 설정법만 설명한다.

< / > application.properties

# batch 20 ~ 50 권장 사이즈이다.
spring.jpa.properties.hibernate.jdbc.batch_size=50

# batch 로그 출력
logging.level.org.hibernate.engine.jdbc.batch.internal.BatchingBatch=trace

다음은 사용자 정보이다. admin 계정은 운영이든 테스트든 항상 필요한 데이터이다. 서비스에는 접근 권한이 있기때문에 허가되지 않은 사용자는 접근할 수 없다. 그러니 최소 1개의 운영권한을 가진 사용자가 없다면 블로그를 관리할 수 없게된다.

참고 : https://docs.spring.io/spring-boot/docs/2.0.6.RELEASE/reference/htmlsingle/#howto-database-initialization

그래서 서비스가 구동되면 Spring Initialize Database 를 통해 data.sql 가 실행되도록 구현하였다. 그리고 최종 properties 는 아래와 같다.

< / > src/main/resources/application.properties

spring.datasource.driver-class-name=org.h2.Driver

spring.jpa.hibernate.ddl-auto=none
spring.datasource.schema=
spring.datasource.data=classpath:data.sql,classpath*:*-data.sql

schema 는 생성할 테이블 직접 작성할 수 있다. 하지만 Entity 클래스를 이용하여 생성하기 때문에 필요없다. 꼭 공백으로 둔다. data 는 일반적으로 데이터 삽입 및 수정, 삭제 의 쿼리를 작성한다. 두개의 설정은 쉼표로 여러개의 파일을 설정할 수 있고 와일드 카드를 사용할 수 있다. 그리고 데이터 초기화 순서는 schema.sql > Entity Class > data.sql 이다.

< / > src/test/resources/application.properties

spring.datasource.driver-class-name=org.h2.Driver

spring.jpa.hibernate.ddl-auto=create
spring.jpa.properties.hibernate.show_sql=true
spring.jpa.properties.hibernate.format_sql=true

#spring.jpa.properties.hibernate.jdbc.batch_size=50
#spring.jpa.properties.hibernate.order_inserts=true
#spring.jpa.properties.hibernate.order_updates=true
#spring.jpa.properties.hibernate.jdbc.batch_versioned_data=true

logging.level.org.hibernate=info
#logging.level.org.hibernate.SQL=debug
logging.level.org.hibernate.type.descriptor.sql=trace
logging.level.org.hibernate.engine.jdbc.batch.internal.BatchingBatch=trace
logging.level.org.springframework.security=debug
logging.level.org.syaku=debug

blog.security.enableCsrf=false

< / > src/main/resources/data.sql

insert into user (id, username, password, email, creation_datetime) values (USER_ID_SEQ.NEXTVAL,'admin', '1234', 'syaku@naver.com', SYSDATE);

전체 테스트를 실행하면 UserControllerTest 의 사용자인증허가UserServiceTest 의 사용자등록에서 오류가 발생한다. 사용자인증허가에서 setup 기능에서 admin 을 생성하는 부분제거했 사용자등록에서는 admin 계정이 아닌 syaku 계정이 생성되게 변경하였다.

전체 테스트를 실행한다.

Spring RestDocs

스프링 컨트롤러를 개발하고 이를 요청하기 위한 Restful API 메뉴얼을 작성한다. 하지만 직접 작성하는 것이 아닌 Spring RestDocs 를 이용하여 자동으로 생성되게 한다. 스프링 컨트롤러의 테스트 코드를 작성하고 이를 Spring RestDocs 와 연동하면 자동으로 AsciiDocs 문서가 생성되게 된다. 이를 다시 html 로 변환하면 최종적으로 사용자가 볼 수 있는 페이지가 완성된다. 모든 작업이 자동으로 이루어진다.

Spring RestDocs 1.2.5 버전을 사용한다. 아래의 주의점은 다른 버전을 사용할 경우 참고하기 위해 작성했다.

주의점

(옵션) Gradle Wrapper 업그레이드

$ ./gradlew wrapper --gradle-version 4.10.2

혹은 gradle.build 아래와 같이 작성하면 된다.

task wrapper(type: Wrapper) {
gradleVersion = '4.10.2'
}

https://docs.gradle.org/current/userguide/gradle_wrapper.html#sec:upgrading_wrapper

< / > gradle.build

추가된 내용만 작성했다.

buildscript {
 ext {
   restDocsVersion = "2.0.2.RELEASE"
}

 dependencies {
   classpath "org.asciidoctor:asciidoctor-gradle-plugin:1.5.3"
}
}

apply plugin: "org.asciidoctor.convert"

ext {
 // 해당 경로에 아스키덕 문성가 생성된다.
 snippetsDir = file("build/generated-snippets")
}

dependencies {
 asciidoctor "org.springframework.restdocs:spring-restdocs-asciidoctor:${restDocsVersion}"
 testCompile "org.springframework.restdocs:spring-restdocs-mockmvc:${restDocsVersion}"
}


// gradle wrapper 버전을 4.10.2 로 설치한다.
task wrapper(type: Wrapper) {
 gradleVersion = "4.10.2"
}

test {
 outputs.dir snippetsDir
}

asciidoctor {
 inputs.dir snippetsDir
 dependsOn test
}

bootJar {
 dependsOn asciidoctor
 from ("${asciidoctor.outputDir}/html5") {
   into 'static/docs'
}
}

만약 스프링 부트 1에서 문서를 jar 에 포함하려면 아래와 같다.

Spring boot 1

jar {
dependsOn asciidoctor
from ("${asciidoctor.outputDir}/html5") {
into 'static/docs'
}
}

메뉴얼 첫페이지는 직접 만들어야 한다. MarkDown이 아닌 AsciiDoc 로 작성한다. 파일은 src/docs/asciidoc 경로에 생성한다.

참고 : https://asciidoctor.org/docs/

= Blog Restful API Document
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 2
:sectlinks:

== 포스트

=== 글 작성
블로그에 글을 작성한다.

==== 요청
include::{snippets}/post/http-request.adoc[]

==== 응답
include::{snippets}/post/http-response.adoc[]

코드에서 {snippets} 은 gradle.build 에서 attributes 'snippets': snippetsDir 설정된 값이다. include 는 해당 파일의 내용을 포함한다.

기존에 있는 테스트 소스에 적용해도 되고 RestDocs 전용 테스트 컨트롤러를 만들어도 된다. RestDosc 용 테스트를 작성했다.

< / > RestDocsPostControllerTest.java

package org.syaku.blog.post.web;

@RunWith(SpringRunner.class)
@SpringBootTest
public class RestDocsPostControllerTest {
 @Autowired
 private WebApplicationContext wac;
 private MockMvc mockMvc;
 private ObjectMapper objectMapper;

 @Rule
 public JUnitRestDocumentation restDocumentation =
   new JUnitRestDocumentation("build/generated-snippets");

 @Before
 public void setup() {
   this.objectMapper = new ObjectMapper();
   this.mockMvc = MockMvcBuilders.webAppContextSetup(wac)
    .defaultRequest(MockMvcRequestBuilders.post("/post")
      .accept(MediaType.APPLICATION_JSON_UTF8_VALUE)
      .contentType(MediaType.APPLICATION_JSON_UTF8_VALUE))
    .alwaysExpect(status().isOk())
    .alwaysExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8_VALUE))
    .alwaysDo(
       document("{method-name}", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint())))
    .apply(documentationConfiguration(this.restDocumentation))
    .build();
}

 @Test
 public void post() throws Exception {
   this.mockMvc.perform(MockMvcRequestBuilders.post("/post")
    .content(objectMapper.writeValueAsString(
       PostEntity.builder().subject("제목").contents("내용").build())));
}
}

테스트를 실행하면 아래와 같이 파일이 생성된다.

그리고 그래들 빌드 작업을 실행하면 아래와 같이 파일이 생성된다.

index.html 파일을 브라우저로 실행하면 된다.