From 68a0724e161ea8d3dfbc0844eeefe9b4525b5564 Mon Sep 17 00:00:00 2001 From: pricelees Date: Mon, 7 Jul 2025 00:43:07 +0900 Subject: [PATCH] add application code with E2E tests --- build.gradle | 48 --------- build.gradle.kts | 57 ++++++++++ .../com/sangdol/validation/DemoController.kt | 40 +++++++ .../kotlin/com/sangdol/validation/DemoDTO.kt | 28 +++++ .../validation/DemoExceptionHandler.kt | 40 +++++++ .../com/sangdol/validation/DemoService.kt | 26 +++++ .../validation/ValidationApplication.kt | 11 ++ src/main/resources/application.properties | 1 + src/main/resources/http/list.http | 3 - .../sangdol/validation/DemoControllerTest.kt | 100 ++++++++++++++++++ 10 files changed, 303 insertions(+), 51 deletions(-) delete mode 100644 build.gradle create mode 100644 build.gradle.kts create mode 100644 src/main/kotlin/com/sangdol/validation/DemoController.kt create mode 100644 src/main/kotlin/com/sangdol/validation/DemoDTO.kt create mode 100644 src/main/kotlin/com/sangdol/validation/DemoExceptionHandler.kt create mode 100644 src/main/kotlin/com/sangdol/validation/DemoService.kt create mode 100644 src/main/kotlin/com/sangdol/validation/ValidationApplication.kt create mode 100644 src/main/resources/application.properties delete mode 100644 src/main/resources/http/list.http create mode 100644 src/test/kotlin/com/sangdol/validation/DemoControllerTest.kt diff --git a/build.gradle b/build.gradle deleted file mode 100644 index 243f27a..0000000 --- a/build.gradle +++ /dev/null @@ -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() -} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..2b67d1a --- /dev/null +++ b/build.gradle.kts @@ -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")) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/sangdol/validation/DemoController.kt b/src/main/kotlin/com/sangdol/validation/DemoController.kt new file mode 100644 index 0000000..ac0d4ad --- /dev/null +++ b/src/main/kotlin/com/sangdol/validation/DemoController.kt @@ -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 { + demoService.doSomething(request) + + return ResponseEntity.ok().build() + } + + @PostMapping("/wrapper") + fun wrapper( + @RequestBody @Valid request: WrapperRequest + ): ResponseEntity { + demoService.doSomething(request) + + return ResponseEntity.ok().build() + } + + @PostMapping("/primitive") + fun primitive( + @RequestBody @Valid request: PrimitiveRequest + ): ResponseEntity { + demoService.doSomething(request) + + return ResponseEntity.ok().build() + } +} diff --git a/src/main/kotlin/com/sangdol/validation/DemoDTO.kt b/src/main/kotlin/com/sangdol/validation/DemoDTO.kt new file mode 100644 index 0000000..5eb140c --- /dev/null +++ b/src/main/kotlin/com/sangdol/validation/DemoDTO.kt @@ -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 +) diff --git a/src/main/kotlin/com/sangdol/validation/DemoExceptionHandler.kt b/src/main/kotlin/com/sangdol/validation/DemoExceptionHandler.kt new file mode 100644 index 0000000..e570abe --- /dev/null +++ b/src/main/kotlin/com/sangdol/validation/DemoExceptionHandler.kt @@ -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 { + log.warn { "${e.javaClass} 발생!" } + + return ResponseEntity.badRequest() + .body( + ErrorResponse( + e.javaClass.toString(), + e.message ?: "Empty Messages" + ) + ) + } + + @ExceptionHandler(Exception::class) + fun handleException(e: Exception): ResponseEntity { + log.error { "${e.javaClass} 발생!" } + + return ResponseEntity.internalServerError().body( + ErrorResponse( + e.javaClass.toString(), + "Internal Server Error" + ) + ) + } +} diff --git a/src/main/kotlin/com/sangdol/validation/DemoService.kt b/src/main/kotlin/com/sangdol/validation/DemoService.kt new file mode 100644 index 0000000..32e91bf --- /dev/null +++ b/src/main/kotlin/com/sangdol/validation/DemoService.kt @@ -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}" } + } +} diff --git a/src/main/kotlin/com/sangdol/validation/ValidationApplication.kt b/src/main/kotlin/com/sangdol/validation/ValidationApplication.kt new file mode 100644 index 0000000..06b6ad2 --- /dev/null +++ b/src/main/kotlin/com/sangdol/validation/ValidationApplication.kt @@ -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) { + runApplication(*args) +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..28111c3 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.application.name=validation diff --git a/src/main/resources/http/list.http b/src/main/resources/http/list.http deleted file mode 100644 index 6ef9342..0000000 --- a/src/main/resources/http/list.http +++ /dev/null @@ -1,3 +0,0 @@ -GET http://localhost:8080/numbers -Accept: application/json - diff --git a/src/test/kotlin/com/sangdol/validation/DemoControllerTest.kt b/src/test/kotlin/com/sangdol/validation/DemoControllerTest.kt new file mode 100644 index 0000000..2ef97cf --- /dev/null +++ b/src/test/kotlin/com/sangdol/validation/DemoControllerTest.kt @@ -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) + ) + ) +} \ No newline at end of file