ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring + Kotlin]Kotest와 MockK를 활용한 테스트 코드 작성
    프로젝트/미디어 스트리밍 서버 프로젝트 2022. 8. 19. 12:52

    개요

    코틀린과 스프링을 같이 쓰게 되면 junit5를 주로 사용하곤 했습니다.

    하지만 Kotest라는 테스트 프레임워크의 인기가 높아지고 있습니다.

     

    Kotest란?

    확장 Assertions와 통합 Property test를 통해 코틀린을 위한 유연하고 우아한 다중 플랫폼 오픈 소스 테스트 프레임워크입니다.

     

    여러 개의 독립 실행형 하위 프로젝트로 나뉘며, 각 하위 프로젝트는 독립적으로 사용할 수 있습니다.

    kotest를 사용하여 세 가지 프로젝트를 모두 함께 사용할 수 있습니다.

    또는 다른 프로젝트와 함께 선택하여 사용할 수 있습니다.

    예를 들어 Junit과 함께 Assertions library를 사용할 수 있습니다.

     

    코틀린에서 제공하는 코틀린 특화 기능을 지원합니다.

    다양한 Assertions를 kotlin DSL스타일로 제공합니다.

    BDD를 포함한 다양한 Test Layout을 제공합니다.

     

    Kotest를 사용하는 이유는?

    코틀린을 사용하더라도 Junit, Assertion, Mockito 등을 동일하게 사용할 수 있습니다.

     

    하지만 코틀린에 익숙해질수록 테스트 코드 내에서 코틀린 스타일로 코드를 작성할 수 없어 비즈니스 코드와 테스트 코드 간의 괴리가 느껴질 수 있습니다.

     

    코틀린에서는 DSL을 활용한 다양한 코드 스타일을 제공합니다.

    하지만 Junit, Assertions, Mockito를 활용하여 테스트 과정 속에 코틀린 DSL을 활용할 수 없습니다.

     

    하지만 Kotest나 Mockk와 같은 도구를 사용하면 코틀린 스타일의 테스트 코드를 작성할 수 있습니다.

    https://techblog.woowahan.com/5825/

     

    Dependencies

    build.gradle.kst

    dependencies {   
        testImplementation("io.kotest:kotest-runner-junit5-jvm:${KOTEST_VERSION}")
        testImplementation("io.kotest:kotest-assertions-core-jvm:${KOTEST_VERSION}")
    }
    
    tasks.test {
        useJUnitPlatform()
    }

     

    Testing Styles

    Kotest는 여러 Test 스타일을 가지고 있습니다.

     

    StringSepc

    class MyTests : StringSpec({
        "strings.length should return size of string" {
            "hello".length shouldBe 5
        }
    })

     

    SholudSpec

    class MyTests : ShouldSpec({
        should("return the length of the string") {
            "sammy".length shouldBe 5
            "".length shouldBe 0
        }
    })

     

    BehaviorSpec

    class MyTests : BehaviorSpec({
        given("a broomstick") {
            `when`("I sit on it") {
                then("I should be able to fly") {
                    // test code
                }
            }
            `when`("I throw it away") {
                then("it should come back") {
                    // test code
                }
            }
        }
    })

     

    Assertions

    코틀린 DSL 스타일의 간결한 assertions를 제공합니다.

     

    mockK란?

    코틀린에서 moccking 할 수 있도록 제공하는 라이브러리로 자바 진영에서는 Mokito를 많이 사용합니다.

    Dependencies

    dependencies {
        testImplementation("io.mockk:mockk:{$MOCKK_VERSION}")
    }

     

    every, verify 등 다양한 mocking 및 검증 함수를 제공합니다. 

    val car = mockk<Car>()
    
    every { car.drive(Direction.NORTH) } returns Outcome.OK
    
    car.drive(Direction.NORTH) // returns OK
    
    verify { car.drive(Direction.NORTH) }
    
    confirmVerified(car)

     

     

    테스트 튜토리얼 시작

    Kotest, mockK에 대해서 간략하게 느낌만 알아봤습니다.

    공식문서를 기반으로 실제로 어떻게 사용하는지 다루어 보겠습니다.

     

    테스트 dependencies  추가

    testImplementation( "io.kotest:kotest-runner-junit5:5.4.2")
    testImplementation ("io.kotest:kotest-assertions-core:5.4.2")
    testImplementation("io.mockk:mockk:1.12.5")

    버전은 달라질 수 있어서 공식문서에서 release 된 버전을 참고하시면 좋을 것 같습니다.

     

    테스트 튜토리얼 with StringSpec

    class VideoServiceTest : StringSpec({
        "length should return size of String"{
            "hello".length shouldBe 5
        }
    })

     

    테스트 튜토리얼 with FunSpec

    class VideoServiceTest : FunSpec({
        test("my first test"){
            1 + 2 shouldBe 3
        }
        test("my second test"){
            3 + 4 shouldBe 7
        }
    })

     

    테스트 튜토리얼 with BehaviorSpec

    class MyTests : BehaviorSpec({
        given("a broomstick") {
            `when`("I sit on it") {
                then("I should be able to fly") {
                    // test code
                }
            }
            `when`("I throw it away") {
                then("it should come back") {
                    // test code
                }
            }
        }
    })

    단, kotlin에 when 키워드가 존재하기 때문에 백 틱(``)을 활용하여 감싸줘야 합니다.

     

    given when then 패턴을 지원하는 BehaviorSpec에 조금 관심이 생겨 추가적인 테스트 코드를 작성해 보았습니다.

    class MyTest2 : BehaviorSpec({
        beforeEach{
            println("Starting a test ${it.descriptor.id.value}")
        }
        given("simple operation") {
            val firstValue = 1
            val secondValue = 2
            `when`("plus operating") {
                val result = firstValue + secondValue
                then("result is 3") {
                    result shouldBe 3
                }
            }
        }
        given("Exception operation") {
            val firstValue = 1
            val secondValue = 0
            `when`("divide operating") {
                val exception = shouldThrow<ArithmeticException> {
                    firstValue / secondValue
                }
                then("it should exception") {
                    exception.message shouldBe "/ by zero"
                }
            }
        }
    })

    beforeEach를 통해 @BeforeEach와 같은 기능을 사용할 수 있습니다.

     

    beforeEach 출력 결과

    Starting a test result is 3
    Starting a test it should exception

     

    shouldBe를 통해 예상 값이 같은지 검증할 수 있으며 shouldThrow<Exception>을 통해 예외처리를 진행할 수 있습니다.

    - 1+2 = 3이 되어야 합니다.

    - 1/0 은 ArithmeticException이 발생해야 합니다.

     

    Service Layer 단위 테스트 하기

    @Service
    @Transactional
    class VideoService(
        private val s3Client: AmazonS3Client,
        private val videoRepository: VideoRepository,
    ) {
        @Value("\${cloud.aws.s3.bucket}")
        lateinit var bucket: String
    
        @Value("\${cloud.aws.s3.dir}")
        lateinit var dir: String
    
        @Throws(IOException::class)
        fun upload(request: UploadRequest, file: MultipartFile): UploadResponse {
            val fileName = UUID.randomUUID().toString() + "-" + file.originalFilename
            val objMeta = ObjectMetadata()
    
            val bytes = IOUtils.toByteArray(file.inputStream)
            objMeta.contentLength = bytes.size.toLong()
    
            ByteArrayInputStream(bytes).use {
                s3Client.putObject(
                    PutObjectRequest(bucket, dir + fileName, it, objMeta)
                        .withCannedAcl(CannedAccessControlList.PublicRead)
                )
            }
    
            val savedUrl = s3Client.getUrl(bucket, dir + fileName).toString()
            videoRepository.save(Video(request.subject, request.content, savedUrl))
            return UploadResponse(savedUrl)
        }
    
        @Transactional(readOnly = true)
        fun findVideos(pageable: Pageable): PageResponse {
            val result = videoRepository.findAll(pageable)
            return PageResponse.toPageResponse(result)
        }
    
        fun updateVideo(request: UpdateRequest): UpdateResponse {
            val video = videoRepository.findByIdOrNull(request.videoNo) ?: throw IllegalArgumentException()
            video.changeSubject(request.subject)
            video.changeContent(request.content)
            return UpdateResponse(
                video.id!!,
                video.subject,
                video.content,
            )
        }
    
        fun deleteVideo(request: DeleteRequest): DeleteResponse {
            videoRepository.deleteById(request.videoNo)
            return DeleteResponse(request.videoNo)
        }
    }

    테스트해야 하는 코드는 위와 같습니다.

     

    여기서 특징은 s3Client 같은 경우에는 AWS에 의존적입니다.

     

    즉, 테스트하기 힘든 외부라이브러리를 사용하기 때문에 MockK를 사용하여 모킹을 진행해야 합니다.

     

    Servcie Layer를 테스트하고 싶기 때문에 Repository Layer는 모킹해야 합니다.

     

    또한 bucket, dir 같은 경우에도 application.yml에서 가져오는 변수들입니다.

    해당 변수들도 임의의 값을 세팅해줘야 합니다.

     

    결과적으로는 총 4개를 모킹해야 합니다. (s3Client, videoRepositroy, bucket, dir)

     

    upload 메서드에 대한 테스트 코드를 작성해 보겠습니다.

    class VideoServiceTest: BehaviorSpec({
    	given("uploadInfo") {
    		val s3Client = mockk<AmazonS3Client>(relaxed = true)
    		val videoRepository = mockk<VideoRepository>()
    		val videoService = VideoService(s3Client, videoRepository)
    		val videoUrl = "https://hello.txt"
    		videoService.bucket = "lili-server"
    		videoService.dir = "images/"
    		
    		every { s3Client.getUrl(any(), any()) } returns URL(videoUrl)
    		every { videoRepository.save(any()) } returns Video("제목", "내용", videoUrl)
    
    		`when`("videoService upload") {
    			val result = videoService.upload(
    				UploadRequest(
    					"제목",
    					"내용",
    				),
    				MockMultipartFile(
    					"file",
    					"hello.txt",
    					MediaType.MULTIPART_FORM_DATA_VALUE,
    					"file".toByteArray(),
    				)
    			)
    			then("saved url contains origin file name") {
    				result.url shouldContain "hello.txt"
    			}
    		}
    	}
    }
    )

    BehaviorSpec으로 작성한 테스트 코드입니다.

    given절

    mockk<객체>()를 활용하여 Mocking을 진행합니다.

     

    이때 relaxed = true 옵션을 통해 every {...}를 통하여 매번 mock 하는 경우가 번거롭거나, 특별히 확인한 내용이 없는 경우에는 every {...} 없이도 수행할 수 있습니다.

     

    s3Client.putObject()에서 특별히 mocking 하기 번거롭기 때문에 relaxed = true 옵션을 부여하여 처리했습니다.

     

    every {...} returns.. 를 통해 mocking 한 객체의 메서드가 어떤 값을 반환할지 지정합니다.

     

    getUrl 메서드가 반환하는 URL

    public URL getUrl(String bucketName, String key) {
    	...
    }

    getUrl 메서드가 URL객체를 반환하기 때문에 URL객체를 반환값으로 넣어주었습니다.

     

     

    when절

    실제로 videoService.upload()를 수행합니다.

    UploadRequest객체와 MultipartFile객체를 주입시켜줍니다.

    이때 MultipartFile객체는 MockMultipartFile객체를 이용하여 주입합니다.

     

    then절

    실제로 result의 url은 만들어진 videoUrl을 포함하고 있어야 합니다.

    shouldContain을 활용하여 "hello.txt"가 포함되어 있는지 검증합니다.

     

    위와 비슷한 방식으로 모든 메서드의 테스트를 수행한 테스트 코드입니다.

    class VideoServiceTest : BehaviorSpec({
    
        lateinit var s3Client: AmazonS3Client
        lateinit var videoRepository: VideoRepository
        lateinit var videoService: VideoService
    
        fun doMocking() {
            s3Client = mockk<AmazonS3Client>(relaxed = true)
            videoRepository = mockk<VideoRepository>()
            videoService = VideoService(s3Client, videoRepository)
            videoService.bucket = "lili-server"
            videoService.dir = "images/"
        }
    
        given("uploadInfo") {
            doMocking()
            val videoUrl = "https://hello.txt"
            every { s3Client.getUrl(any(), any()) } returns URL(videoUrl)
            every { videoRepository.save(any()) } returns Video("제목", "내용", videoUrl)
    
            `when`("videoService upload") {
                val result = videoService.upload(
                    UploadRequest(
                        "제목",
                        "내용",
                    ),
                    MockMultipartFile(
                        "file",
                        "hello.txt",
                        MediaType.MULTIPART_FORM_DATA_VALUE,
                        "file".toByteArray(),
                    )
                )
    
                then("saved url contains origin file name") {
                    result.url shouldContain "hello.txt"
                }
            }
        }
    
        given("paged video info") {
            doMocking()
            val pageable = PageRequest.of(0, 3)
    
            `when`("return empty Page") {
                every { videoRepository.findAll(pageable) } returns Page.empty()
                val result = videoService.findVideos(pageable)
    
                then("pageable mapped PageResponse DTO") {
                    result.javaClass shouldBe PageResponse::class.java
                    result.contents.size shouldBe 0
                }
            }
        }
    
        given("UpdateRequest") {
            doMocking()
            val updateRequest = UpdateRequest(
                1L,
                "changed",
                "changed",
            )
    
            `when`("update video Info success") {
                every { videoRepository.findByIdOrNull(any()) } returns Video(
                    "origin",
                    "origin",
                    "videoUrl",
                    "imageUrl",
                    1L,
                )
                val result = videoService.updateVideo(updateRequest)
    
                then("subject and content must changed") {
                    result.content shouldBe "changed"
                    result.subject shouldBe "changed"
                }
            }
            `when`("cannot found video") {
                every { videoRepository.findByIdOrNull(any()) } returns null
    
                then("get IllegalArgumentException") {
                    shouldThrow<IllegalArgumentException> {
                        videoService.updateVideo(updateRequest)
                    }
                }
            }
        }
    
        given("DeleteRequest") {
            doMocking()
            val deleteRequest = DeleteRequest(
                1L,
            )
    
            `when`("delete video success") {
                justRun { videoRepository.deleteById(any()) }
                val result = videoService.deleteVideo(deleteRequest)
    
                then("subject and content must changed") {
                    result.videoNo shouldBe 1
                }
            }
            `when`("cannot found video") {
                every { videoRepository.deleteById(any()) } throws IllegalArgumentException()
    
                then("get IllegalArgumentException") {
                    shouldThrow<IllegalArgumentException> {
                        videoService.deleteVideo(deleteRequest)
                    }
                }
            }
        }
    })

    매번 모든객체에서 s3Client, videoRepository, videoService, bucket, dir을 주입하는 반복이 발생합니다.

    따라서 doMoking이라는 메서드를 만들어서 중복을 제거하고자 했습니다.

     

    beforeTest {...} , afterTest {...}를 활용하여 중복으로 모킹 하는 과정을 줄이려고 하였습니다. 

        lateinit var s3Client: AmazonS3Client
        lateinit var videoRepository: VideoRepository
        lateinit var videoService: VideoService
        beforeTest {
            s3Client = mockk<AmazonS3Client>()
            videoRepository = mockk<VideoRepository>()
            videoService = VideoService(s3Client, videoRepository)
            videoService.bucket = "lili-server"
            videoService.dir = "images/"
        }
        afterTest {
            clearAllMocks()
        }

    하지만 이렇게 하면 MockKException : no answer found for: 에러가 발생합니다.

     

     

     

     

     

    출처

    https://mockk.io/

     

    MockK

    Provides DSL to mock behavior. Built from zero to fit Kotlin language. Supports named parameters, object mocks, coroutines and extension function mocking

    mockk.io

     

    https://kotest.io/docs/quickstart/

     

    Quick Start | Kotest

    Kotest is divided into several, stand alone, subprojects, each of which can be used independently:

    kotest.io

    https://techblog.woowahan.com/5825/

     

    스프링에서 코틀린 스타일 테스트 코드 작성하기 | 우아한형제들 기술블로그

    {{item.name}} 안녕하세요 저는 공통시스템개발팀에서 플랫폼 개발을 담당하고 있는 김규남이라고 합니다. 이 글은 올해 사내에서 진행한 코틀린 밋업에서 스프링에서 코틀린 스타일 테스트 코드

    techblog.woowahan.com

    https://velog.io/@jaeyun-jo/Kotest-MockK를-활용한-코틀린-단위-테스트

     

    https://stackoverflow.com/questions/55878805/kotlin-how-to-clean-up-or-reset-mocks-with-junit5-and-mockk

     

    Kotlin: how to clean up or reset mocks with JUnit5 and Mockk

    In some case it's needed to clean up or reset the mocks between tests cases. Using Kotling with JUnit5 and Mockk, a first approach should be like this: class CreateProductsTests { @Test ...

    stackoverflow.com

    https://kapentaz.github.io/test/Kotlin%EC%97%90%EC%84%9C-mock-%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%95%98%EA%B8%B0/#

     

    Kotlin에서 mock 테스트 하기

    예전에는 테스트 코드를 작성하려고 해도 스스로도 익숙하지 않았고 테스트 코드 작성에 대해 호의적이지 않은 사람이 있거나 일정 압박으로 테스트 코드를 작성하다가도 중간에 포기한 적이

    kapentaz.github.io

    https://isntyet.github.io/kotlin/Kotest-%ED%95%B4%EB%B3%B4%EA%B8%B0/

     

    Kotest 해보기

    Kotest

    isntyet.github.io

    https://hackeen.tistory.com/18

     

    [java 네트워크 프로그래밍] 6. URL 클래스

    이 저작물은 크리에이티브 커먼즈 저작자표시-비영리-동일조건변경허락 2.0 대한민국 라이선스에 따라 이용할 수 있습니다. 1. .URL 클래스 1-1 URL 구조 http : //www.naver.com/ URL의 구조는 다음과 같이

    hackeen.tistory.com

    https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.text/to-byte-array.html

     

    toByteArray - Kotlin Programming Language

     

    kotlinlang.org

    https://kukekyakya.tistory.com/549

     

    Spring Boot 파일 업로드 및 테스트(multipart/form-data)

    스프링부트에서 파일과 데이터를 업로드하고, 테스트하는 방법에 대해서 간략히 정리해보겠습니다. 단순히 컨트롤러 단에서 요청을 어떻게 받고, 어떻게 테스트할 것인지에 대해서만 살펴보겠

    kukekyakya.tistory.com

     

    댓글

Designed by Tistory.