BaseDocs

Test๋ฅผ ํ†ตํ•ด์„œ ์ƒ์„ฑ๋˜๋Š” document๋ฅผ ๋งŒ๋“ค๊ธฐ ์œ„ํ•œ ์ดˆ์•ˆ.
๋ชจ๋“  RescDocs ์ƒ์„ฑ์„ ์œ„ํ•œ, TestController๋“ค์€ ํ•ด๋‹น BascDocs.kt๋ฅผ ์ƒ์†๋ฐ›์•„์•ผ ํ•จ.

  • MockMvc ์‚ฌ์šฉ.
  • ๊ถŒํ•œ ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•ด setUp() ์‹œ์ ์— ์œ ์ € ๊ฐ์ฒด ์ฃผ์ž….
  • ํ…Œ์ŠคํŠธ ์ข…๋ฃŒ ํ›„, document ์ƒ์„ฑ์„ ์œ„ํ•ด, end()์‹œ์ ์— ์ƒ์„ฑ function ์‹คํ–‰.
BascDocs.kt
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 ์ฐธ๊ณ .
RescDocsGenerator.kt
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

  1. given-willReturn์œผ๋กœ Controller์™€ 1:1 ๋งคํ•‘๋œ service Mock ์ •์˜
  2. mockMvc perform์œผ๋กœ Docs๋ฅผ ์ƒ์„ฑํ•  Controller์— ์š”์ฒญ.
  3. andDo๋กœ Document ์ƒ์„ฑ.
    1. ์ƒ์„ฑ ์‹œ, response ๊ฒฐ๊ณผ ๊ฐ’๊ณผ Fields ๋“ค์ด 1:1 ๋งคํ•‘ ๋˜์–ด์•ผ ํ•จ.
MemberControllerDocs.kt
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  
                    )  
                )  
            )  
  
    }  
}