๋ค์ด๊ฐ๊ธฐ ์
์ด๋ฒ ํ๋ก์ ํธ๋ฅผ ์งํํ๋ฉด์ API๋ฌธ์๋ฅผ REST Docs๋ฅผ ์ฌ์ฉํด ๋ง๋ค๊ธฐ๋ก ํ์๋ค. API ๋ช ์ธ์๋ฅผ REST Docs๋ฅผ ์ฌ์ฉํด์ ์์ฑํ๋๊ฑด ์ฒ์์ด์ฌ์ ํ๋ก์ ํธ๋ฅผ ์์ํ๊ธฐ์ ์ ์ด๋ฐ์ ๋ฐ ํ ์คํธ๋ฅผ ์งํํด ๋ณด์๋ค. ๊ทธ๋ผ ์ง๊ธ๋ถํฐ ์์๋ณด์!
REST Docs?
Spring REST Docs๋ ์ ํํ๊ณ ๊ฐ๋ ์ฑ ์ข์ REST ๋ฌธ์๋ฅผ ์ ๊ณตํ๊ธฐ ์ํ ํ๋ก์ ํธ์ด๋ค. Asciidoctor๋ฅผ ํ์ฉํด์ ํ ์คํธ์ฝ๋๋ฅผ adoc์ผ๋ก ๋ณํํ HTML๋ก ๋ณํ์์ผ์ฃผ๋ ๋ฐฉ์์ ์ฌ์ฉํ๋ค. ๋์์ผ๋ก Markdown์ ์ฌ์ฉํ ์๋ ์๋ค.
Adoc?
adoc์ ๋ฌธ์๋ฅผ ์์ฑํ๊ธฐ ์ํ ๊ฒฝ๋ํ ๋งํฌ์ ์ธ์ด์ด๋ค. asciidoctor๋ฅผ ํตํด html์ด๋ pdf๋ฑ์ ํํ๋ก ๋ณํํ์ฌ ํ์ฉ์ด ๊ฐ๋ฅํ๋ค.
๋จผ์ Spring MVC์ test์์ ์ ๊ณตํ๋ MockMvc๋ก ํ ์คํธ ์ฝ๋๋ฅผ ์์ฑํ์ฌ ์ฌ์ฉํ๋ค.์ฌ๊ธฐ์ REST Docs์ ์ฅ์ ์ด ๋์จ๋ค. ๋ฐ๋ก ํ ์คํธ ์ฝ๋๋ฅผ ํตํด REST API ๋ฌธ์๋ฅผ ์์ฑํด์ฃผ๊ธฐ ๋๋ฌธ์ ํ ์คํธ๊ฐ ๊ฒ์ฆ๋์๋ค๋ฉด ์์ฑ๋๋ ๋ฌธ์๋ ์ ๋ขฐํ ์์๋ค๋ ๊ฒ์ด๋ค.
REST Docs vs Swagger
์ค์จ๊ฑฐ๋ฅผ ์ฌ์ฉํ๊ฒ ๋๋ฉด ํ๋ก๋์ ์ฝ๋์ ๋ฌธ์ํ์ ๋ํ ์ฝ๋๊ฐ ํฌํจ๋์ด, ๊ฐ๋ ์ฑ์ด ๋จ์ด์ง๊ฒ๋๋ค. ๊ทธ๋ฆฌ๊ณ API ์คํ์ด ๋ณ๊ฒฝ๋์์๋, ์ด๋ ธํ ์ด์ ์ ๋ณ๊ฒฝํ์ง ์์ผ๋ฉด API ๋ฌธ์๊ฐ ์์ ๋์ง ์๋๋ค.
์ด์๋ฐํด REST Docs๋ ํ ์คํธ์ฝ๋๋ฅผ ์์ฑํ๋ฉด์ API๋ฅผ ๋ช ์ธ ํ ์์๋ค. ๋ํ ํ ์คํธ์ฝ๋๋ฅผ ํตํด ์์ฑ๋ ์ค๋ํซ๊ณผ ์ง์ ์์ฑํ ๋ฌธ์๋ฅผ ๊ฒฐํฉํ์ฌ ์ต์ข ์ ์ธ API ๋ฌธ์๋ฅผ ๋ง๋ค์์์ผ๋ฏ๋ก ์ ํ ์ฝ๋์ ์ํฅ์ด ์๋ค. ํ์ง๋ง ํ ์คํธ ๊ธฐ๋ฐ์ผ๋ก ์์ฑ๋๋ค๋ณด๋ ํ ์คํธ๊ฐ ์คํจํ๋ฉด ๋ฌธ์๋ฅผ ์์ฑํ ์๊ฐ์๊ณ , ๋ฌธ์ํ๋ฅผ ์ํ ํ ์คํธ๋ฅผ ์์ฑํด์ผํ๋ค๋ ์ ์ด๋ค. TDD๋ฐฉ์์ ์ ์ฉํ๋ค๋ฉด ์คํ๋ ค ์ข์๊ฒ ์๋๊น?
MockMvc vs RestAssured
RestDocs์์ ์ฌ์ฉํ ์์๋ 2๊ฐ์ง ๋ฐฉ์์ด๋ค. RestAssured๋ ๋ณ๋์ ๊ตฌ์ฑ์ ํ์ง์๋๋ค๋ฉด, @SpringBootTest ์ ํจ๊ป ์ฌ์ฉ๋์ด์ผํ๋๋ฐ, @SpringBootTest๋ ์คํ๋ง์ ์ ์ฒด ๋น์ ์ปจํ ์คํธ์ ๋์ด์ ํ ์คํธ ํ๊ฒฝ์ ๊ตฌ๋ํ๋ค. ์ฆ, ์ดํ๋ฆฌ์ผ์ด์ ์ด ๋์ํ๋ ์ค์ ํ๊ฒฝ๊ณผ ๋์ผํ๊ฒ ํ ์คํธ๋ฅผ ์งํํ๊ณ ์ถ์๋ ์ฌ์ฉํ๋ค. ์ด์ ๋ฐ๋ผ์ @SpringBootTest๋ฅผ ํตํด ๋ชจ๋ ๋น์ด ๋ฑ๋ก ๋๋ ๊ณผ์ ์์ ๋๋ ค์ง๊ณ ๋น์ฉ์ด ๋ง์ด๋ ๋ค.
์ด์๋ฐํด MockMvc ๋ @SpringBootTest , @WebMvcTest ์ค ์ ํํด์ ์ฌ์ฉ์ด ๊ฐ๋ฅํ๋ค. @WebMvcTest๋ ํ๋ ์ ํ ์ด์ ๋ ์ด์ด์ ๋น๋ค๋ง ๋ก๋ํ๋ค. ๊ทธ๋ฆฌ๊ณ ๋๋จธ์ง ๊ณ์ธต์ Mocking์ ํ๋ค. ์ด๋ฐ ๋ฐฉ์์ผ๋ก ํ๋์ ๊ณ์ธต๋ง์ ํ ์คํธํ๋ ๊ฒ์ ์ฌ๋ผ์ด์ค ํ ์คํธ๋ผ๊ณ ํ๋ฉฐ, ์ผ๋ฐ์ ์ผ๋ก ๋ฌธ์ํ๋ฅผ ์ํด ์ปจํธ๋กค๋ฌ ํ ์คํธ๋ฅผ ์์ฑํ ๋๋ ์ปจํธ๋กค๋ฌ ์ด์ธ์ ๊ณ์ธต์ Mockingํ์ฌ ์งํํ๋ค.
REST Docs ์ฌ์ฉ
๋ด๊ฐ ๊ตฌ์ฑํ ํ๊ฒฝ์ ์๋์ ๊ฐ๋ค.
- Java 11
- Gradle 8.8
- Spring 2.7
Gradle ์ค์
[ํ๋ฌ๊ทธ์ธ ์ถ๊ฐ]
plugins {
// ...
id 'org.asciidoctor.jvm.convert' version '3.3.2'
}
Asciidoctor ํ๋ฌ๊ทธ์ธ์ ์ ์ฉํ๋ค.
- Gradle 7 ๋ฒ์ ์ด์์ผ ๋ org.asciidoctor.jvm.convert 3.3.2 ๋ฒ์ ์ ์ฌ์ฉํ๋ค.
- Gradle 7 ๋ฒ์ ๋ฏธ๋ง์ผ ๋ org.asciidoctor.convert 2.4.0 ๋ฒ์ ์ ์ฌ์ฉํ๋ค.
[๊ตฌ์ฑ ์ถ๊ฐ]
configurations {
// ...
asciidoctorExt
}
Asciidoctor๋ฅผ ํ์ฅํ๋ ์ข ์์ฑ์ ๋ํ ๊ตฌ์ฑ์ ์ ์ธํ๋ค.
[์์กด์ฑ ์ถ๊ฐ]
dependencies {
// ...
asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
}
ํ ์คํธ ์ฝ๋์์ MockMvc๋ฅผ ์ฌ์ฉํ๊ธฐ ์ํด ํ์ํ๋ค.
[๋ณ์ ์ ์ธ]
ext {
snippetsDir = file('build/generated-snippets')
}
[์์ ์ค์ ]
test {
useJUnitPlatform()
outputs.dir snippetsDir
}
asciidoctor {
inputs.dir snippetsDir
configurations 'asciidoctorExt'
dependsOn test
}
- test ์์
์ output์ ์์์ ์ค์ ํ snippetsDir์ ๋ด๋๋ก ํ๋ค.
- asciidoctor ์์
์ test ์์
์ ์์กดํ๊ธฐ์, test ์์
์ ๋จผ์ ์ํํ๋๋ก ํ๋ค.
- asciidoctor ์์
์ ํ์ํ input์ด snippetsDir์ ์๋ ๊ฒ์ ์ ์ ์๋ค.
- ๊ตฌ์ฑ ์ถ๊ฐ์์ ํ์ฅํ asciidoctorExt๋ฅผ ์ฌ์ฉํ๋ค.
[Jar ๋น๋ ์์ ]
bootJar {
dependsOn asciidoctor
from("${asciidoctor.outputDir}/html5") {
into 'static/docs'
}
}
ํ๋ก์ ํธ๋ฅผ Jar ํ์ผ๋ก ์์ฑ์์ Rest Docs๊ฐ ํด๋น ๊ฒฝ๋ก์ ์์นํ ์ ์๋๋ก ํ๋ค.
bootJar {
dependsOn asciidoctor
print ${asciidoctor.outputDir}
from("${asciidoctor.outputDir}/html5") {
into 'static/docs'
}
}
์ด๋ ํน์ ${asciidoctor.outputDir} ์ ๊ฒฝ๋ก๊ฐ ๋ค๋ฅธ๊ฒฝ์ฐ๋ ์๋ค. ์ด๋ฅผ ๋ฐฉ์งํ๊ธฐ ์ํด์ ์์ ๊ฐ์ ๋ฐฉ๋ฒ์ผ๋ก outputDir์ ์์น๋ฅผ ์ฐ์ด๋ณด๊ณ ์์น๋ฅผ ๋ณ๊ฒฝํด์ค๋ ์ข๋ค.
์ฌ๊ธฐ๊น์ง๋ ํ์๋ก ์ ์ฉํด์ผํ๋ ๋ถ๋ถ์ด๊ณ , ๊ธฐ๋ณธ์ ์ผ๋ก ์ด๋ ๊ฒ ์์ฑ๋๋ค๋ฉด build/docs/ ๊ฒฝ๋ก๋ก html ํ์ผ์ด ์์นํ๊ฒ ๋๋ค. ์ด๋ฅผ src/main/resources/static/docs ๋ก ๋ณต์ฌํ๋ ์์ ์ ์๋ ์ ์ฒด ์ฝ๋์ ์ถ๊ฐ๋์ด์๋ค.
[์ ์ฒด ์ฝ๋]
/******* Start Spring Rest Docs *******/
ext {
snippetsDir = file("$buildDir/generated-snippets")
}
test {
useJUnitPlatform()
outputs.dir snippetsDir
}
asciidoctor.doFirst {
delete file("src/main/resources/static/docs")
}
asciidoctor {
inputs.dir snippetsDir
configurations 'asciidoctorExt'
dependsOn test
}
bootJar {
dependsOn asciidoctor
from("${asciidoctor.outputDir}/html5") {
into 'static/docs'
}
}
task copyDocument(type: Copy) {
dependsOn asciidoctor
from file("$buildDir/docs/asciidoc")
into file("src/main/resources/static/docs")
}
build {
dependsOn copyDocument
}
/******* End Spring Rest Docs *******/
test -> asciidoctor -> copyDocument -> build ์์ผ๋ก ์์ ์ด ์งํ๋์ด ์ ํ๋ฆฌ์ผ์ด์ ์ ์คํํค๋ฉด http://localhost:8080/docs/ํ์ผ๋ช .html ๊ฒฝ๋ก๋ฅผ ํตํด API ๋ฌธ์์ ์ ๊ทผํ ์ ์๋ค.
Adoc ์ค์
= ALARM API ๋ฌธ์
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 3
== ์๋ฆผ ๊ฐ์ ธ์ค๊ธฐ
=== Request
include::{snippets}/alarm/postAlarm/http-request.adoc[]
=== Response
include::{snippets}/alarm/postAlarm/http-response.adoc[]
index.adoc
์ ์ฒด ์ฝ๋๋ฅผ ์ฐ๊ฒฐํด์ค ํ ํ๋ฉด์ด๋ผ๊ณ ์๊ฐํ๋ฉด ๋๋ค. include๋ฅผ ํตํด ๋ค๋ฅธ ํ์ผ์ ์ฐ๊ฒฐํด์ฃผ๋ฉด ๋๋ค!
= Spring REST Docs Test
:doctype: book
:source-highlighter: highlightjs
:toc: left
:toclevels: 2
:seclinks:
include::test.adoc[]
MockMvc ์ค์
[ํตํฉํ ์คํธ ์ฌ์ฉ์]
@ExtendWith({RestDocumentationExtension.class})
@Transactional
@SpringBootTest
public class IntegrationTest {
@Autowired
protected WebApplicationContext webApplicationContext;
protected MockMvc mockMvc;
@BeforeEach
protected void setUpAll(RestDocumentationContextProvider restDocumentationContextProvider) {
this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
.addFilter(new CharacterEncodingFilter("UTF-8", true))
.apply(documentationConfiguration(restDocumentationContextProvider)
.operationPreprocessors()
.withRequestDefaults( // (1)
modifyUris().scheme("https").host("docs.api.com").removePort(), prettyPrint())
.withResponseDefaults(prettyPrint()) // (2)
)
.build();
}
}
MockMvcBuilders๋ฅผ ํตํด MockMvc๋ฅผ ์์ฑํ ์ ์๋ค. ์ค์ ์ผ๋ก WebApplicationContext๋ฅผ ์ฌ์ฉํ๋๋ฐ, ์คํ๋ง์์ ๋ก๋ํ WebApplicationContext์ ์ธ์คํด์ค๋ก ๋์ํ๊ธฐ์ ์ปจํธ๋กค๋ฌ๋ ๋ฌผ๋ก ์์กด์ฑ๊น์ง ๋ก๋๋์ด ์์ ํ ํตํฉํ
์คํธ๊ฐ ๊ฐ๋ฅํ๋ค. ํํฐ์ UTF-8์ ๊ฐ์ ํ๋ CharacterEncodingFilter์ ์ถ๊ฐํ์ฌ MockMvc ์ฌ์ฉ ์ ํ๊ธ ๊นจ์ง์ ๋ฐฉ์งํ๋ค.
RestDocument๋ ์์ฒญ๊ณผ ์๋ต์ ์์ ํ ์ ์๋ ์ ์ฒ๋ฆฌ๊ธฐ๋ฅผ ์ ๊ณตํ๋ค.
(1) : ๋ฌธ์์ Request URI๋ฅผ ๊ธฐ๋ณธ http://localhost:8080์์ https://docs.api.com์ผ๋ก ๋ณ๊ฒฝํ๊ณ , ์์๊ฒ ์ถ๋ ฅํ ์ ์๋๋ก ํ๋ค.
(2): ๋ฌธ์์ Response๋ฅผ ์์๊ฒ ์ถ๋ ฅํ ์ ์๋๋ก ํ๋ค.
ํตํฉ ํ
์คํธ๋ฅผ ์ํํ๋ ํ
์คํธ๋ ์์ ํด๋์ค๋ฅผ ์์๋ฐ์์ ์ฌ์ฉํ๋ฉด ๋๋ค.
[์ง์ MockMvc ๋ง๋ค์ด์ ์ฌ์ฉ]
//Test
@AutoConfigureRestDocs
@WithMockUser
@WebMvcTest(AlarmController.class)
public class AlarmControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper; //DTO to Json
@MockBean
private AlarmService alarmService; //mocking
@DisplayName("์๋ ์์ฑ")
@Test
void createAlarm() throws Exception {
//given
AlarmDto alarmDto = new AlarmDto("test12", "test22");
Alarm alarm = Alarm.builder()
.alarmId(1L)
.testContent1("test1")
.testContent2("test2")
.build();
given(alarmService.createAlarm(any(Alarm.class)))
.willReturn(alarm);
//when & then
mockMvc.perform(MockMvcRequestBuilders.get("/alarm")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(alarmDto))
.with(SecurityMockMvcRequestPostProcessors.csrf()))
.andDo(MockMvcResultHandlers.print())
.andDo(MockMvcRestDocumentation.document("alarm/postAlarm",
Preprocessors.preprocessRequest(prettyPrint()),
Preprocessors.preprocessResponse(prettyPrint())))
.andExpect(MockMvcResultMatchers.status().isCreated());
}
}
//Controller
@RestController
@RequestMapping
@RequiredArgsConstructor
public class AlarmController {
private final AlarmService alarmService;
@GetMapping("/alarm")
public ResponseEntity postAlarm(@RequestBody AlarmDto alarmDto) {
Alarm alarm = Alarm.builder()
.alarmId(1L)
.testContent1("alarmDto.getTestContent1()")
.testContent2("alarmDto.getTestContent2()")
.build();
return new ResponseEntity<>(alarmService.createAlarm(alarm), HttpStatus.CREATED);
}
}
//DTO
@Getter
@Setter
@AllArgsConstructor
public class AlarmDto {
private String testContent1;
private String testContent2;
}
//Entity
@Entity
@Builder
@Getter
public class Alarm {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long alarmId;
private String testContent1;
private String testContent2;
}
//service -> ์ด๋ถ๋ถ๋ Mocking ๋๋ค.
@Service
public class AlarmService {
public Alarm createAlarm(Alarm alarm) {
return Alarm.builder().alarmId(1L).testContent1("111").testContent2("222").build();
}
}
๊ฒฐ๊ณผ
์ด๋ ๊ฒ ํ์ถ์ด ๋๊ฒ ๋๋ค.