๐ Spring Boot + MockMvc ํ ์คํธ
์๋ ํ์ธ์, ์ด๋ฒ ์๊ฐ์ ์ ๋ฆฌํ ๋ด์ฉ์ ์คํ๋ง ๋ถํธ์ MockMvc๋ฅผ ํตํ GET, POST ๋ฑ์ API๋ฅผ ํ ์คํธํ๋ ๋ฒ์ ๋ํด ์ ๋ฆฌํด๋ณด๋๋ก ํ๊ฒ ์ต๋๋ค. ์ ์ฒด ์ฝ๋๋ ๊นํ๋ธ์์ ํ์ธํ์ค ์ ์์ต๋๋ค :)
โป ํฌ์คํ ์ ํ๋ฆฐ ๋ด์ฉ์ด ์กด์ฌํ๋ฉด ์ง์ ๋ถํ๋๋ฆฌ๊ฒ ์ต๋๋ค!
โ MockMvc๋?
MockMvc๋ Spring MVC์ ํ ์คํธ ํ๋ ์์ํฌ๋ก, ๊ฐ์ง ๊ฐ์ฒด๋ฅผ ๋ง๋ค์ด ์ดํ๋ฆฌ์ผ์ด์ ์ ์๋ฒ์ ๋ฐฐํฌํ์ง ์๊ณ ๋ Spring MVC์ ๋์์ ์ฌํํ ์ ์๋ ํด๋์ค์ ๋๋ค.
์ด๋ฅผ ํตํด ๊ฐ์ง์ HTTP ์์ฒญ์ ์ปจํธ๋กค๋ฌ์ ๋ณด๋ด๊ณ ์๋ฒ ๋ด์์ ์ปจํธ๋กค๋ฌ๋ฅผ ์คํํ์ง ์๊ณ ์ปจํธ๋กค๋ฌ์ ๋์์ ํ ์คํธํ ์ ์์ต๋๋ค.
MockMvc๋ฅผ ์ฌ์ฉํจ์ผ๋ก์ ์ค์ ์๋ฒ ํ๊ฒฝ๊ณผ ๋์ผํ @SpringBootTest ์ด๋ ธํ ์ด์ ์ด ์๋ @WebMvcTest ์ด๋ ธํ ์ด์ ์ ํตํด ์์ Controller ๋ ์ด์ด์ ๋ก์ง์ ํ ์คํธ ํ ์ ์์ต๋๋ค.
โ MockMvc ์ฌ์ฉ ์์ - ์์กด์ฑ ์ถ๊ฐ & ๊ฐ์ฒด ์ฝ๋ ์์ฑ
์์ ์ฝ๋๋ Kotlin ๊ธฐ๋ฐ์ผ๋ก ์์ฑ์ ํ์์ต๋๋ค.
1) ๋จผ์ ์์กด์ฑ์ ์ถ๊ฐํฉ๋๋ค.
// gradle
testImplementation("org.springframework.boot:spring-boot-starter-test")
// maven
<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>2.5.4</version>
<scope>test</scope>
</dependency>
2) ํ ์คํธ์ ์ฌ์ฉ๋ ErrorResponse, UserRequest ํด๋์ค์ ์ฝ๋๋ ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
package org.juhyun.kotlinspringboot.model.exception
import com.fasterxml.jackson.annotation.JsonProperty
import java.time.LocalDateTime
/* ErrorResponse API
{
"result_code": "FAIL",
"http_status": "400",
"http_method": "GET",
"message": "์์ฒญ์ ์๋ฌ๊ฐ ๋ฐ์ํ์์ต๋๋ค.",
"path": "/api/exception",
"timestamp": "2021-09-06T16:03:43.556134",
"errors":[
{
"field": "name",
"message": "ํฌ๊ธฐ๊ฐ 2์์ 6 ์ฌ์ด์ฌ์ผ ํฉ๋๋ค",
"value": "LeeJuHyun"
}
]
}
*/
data class ErrorResponse(
@field: JsonProperty("result_code")
var resultCode: String? = null,
@field: JsonProperty("http_status")
var httpStatus: String? = null,
@field: JsonProperty("http_method")
var httpMethod: String? = null,
var message: String? = null,
var path: String? = null,
var timestamp: LocalDateTime? = null,
var errors: MutableList<Error>? = mutableListOf()
)
data class Error(
var field: String? = null,
var message: String? = null,
var value: Any? = null
)
package org.juhyun.kotlinspringboot.model
import org.juhyun.kotlinspringboot.annotation.StringFormatDateTime
import java.lang.Exception
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import javax.validation.constraints.*
import kotlin.math.min
data class UserRequest(
@field: NotEmpty
@field: Size(min = 2, max = 10)
var name: String? = null,
@field: PositiveOrZero // 0๋ณด๋ค ํฐ ์์
var age: Int? = null,
@field: Email
var email: String? = null,
@field: NotBlank
var address: String? = null,
@field: Pattern(regexp = "\\d{3}-\\d{3,4}-\\d{4}\$")
var phoneNumber: String? = null,
@field:StringFormatDateTime(pattern = "yyyy-MM-dd HH:mm:ss", message = "ํจํด์ด ์ฌ๋ฐ๋ฅด์ง ์์ต๋๋ค.")
var createdAt: String? = null // yyyy-MM-dd HH:mm:ss
)
ErrorResponse ํด๋์ค์๋ ์ค๋ค์ดํฌ ์ผ์ด์ค๋ก ์๋ตํ๊ธฐ ์ํด @JsonProperty ์ด๋ ธํ ์ด์ ์ ์ ์ฉํด ์ฃผ์์ต๋๋ค.
UserRequest ํด๋์ค์๋ ๊ฐ ํ๋์๋ํ ์ ํจ์ฑ ๊ฒ์ฆ ์ฝ๋๋ฅผ ์ถ๊ฐํด ์ฃผ์์ต๋๋ค.
โ MockMvc ์ฌ์ฉ ์์ - HTTP GET ํ ์คํธ
3) HTTP GET ํ ์คํธ
HTTP GET API ํ ์คํธ๋ฅผ ์ํด Controller ๋ฐ Test๋ฅผ ์์ฑํ๊ฒ ์ต๋๋ค.
@RestController
@RequestMapping("/api/exception")
@Validated
class ExceptionApiController {
@GetMapping("/hello")
fun hello(): String {
val list = mutableListOf<String?>()
return "Hello"
}
@GetMapping
fun get(
@NotBlank @Size(min = 2, max = 6) @RequestParam name: String,
@Min(10) @RequestParam age: Int): String {
println(name)
println(age)
return "$name $age"
}
...
}
- hello() ๋ฉ์๋: ๋จ์ ๋ฌธ์์ด("Hello") ๋ฆฌํด
- get() ๋ฉ์๋: name, age ํ๋ผ๋ฏธํฐ๋ฅผ ๋ฐ์ผ๋ฉฐ ์ ํจ์ฑ ๊ฒ์ฆ
@WebMvcTest
@AutoConfigureMockMvc
class ExceptionApiControllerTest {
@Autowired
private lateinit var mockMvc: MockMvc
@Test
fun helloTest() {
val uri: String = "/api/exception/hello"
mockMvc.perform(MockMvcRequestBuilders.get(uri))
.andExpect(MockMvcResultMatchers.status().isOk)
.andExpect(MockMvcResultMatchers.content().string("Hello"))
.andDo(MockMvcResultHandlers.print())
}
@Test
fun getTest() {
val uri = "/api/exception"
val queryParams = LinkedMultiValueMap<String, String>()
queryParams.add("name", "JuHyun")
queryParams.add("age", "20")
mockMvc.perform(MockMvcRequestBuilders.get(uri).queryParams(queryParams))
.andExpect(MockMvcResultMatchers.status().isOk)
.andExpect(MockMvcResultMatchers.content().string("JuHyun 20"))
.andDo(MockMvcResultHandlers.print())
}
@Test
fun ์ด๋ฆ์_2๊ธ์์์_6๊ธ์_์ฌ์ด์ฌ์ผํ๋ค() {
val uri = "/api/exception"
val queryParams = LinkedMultiValueMap<String, String>()
queryParams.add("name", "JuHyunJuHyun")
queryParams.add("age", "11")
mockMvc.perform(MockMvcRequestBuilders.get(uri).queryParams(queryParams))
.andExpect(MockMvcResultMatchers.status().isBadRequest)
.andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON))
.andDo(MockMvcResultHandlers.print())
}
@Test
fun ๋์ด๋_10๋ณด๋ค_์ปค์ผํ๋ค() {
var uri = "/api/exception"
val queryParams = LinkedMultiValueMap<String, String>()
queryParams.add("name", "JuHyun")
queryParams.add("age", "9")
mockMvc.perform(MockMvcRequestBuilders.get(uri).queryParams(queryParams))
.andExpect(MockMvcResultMatchers.status().isBadRequest)
.andExpect(MockMvcResultMatchers.jsonPath("\$.result_code").value("FAIL"))
.andExpect(MockMvcResultMatchers.jsonPath("\$.errors[0].field").value("age"))
.andExpect(MockMvcResultMatchers.jsonPath("\$.errors[0].value").value("9"))
.andDo(MockMvcResultHandlers.print())
}
perform()
- mock ๊ฐ์ฒด์ ์์ฒญ์ ์ ์กํ๋ ์ญํ ์ ํ๋ฉฐ ๊ฒฐ๊ณผ๋ก ResultActions ์ธํฐํ์ด์ค๋ฅผ ๋ฐ์ต๋๋ค.
- ResultActions ์ธํฐํ์ด์ค๋ ๋ฆฌํด ๊ฐ์ ๊ฒ์ฆํ๊ณ ํ์ธํ ์ ์๋ andExpect(), ์ํํ๋ andDo(), ๊ฒฐ๊ณผ๋ฅผ ๋ฆฌํดํ๋ andReturn() ๋ฉ์๋๋ฅผ ์ ๊ณตํฉ๋๋ค.
- ํ๋ผ๋ฏธํฐ๋ RequestBuilder ์ธํฐํ์ด์ค๋ฅผ ๋ฐ์ผ๋ฉฐ ์ด๋ฅผ ์ํด ์ ์ ํฉํ ๋ฆฌ ๋ฉ์๋์ธ MockMvcRequestBuilders ์ถ์ ํด๋์ค๊ฐ ์กด์ฌํฉ๋๋ค.
RequestBuilders๋ฅผ ์์ & ๊ตฌํํ๊ณ ์๋ ์ธํฐํ์ด์ค, ํด๋์ค๋ ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
- SmartRequestBuilder
- ConfigurableSmartRequestBuilder
- MockHttpServletRequestBuilder
- ...
์ ์ ํฉํ ๋ฆฌ ๋ฉ์๋๋ฅผ ๊ฐ์ง๊ณ ์๋ MockMvcRequestBuilders ํด๋์ค์ ๋ฉ์๋๋ MockHttpServletRequestBuilder ํด๋์ค๋ฅผ
๋ฆฌํดํ๊ณ ์๊ธฐ ๋๋ฌธ์ ๋ค์๊ณผ ๊ฐ์ด perform() ๋ฉ์๋์ ํ๋ผ๋ฏธํฐ๋ก MockMvcRequestBuilders ํด๋์ค๋ฅผ ์ฌ์ฉํ ์ ์์ต๋๋ค.
andExpect()
- perform() ์์ฒญ์ ๋ํ ๊ฒฐ๊ณผ๋ฅผ ๊ฒ์ฆํฉ๋๋ค.
- ํ๋ผ๋ฏธํฐ๋ ResultMatcher ์ธํฐํ์ด์ค๋ฅผ ๋ฐ์ผ๋ฉฐ ์ด๋ฅผ ์ํด ์ ์ ํฉํ ๋ฆฌ ๋ฉ์๋์ธ MockMvcResultMatchers ์ถ์ ํด๋์ค๊ฐ ์กด์ฌํฉ๋๋ค.
andDo()
- ๊ฒฐ๊ณผ์ ๋ํด ํน์ ์์ ์ ์ํํฉ๋๋ค.
- ํ๋ผ๋ฏธํฐ๋ ResultHandler ์ธํฐํ์ด์ค๋ฅผ ๋ฐ์ผ๋ฉฐ ์ด๋ฅผ ์ํด ์ ์ ํฉํ ๋ฆฌ ๋ฉ์๋์ธ MockMvcResultHandlers ์ถ์ ํด๋์ค๊ฐ ์กด์ฌํฉ๋๋ค.
get()
- ๊ฒฝ๋ก์ ํด๋นํ๋ String ํน์ URI๋ฅผ ํ๋ผ๋ฏธํฐ๋ก ๋ฐ์ต๋๋ค.
queryParams()
- ์ฟผ๋ฆฌ ํ๋ผ๋ฏธํฐ๊ฐ ํ์ํ ์์ฒญ์ ๊ฒฝ์ฐ ํด๋น ๋ฉ์๋๋ฅผ ์ฌ์ฉํ๋ฉฐ MultiValueMap<>์ ํ๋ผ๋ฏธํฐ๋ก ๋ฐ์ต๋๋ค.
- ๋น์ทํ ๋ฉ์๋๋ก param(), params(), queryParam() ๋ฑ์ ๋ฉ์๋๊ฐ ์์ต๋๋ค.
jsonPath()
- Response Body์ ๋ฐ์ดํฐ๋ฅผ ๊ฒ์ฆํ ์ ์๋ ๋ฉ์๋ ์ ๋๋ค.
์ ์ฌ์ง์ ์ด๋ฆ์_2๊ธ์์์_6๊ธ์_์ฌ์ด์ฌ์ผํ๋ค() ๋ฉ์๋์ ํ ์คํธ ๊ฒฐ๊ณผ ์ ๋๋ค.
โ MockMvc ์ฌ์ฉ ์์ - HTTP POST ํ ์คํธ
4) HTTP-POST ํ ์คํธ
HTTP POST API๋ฅผ ํ ์คํธํด๋ณด๊ฒ ์ต๋๋ค.
@RestController
@RequestMapping("/api/exception")
@Validated
class ExceptionApiController {
...
@PostMapping
fun post(@Valid @RequestBody userRequest: UserRequest): UserRequest {
println(userRequest)
return userRequest
}
@ExceptionHandler(ConstraintViolationException::class)
fun constraintViolationException(e: ConstraintViolationException, request: HttpServletRequest): ResponseEntity<ErrorResponse>? {
// 1. ์๋ฌ ๋ถ์
val errors = mutableListOf<Error>()
e.constraintViolations.forEach {
val error = Error().apply {
this.field = it.propertyPath.last().name
this.message = it.message
this.value = it.invalidValue
}
errors.add(error)
}
// 2. ErrorResponse
val errorResponse = getErrorResponse(request, errors)
// ResponseEntity
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse)
}
private fun getErrorResponse(request: HttpServletRequest, errors: MutableList<Error>): ErrorResponse {
return ErrorResponse().apply {
this.resultCode = "FAIL"
this.httpStatus = HttpStatus.BAD_REQUEST.value().toString()
this.httpMethod = request.method
this.message = "์์ฒญ์ ์๋ฌ๊ฐ ๋ฐ์ํ์์ต๋๋ค."
this.path = request.requestURI.toString()
this.timestamp = LocalDateTime.now()
this.errors = errors
}
}
Controller์์ post ๋ฉ์๋๋ UserRequest ๊ฐ์ฒด๋ง ํ๋ผ๋ฏธํฐ๋ก ๋ฐ์ ์ถ๋ ฅํ ํ ๋ฆฌํดํ๋ ๋ฉ์๋์ ๋๋ค.
@WebMvcTest // ๊ธ๋ก๋ฒ ์์ธ ์ฒ๋ฆฌ๋ ํฌํจ X
@AutoConfigureMockMvc
class ExceptionApiControllerTest {
@Test
fun postTest() {
val userRequest = UserRequest().apply{
this.name = "JuHyun"
this.age = 20
this.phoneNumber = "010-1111-2222"
this.address = "์์ธ"
this.email = "a@a.com"
this.createdAt = "2021-09-06 21:11:12"
}
// JSON ํ์์ ๋ฌธ์์ด๋ก ๋ณํ
val json = jacksonObjectMapper().writeValueAsString(userRequest)
println(json)
val uri = "/api/exception"
mockMvc.perform(
MockMvcRequestBuilders.post(uri)
.content(json)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON))
.andExpect(MockMvcResultMatchers.status().isOk)
.andExpect(MockMvcResultMatchers.jsonPath("\$.name").value("JuHyun"))
.andExpect(MockMvcResultMatchers.jsonPath("\$.age").value("20"))
.andExpect(MockMvcResultMatchers.jsonPath("\$.address").value("์์ธ"))
.andDo(MockMvcResultHandlers.print())
}
val json = jacksonObjectMapper().writeValueAsString(userRequest)
- ์ ๋ฉ์๋๋ ๋ฌธ์์ด์ JSON ํ์์ผ๋ก ๋ณํํด์ฃผ๋ ๋ฉ์๋์ ๋๋ค.
- println(json) ์ ๊ฒฐ๊ณผ๋ ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
content()
- Request Body์ ๋ฌธ์์ด์ ์ค์ ํฉ๋๋ค.
postTest() ๋ฉ์๋์ ๋ํ ๊ฒฐ๊ณผ์ ๋๋ค.
์ ์์ ์์๋ ์์ฑํ์ง ์์์ง๋ง MockMvcRequestBuilders, MockMVcResultMatchers ํด๋์ค์ ๋ชจ๋ ๋ฉ์๋๋ค์ ์ ๋ถ ์ ์ ๋ฉ์๋์ด๊ธฐ ๋๋ฌธ์ ๋ค์๊ณผ ๊ฐ์ด ํด๋์ค๋ฅผ ์๋ตํ ์ ์์ต๋๋ค.
@WebMvcTest // ๊ธ๋ก๋ฒ ์์ธ ์ฒ๋ฆฌ๋ ํฌํจ X
@AutoConfigureMockMvc
class ExceptionApiControllerTest {
@Test
fun postTest() {
val userRequest = UserRequest().apply{
this.name = "JuHyun"
this.age = 20
this.phoneNumber = "010-1111-2222"
this.address = "์์ธ"
this.email = "a@a.com"
this.createdAt = "2021-09-06 21:11:12"
}
// JSON ํ์์ ๋ฌธ์์ด๋ก ๋ณํ
val json = jacksonObjectMapper().writeValueAsString(userRequest)
println(json)
val uri = "/api/exception"
mockMvc.perform(
post(uri)
.content(json)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(jsonPath("\$.name").value("JuHyun"))
.andExpect(jsonPath("\$.age").value("20"))
.andExpect(jsonPath("\$.address").value("์์ธ"))
.andDo(print())
}
โ ์ ๋ฆฌ
์ด์์ผ๋ก Spring Boot์์ MockMvc๋ฅผ ์ฌ์ฉํ๋ ๋ฐฉ๋ฒ์ ๋ํด ๊ฐ๋ตํ ์ ๋ฆฌ๋ฅผ ํด๋ณด์์ต๋๋ค.
์์ ์์๋ ์์ฑํ์ง ์์ PUT, DELETE ๋ฉ์๋๋ ์์ ๋น์ทํ ๋ฐฉ์์ผ๋ก ํ ์คํธ๋ฅผ ํ ์ ์์ต๋๋ค.
๋ํ ํฌ์คํ ์์๋ ์ฌ์ฉํ์ง ์์ ๋ฉ์๋๋ ๋ง์ด ์กด์ฌํ๊ธฐ ๋๋ฌธ์ ์ถ๊ฐ์ ์ธ ๋ด์ฉ์ ์๋ ๊ณต์๋ฌธ์ ๋งํฌ๋ฅผ ์ฐธ๊ณ ํด์ฃผ์ธ์
โ References
- https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/test/web/servlet/MockMvc.html
- https://shinsunyoung.tistory.com/52
- https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-%EC%BD%94%ED%8B%80%EB%A6%B0/dashboard
- https://ktko.tistory.com/entry/%EC%8A%A4%ED%94%84%EB%A7%81Spring-MockMvc-%ED%85%8C%EC%8A%A4%ED%8A%B8
'Spring' ์นดํ ๊ณ ๋ฆฌ์ ๋ค๋ฅธ ๊ธ
[Spring] - ๋ก๊น : Log4j, Log4j2, Slf4j, Logback (0) | 2021.09.15 |
---|---|
Spring Validation - @NotNull, @NotEmpty, @NotBlank (0) | 2021.09.09 |
[Spring] - @JsonProperty, @JsonNaming (2) | 2021.09.02 |
Spring AOP - (1) ํ๋ก์ ํจํด, ๋ฐ์ฝ๋ ์ดํฐ ํจํด (0) | 2021.08.27 |
Spring Boot Maven profile ์ด์ & ๊ฐ๋ฐ DB ๋ถ๋ฆฌ(AWS EC2) (6) | 2021.06.18 |
๋๊ธ