add application code with E2E tests

This commit is contained in:
이상진 2025-07-07 00:43:07 +09:00
parent 55856659dd
commit 68a0724e16
10 changed files with 303 additions and 51 deletions

View File

@ -1,48 +0,0 @@
plugins {
id 'org.jetbrains.kotlin.jvm' version '1.9.25'
id 'org.jetbrains.kotlin.plugin.spring' version '1.9.25'
id 'org.springframework.boot' version '3.5.3'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com.sangdol'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin'
implementation 'org.jetbrains.kotlin:kotlin-reflect'
implementation 'io.github.oshai:kotlin-logging-jvm:7.0.3'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.jetbrains.kotlin:kotlin-test-junit5'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
kotlin {
compilerOptions {
freeCompilerArgs.addAll '-Xjsr305=strict'
}
}
tasks.named('test') {
useJUnitPlatform()
}

57
build.gradle.kts Normal file
View File

@ -0,0 +1,57 @@
plugins {
val springBootVersion = "3.5.3"
val springDependencyVersion = "1.1.7"
val kotlinVersion = "2.2.0"
id("org.springframework.boot") version springBootVersion
id("io.spring.dependency-management") version springDependencyVersion
//kotlin plugins
kotlin("jvm") version kotlinVersion
kotlin("plugin.spring") version kotlinVersion
}
group = "com.sangdol"
version = "0.0.1-SNAPSHOT"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks {
test {
useJUnitPlatform()
}
compileKotlin {
compilerOptions {
freeCompilerArgs.add("-Xjsr305=strict")
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
freeCompilerArgs.set(listOf("-Xannotation-default-target=param-property"))
}
}
compileTestKotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
freeCompilerArgs.set(listOf("-Xannotation-default-target=param-property"))
}
}
}

View File

@ -0,0 +1,40 @@
package com.sangdol.validation
import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController
@RestController
class DemoController(
private val demoService: DemoService
) {
@PostMapping("/solution")
fun solution(
@RequestBody @Valid request: SolutionForPrimitive
): ResponseEntity<Void> {
demoService.doSomething(request)
return ResponseEntity.ok().build()
}
@PostMapping("/wrapper")
fun wrapper(
@RequestBody @Valid request: WrapperRequest
): ResponseEntity<Void> {
demoService.doSomething(request)
return ResponseEntity.ok().build()
}
@PostMapping("/primitive")
fun primitive(
@RequestBody @Valid request: PrimitiveRequest
): ResponseEntity<Void> {
demoService.doSomething(request)
return ResponseEntity.ok().build()
}
}

View File

@ -0,0 +1,28 @@
package com.sangdol.validation
import jakarta.validation.constraints.NotNull
data class WrapperRequest(
@NotNull
val withAnnotation: String,
val withoutAnnotation: String
)
data class PrimitiveRequest(
@NotNull
val withAnnotation: Int,
val withoutAnnotation: Int
)
data class SolutionForPrimitive(
@NotNull
val value: Int?
)
data class ErrorResponse(
val type: String,
val message: String
)

View File

@ -0,0 +1,40 @@
package com.sangdol.validation
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.http.ResponseEntity
import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
@RestControllerAdvice
class DemoExceptionHandler(
private val log: KLogger = KotlinLogging.logger {}
) {
@ExceptionHandler(value = [HttpMessageNotReadableException::class, MethodArgumentNotValidException::class])
fun handleHttpMessageNotReadable(e: Exception): ResponseEntity<ErrorResponse> {
log.warn { "${e.javaClass} 발생!" }
return ResponseEntity.badRequest()
.body(
ErrorResponse(
e.javaClass.toString(),
e.message ?: "Empty Messages"
)
)
}
@ExceptionHandler(Exception::class)
fun handleException(e: Exception): ResponseEntity<ErrorResponse> {
log.error { "${e.javaClass} 발생!" }
return ResponseEntity.internalServerError().body(
ErrorResponse(
e.javaClass.toString(),
"Internal Server Error"
)
)
}
}

View File

@ -0,0 +1,26 @@
package com.sangdol.validation
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service
@Service
class DemoService(
private val log: KLogger = KotlinLogging.logger {},
) {
fun doSomething(request: SolutionForPrimitive) {
log.info { "solution for primitive requests: $request" }
log.info { "value: ${request.value!!}" }
}
fun doSomething(request: WrapperRequest) {
log.info { "wrapper requests: $request." }
log.info { "withAnnotation: ${request.withAnnotation}, withoutAnnotation: ${request.withoutAnnotation}" }
}
fun doSomething(request: PrimitiveRequest) {
log.info { "primitive requests: $request." }
log.info { "withAnnotation: ${request.withAnnotation}, withoutAnnotation: ${request.withoutAnnotation}" }
}
}

View File

@ -0,0 +1,11 @@
package com.sangdol.validation
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class ValidationApplication
fun main(args: Array<String>) {
runApplication<ValidationApplication>(*args)
}

View File

@ -0,0 +1 @@
spring.application.name=validation

View File

@ -1,3 +0,0 @@
GET http://localhost:8080/numbers
Accept: application/json

View File

@ -0,0 +1,100 @@
package com.sangdol.validation
import org.hamcrest.CoreMatchers.containsString
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.test.context.bean.override.mockito.MockitoBean
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.ResultActions
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultHandlers.print
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
@WebMvcTest(controllers = [DemoController::class])
@MockitoBean(types = [DemoService::class])
class DemoControllerTest {
@Autowired
private val mockMvc: MockMvc? = null
@DisplayName("Wrapper 타입인 경우 값이 입력되지 않으면 HttpMessageNotReadableException이 발생한다.")
@ParameterizedTest(name = "{0}")
@CsvSource(
value = [
"@NotNull 필드의 값만 입력되는 경우 ; withAnnotation",
"@NotNull이 아닌 필드의 값만 입력되는 경우 ; withoutAnnotation"
],
delimiter = ';'
)
fun except_wrapper_field(testName: String, field: String) {
val endpoint = "/wrapper"
val value = "message"
val body = "{\"$field\": \"$value\"}"
runException(endpoint, body, HttpStatus.BAD_REQUEST.value(), "HttpMessageNotReadableException")
}
@DisplayName("Primitive 타입인 경우 @NotNull 지정과 무관하게 값이 입력되지 않아도 기본값이 들어간다.")
@ParameterizedTest(name = "{0}")
@CsvSource(
value = [
"@NotNull 필드의 값만 입력되는 경우 ; withAnnotation",
"@NotNull이 아닌 필드의 값만 입력되는 경우 ; withoutAnnotation"
],
delimiter = ';'
)
fun except_primitive_field(testName: String, field: String) {
val endpoint = "/primitive"
val value = 10
val body = "{\"$field\": $value}"
run(endpoint, body, HttpStatus.OK.value())
}
@DisplayName("Primitive 타입인 경우 Bean Validation을 위해서는 @NotNull + nullable 필드 지정이 필요하다.")
@ParameterizedTest(name = "{0}")
@CsvSource(
value = [
"""필드가 없는 경우 ; {"notExistField": 10} ; 400""",
"""필드가 Null인 경우 ; {"value": null} ; 400""",
"""필드의 값이 정상인 경우 ; {"value": 10} ; 200"""
], delimiter = ';'
)
fun primitive_validation_with_nullable_field(testName: String, body: String, expectedStatus: Int) {
val endpoint = "/solution"
if (expectedStatus == HttpStatus.OK.value()) {
run(endpoint, body, expectedStatus)
} else {
runException(endpoint, body, expectedStatus, "MethodArgumentNotValidException")
}
}
private fun run(endpoint: String, body: String, expectedStatus: Int): ResultActions = mockMvc!!
.perform(
post(endpoint)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(body)
)
.andExpect(status().`is`(expectedStatus))
.andDo(print())
private fun runException(
endpoint: String,
body: String,
expectedStatus: Int,
expectedExceptionType: String
): ResultActions = run(endpoint, body, expectedStatus)
.andExpect(
jsonPath(
"$.type", containsString(expectedExceptionType)
)
)
}