BaseDocs
Test๋ฅผ ํตํด์ ์์ฑ๋๋ document๋ฅผ ๋ง๋ค๊ธฐ ์ํ ์ด์.
๋ชจ๋ RescDocs ์์ฑ์ ์ํ, TestController๋ค์ ํด๋น BascDocs.kt๋ฅผ ์์๋ฐ์์ผ ํจ.
- MockMvc ์ฌ์ฉ.
- ๊ถํ ํ
์คํธ๋ฅผ ์ํด
setUp()์์ ์ ์ ์ ๊ฐ์ฒด ์ฃผ์ . - ํ
์คํธ ์ข
๋ฃ ํ, document ์์ฑ์ ์ํด,
end()์์ ์ ์์ฑ function ์คํ.
package com.example.kotlinapiserverguide.restDocs.docs.base
import com.example.kotlinapiserverguide.api.user.domain.dto.User
import com.example.kotlinapiserverguide.restDocs.util.RestDocsGenerator
import com.example.kotlinapiserverguide.restDocs.util.RestDocsUtils
import com.fasterxml.jackson.databind.ObjectMapper
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.context.annotation.Profile
import org.springframework.restdocs.RestDocumentationContextProvider
import org.springframework.restdocs.RestDocumentationExtension
import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.test.context.support.WithMockUser
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.web.context.WebApplicationContext
import java.time.LocalDateTime
@ExtendWith(RestDocumentationExtension::class)
@WebMvcTest(useDefaultFilters = false)
@EnableMethodSecurity(proxyTargetClass = true)
@WithMockUser
@AutoConfigureMockMvc
@AutoConfigureRestDocs(
// uriScheme = "http",
uriHost = "localhost",
uriPort = 8000
)
open class BaseDocs {
@Autowired
lateinit var mockMvc: MockMvc
@Autowired
lateinit var objectMapper: ObjectMapper
@BeforeEach
fun setUp(
webApplicationContext: WebApplicationContext,
restDocumentationContextProvider: RestDocumentationContextProvider,
) {
mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
.apply<DefaultMockMvcBuilder>(documentationConfiguration(restDocumentationContextProvider))
.build()
setUser()
}
private fun setUser() {
val user = User(
id = 1,
username = "test",
password = "test",
name = "test",
phoneNumber = "01011111111",
imageUrl = null,
createdAt = LocalDateTime.now(),
updatedAt = LocalDateTime.now()
)
val authenticationToken = UsernamePasswordAuthenticationToken(user, null, null)
authenticationToken.details = user
SecurityContextHolder.getContext().authentication = authenticationToken
}
companion object {
@JvmStatic
@AfterAll
fun end(): Unit {
RestDocsGenerator().generateTemplate()
}
}
}RestDocsGenerator
RestDocs ์์ฑ์ ์ํ ๊ฐ์ฒด.
- ๊ธฐ๋ณธ์ ์ผ๋ก ํ๋ ์ฝ๋ฉ์ผ๋ก
.adocํ์ผ์ ์์ฑํ๊ณ gradle ascii doctor๋ฅผ ํตํด.adocํ์ผ์ html ํํ๋ก ๋ณํ. - ํ๋ ์ฝ๋ฉ์ด ๋ค์ด๊ฐ ์ด์ ๋ ์ง์ custom์ ํตํด ๋ด๊ฐ ์ํ๋ ํํ๋ก ๋ง๋ค๊ธฐ๋ฅผ ์ํจ.
- ascii docs์์ ์ ๊ณตํ๋ ๊ฐ๊ฐ์ snippet์ ์์ ํ๋ฉด ์ข ๋ ์ธ๋ฐํ custom ๊ฐ๋ฅ.
src/test/resources/org/springframework/restdocs/templates์ฐธ๊ณ .
package com.example.kotlinapiserverguide.restDocs.util
import com.example.kotlinapiserverguide.common.http.constant.ResponseCode
import com.example.kotlinapiserverguide.restDocs.constant.DocsApi
import com.example.kotlinapiserverguide.restDocs.constant.DocsApiType
import java.io.File
import java.io.FileOutputStream
class RestDocsGenerator {
private val defaultUri: String = "localhost:8080"
private var snippetDefaultPathParameter: String = "{snippets}"
private val snippetPath: String = "build/generated-snippets"
private var pathParameterTemplate: String = "path-parameters.adoc"
private var httpRequestTemplate: String = "http-request.adoc"
private var requestFieldsTemplate: String = "request-fields.adoc"
private var httpResponseTemplate: String = "http-response.adoc"
private var responseFieldsTemplate: String = "response-fields.adoc"
private val curlRequestTemplate: String = "curl-request.adoc"
private val URITemplate:String = "httpie-request.adoc"
fun generateTemplate() {
val docs: File = File("src/docs/asciidoc/apiDocs.adoc")
val stringBuilder = StringBuilder()
stringBuilder
// snippets ๊ฒฝ๋ก
.append("ifndef::snippets[]")
.append(System.lineSeparator())
.append(":snippets: ./build/generated-snippets")
.append(System.lineSeparator())
.append("endif::[]")
// ๊ตฌ๋ถ์
.append(System.lineSeparator())
.append(System.lineSeparator())
// Ascii Docs ์์
.append(":doctype: book")
.append(System.lineSeparator())
.append(":icons: font")
.append(System.lineSeparator())
.append(":source-highlighter: rouge")
.append(System.lineSeparator())
.append(":table-caption!:")
.append(System.lineSeparator())
.append("= \uD83D\uDE80 API")
.append(System.lineSeparator())
.append(":toc: left")
.append(System.lineSeparator())
.append(":toc-title: \uD83D\uDE80 API")
.append(System.lineSeparator())
.append(":toclevels: 4")
.append(System.lineSeparator())
.append(":sectlinks:")
// ๊ตฌ๋ถ์
.append(System.lineSeparator())
.append(System.lineSeparator())
// ๊ณตํต header
buildDocsCommonHeader(stringBuilder)
// ๊ณตํต responseCode
buildDocsCommonResponseCode(stringBuilder)
// API ๋ชฉ๋ก
DocsApiType.entries
.forEach { type ->
stringBuilder
.append(buildHeader(type.description, 2))
.append(System.lineSeparator())
DocsApi.entries.filter { it.docsApiType == type }
.filter { File("$snippetPath${it.path}").exists() }
.forEach { api ->
stringBuilder
.append(buildHeader(api.title, 3))
.append(System.lineSeparator())
.append(".๐ ${api.description}")
.append(System.lineSeparator())
.append(System.lineSeparator())
stringBuilder
.append(System.lineSeparator())
.append(System.lineSeparator())
buildURITemplate(stringBuilder, api.path)
stringBuilder
.append("{nbsp} +")
.append(System.lineSeparator())
.append(buildHighlight("Request"))
.append(System.lineSeparator())
.append(System.lineSeparator())
buildCurlRequestTemplate(stringBuilder, api.path)
buildPathParameterTemplate(stringBuilder, api.path)
buildRequestFieldsTemplate(stringBuilder, api.path)
buildRequestSampleTemplate(stringBuilder, api.path)
stringBuilder
.append("{nbsp} +")
.append(System.lineSeparator())
.append(buildHighlight("Response"))
.append(System.lineSeparator())
.append(System.lineSeparator())
buildResponseFieldsTemplate(stringBuilder, api.path)
buildResponseSampleTemplate(stringBuilder, api.path)
}
}
try {
val fileOutputStream = FileOutputStream(docs)
fileOutputStream.write(stringBuilder.toString().toByteArray())
fileOutputStream.close()
} catch (exception: Exception) {
exception.printStackTrace()
}
}
private fun buildDocsCommonHeader(stringBuilder: StringBuilder): StringBuilder {
stringBuilder
.append("== ๊ณตํต headers")
.append(System.lineSeparator())
.append("[%autowidth]")
.append(System.lineSeparator())
.append("|===")
.append(System.lineSeparator())
.append("|Name|Description|optional")
.append(System.lineSeparator())
.append(System.lineSeparator())
.append("\t|*Authorization*")
.append(System.lineSeparator())
.append("\t|JWT accessToken")
.append(System.lineSeparator())
.append("\t|")
.append(System.lineSeparator())
.append(System.lineSeparator())
.append("|===")
.append(System.lineSeparator())
.append(System.lineSeparator())
return stringBuilder
}
private fun buildDocsCommonResponseCode(stringBuilder: StringBuilder): StringBuilder {
stringBuilder
.append("== ๊ณตํต ResponseCode")
.append(System.lineSeparator())
.append("[%autowidth]")
.append(System.lineSeparator())
.append("|===")
.append(System.lineSeparator())
.append("|Code|Description")
.append(System.lineSeparator())
.append(System.lineSeparator())
ResponseCode.entries.forEach {
stringBuilder
.append("\t|*${it.code}*")
.append(System.lineSeparator())
.append("\t|${it.message}")
.append(System.lineSeparator())
.append(System.lineSeparator())
}
stringBuilder
.append(System.lineSeparator())
.append(System.lineSeparator())
.append("|===")
.append(System.lineSeparator())
.append(System.lineSeparator())
return stringBuilder
}
private fun buildHeader(text: String, depth: Int): String {
return "=".repeat(depth) + " " + text
}
private fun buildHighlight(text: String): String {
return "*$text*"
}
private fun buildTemplateTitle(text: String): String {
return ".$text"
}
private fun buildTemplate(path: String, template: String): String {
return "include::$path/$template[]"
}
private fun isTemplateExist(snippetDir: String, template: String): Boolean {
return File(snippetDir + template).exists()
}
private fun buildURITemplate(stringBuilder: StringBuilder, directoryName: String): StringBuilder{
val apiPath = "$snippetDefaultPathParameter$directoryName"
val snippetDir = "$snippetPath${directoryName}/"
if (isTemplateExist(snippetDir, URITemplate)) {
stringBuilder.append(File(snippetDir + URITemplate).readText())
stringBuilder.append(" $defaultUri$directoryName HTTP/1.1")
stringBuilder.append(System.lineSeparator())
stringBuilder.append(System.lineSeparator())
stringBuilder.append("----")
stringBuilder.append(System.lineSeparator())
stringBuilder.append(System.lineSeparator())
}
return stringBuilder
}
private fun buildPathParameterTemplate(stringBuilder: StringBuilder, directoryName: String): StringBuilder {
val snippet = "$snippetDefaultPathParameter$directoryName"
val snippetDir = "$snippetPath${directoryName}/"
if (isTemplateExist(snippetDir, pathParameterTemplate)) buildTemplate(
stringBuilder,
"path parameter",
snippet,
pathParameterTemplate
)
return stringBuilder
}
private fun buildRequestFieldsTemplate(stringBuilder: StringBuilder, directoryName: String): StringBuilder {
val apiPath = "$snippetDefaultPathParameter$directoryName"
val snippetDir = "$snippetPath${directoryName}/"
if (isTemplateExist(snippetDir, requestFieldsTemplate)) buildTemplate(
stringBuilder,
"request body",
apiPath,
requestFieldsTemplate
)
return stringBuilder
}
private fun buildRequestSampleTemplate(stringBuilder: StringBuilder, directoryName: String): StringBuilder {
val apiPath = "$snippetDefaultPathParameter$directoryName"
val snippetDir = "$snippetPath${directoryName}/"
if (isTemplateExist(snippetDir, httpRequestTemplate)) buildSampleTemplate(
stringBuilder,
"sample",
apiPath,
httpRequestTemplate
)
return stringBuilder
}
private fun buildResponseFieldsTemplate(stringBuilder: StringBuilder, directoryName: String): StringBuilder {
val apiPath = "$snippetDefaultPathParameter$directoryName"
val snippetDir = "$snippetPath${directoryName}/"
if (isTemplateExist(snippetDir, responseFieldsTemplate)) buildTemplate(
stringBuilder,
"response body",
apiPath,
responseFieldsTemplate
)
return stringBuilder
}
private fun buildResponseSampleTemplate(stringBuilder: StringBuilder, directoryName: String): StringBuilder {
val apiPath = "$snippetDefaultPathParameter$directoryName"
val snippetDir = "$snippetPath${directoryName}/"
if (isTemplateExist(snippetDir, httpResponseTemplate)) buildSampleTemplate(
stringBuilder,
"sample",
apiPath,
httpResponseTemplate
)
return stringBuilder
}
private fun buildCurlRequestTemplate(stringBuilder: StringBuilder, directoryName: String): StringBuilder {
val apiPath = "$snippetDefaultPathParameter$directoryName"
val snippetDir = "$snippetPath${directoryName}/"
if (isTemplateExist(snippetDir, curlRequestTemplate)) buildTemplate(
stringBuilder,
"curl",
apiPath,
curlRequestTemplate
)
return stringBuilder
}
private fun buildTemplate(stringBuilder: StringBuilder, title: String, apiPath: String, template: String) {
stringBuilder
.append(buildTemplateTitle(title))
.append(System.lineSeparator())
.append(System.lineSeparator())
.append(buildTemplate(apiPath, template))
.append(System.lineSeparator())
.append(System.lineSeparator())
}
private fun buildSampleTemplate(stringBuilder: StringBuilder, title: String, apiPath: String, template: String) {
stringBuilder
.append(buildTemplateTitle(title))
.append(System.lineSeparator())
.append(System.lineSeparator())
.append("[%collapsible]")
.append(System.lineSeparator())
.append("====")
.append(System.lineSeparator())
.append(buildTemplate(apiPath, template))
.append(System.lineSeparator())
.append("====")
.append(System.lineSeparator())
.append(System.lineSeparator())
}
}Usage
- given-willReturn์ผ๋ก Controller์ 1:1 ๋งคํ๋ service Mock ์ ์
- mockMvc perform์ผ๋ก Docs๋ฅผ ์์ฑํ Controller์ ์์ฒญ.
- andDo๋ก Document ์์ฑ.
- ์์ฑ ์, response ๊ฒฐ๊ณผ ๊ฐ๊ณผ Fields ๋ค์ด 1:1 ๋งคํ ๋์ด์ผ ํจ.
package com.example.kotlinapiserverguide.restDocs.docs
import com.example.kotlinapiserverguide.api.member.controller.MemberController
import com.example.kotlinapiserverguide.api.member.domain.dto.MemberDto
import com.example.kotlinapiserverguide.api.member.service.MemberService
import com.example.kotlinapiserverguide.restDocs.constant.*
import com.example.kotlinapiserverguide.restDocs.constant.DocsFormatter.Companion.datetimeNowToString
import com.example.kotlinapiserverguide.restDocs.docs.base.BaseDocs
import com.example.kotlinapiserverguide.restDocs.infix.means
import com.example.kotlinapiserverguide.restDocs.infix.type
import com.example.kotlinapiserverguide.restDocs.util.RestDocsUtils.Companion.buildDocument
import com.example.kotlinapiserverguide.restDocs.util.RestDocsUtils.Companion.buildResponseFields
import org.junit.jupiter.api.Test
import org.mockito.BDDMockito.*
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.context.annotation.Import
import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders
import org.springframework.restdocs.request.RequestDocumentation.pathParameters
import java.time.LocalDateTime
@WebMvcTest(controllers = [MemberController::class], useDefaultFilters = false)
@Import(MemberController::class)
class MemberControllerDocs : BaseDocs() {
@MockBean
private lateinit var memberService: MemberService
@Test
fun findMember() {
val api = DocsApi.MEMBER_FIND
val username: String = "test"
given(memberService.findMemberDto(username))
.willReturn(
MemberDto(
id = 1L,
username = "test",
name = "test",
phoneNumber = "01011111111",
createdAt = LocalDateTime.now(),
updatedAt = LocalDateTime.now(),
imageUrl = "https://~~.com"
)
)
mockMvc.perform(RestDocumentationRequestBuilders.get(api.path, username))
.andDo(
buildDocument(
documentPath = api.documentPath,
pathParameters("username" means "๋ก๊ทธ์ธ ID"),
buildResponseFields(
"id" type NUMBER means "ํ์ SEQ",
"username" type STRING means "๋ก๊ทธ์ธ ID",
"name" type STRING means "ํ์ ์ด๋ฆ",
"phoneNumber" type STRING means "ํ์ ์ ํ๋ฒํธ" isOptional true,
"createdAt" type STRING means "ํ์ ์์ฑ์ผ์" formattedAs DocsFormatter.DATETIME defaultValue datetimeNowToString(),
"updatedAt" type STRING means "ํ์ ์์ ์ผ์" formattedAs DocsFormatter.DATETIME defaultValue datetimeNowToString(),
"imageUrl" type STRING means "ํ์ ํ๋กํ URL" isOptional true
)
)
)
}
}