어 나 갱수.

[Kotlin] Kotest와 MockK를 활용해 SpringBoot에서 단위 테스트 작성하기 본문

카테고리 없음

[Kotlin] Kotest와 MockK를 활용해 SpringBoot에서 단위 테스트 작성하기

김경수 2024. 5. 7. 16:01
728x90

스프링 기반의 프로젝트에서는 코틀린을 사용해도 기존의 프레임워크인 JUnit, Assertion, Mockito를 동일하게 사용할 수 있지만, 
Kotlin환경에서는 Kotlin 스럽게 테스트코드를 짤 수 있도록 돕는 도구들이 존재합니다. (MockK, Kotest)

 

오늘은 MockK과 Kotest를 활용해 SpringBoot에서 단위테스트를 작성하는 방법을 알아보겠습니다.

 

MockK

MockK은 코틀린을 위한 모킹라이브러리입니다.

Mock, 가짜 객체를 만들어 단위테스트를 하던 Mockito에서 코틀린 스타일로 만들어진 것이라고 생각하면 됩니다.

Mockito에서 지원하는 모든 기능에 몇몇 추가 기능을 더한, Kotlin을 위한 모킹 프레임워크입니다.

  • Coroutine 지원도 되고, Object도 모킹 가능하고, Private 메서드도 모킹 가능합니다.
  • Kotlin언어에서 지원하는 모든것을 모킹 할 수 있습니다.

Kotest

스프링에서 코틀린을 사용하면 JUnit 뿐만 아니라 Kotest도 사용할 수 있습니다.

kotlin DSL을 사용해 더 코틀린스러운 테스트를 작성할 수 있습니다.

kotest는 다양한 테스트 스타일을 제공합니다. 특히, 우리가 보통 사용하는 given when then 스타일을 사용할 수 있도록 Behavior Spec을 제공해주기도 합니다.

 

Kotest의 테스트 스타일

저는 프로젝트에서 kotest를 사용하면서 두 가지의 kotest 테스트 스타일을 사용하였습니다.

Behavior Spec과 Describe Spec을 사용하였습니다.

 

UseCase 테스트 스타일 결정

UseCase에 해당하는 코드들은 "Given When Then" 패턴인 BDD를 적용시킨 스타일로 테스트하기로 하였습니다.

  • Given : 주어진 환경
  • When : 행위
  • Then : 기대결과

UseCase 테스트코드에서 Behavior Spec을 사용한 이유는 이런 비즈니스 로직의 복잡성과 다양한 사용자 시나리오를 고려하여서 “Given, When, Then”형식으로 자연스럽게 구현가능한 BehaviorSpec을 사용하였습니다.

class SignUpUseCaseTest: BehaviorSpec({
    val accountRepository = mockk<AccountRepository>()
    val passwordEncoderPort = mockk<PasswordEncoderPort>()
    val authenticationValidator = mockk<AuthenticationValidator>()
    val publisher = mockk<ApplicationEventPublisher>()
    val signUpUseCase = SignUpUseCase(accountRepository, passwordEncoderPort, authenticationValidator, publisher)

    Given("SignUpDto 가 주어질때") {
        val encodePassword = "encodePassword"
        val email = "s22039@gsm.hs.kr"
        val authentication = AnyValueObjectGenerator.anyValueObject<Authentication>("email" to email)
        val account = AnyValueObjectGenerator.anyValueObject<Account>("email" to email)
        val signUpDto = AnyValueObjectGenerator.anyValueObject<SignUpDto>("email" to email)
        val deleteAuthenticationEvent = AnyValueObjectGenerator.anyValueObject<DeleteAuthenticationEvent>("authentication" to authentication)

        every { accountRepository.existsByEmail(signUpDto.email) } returns false
        every { authenticationValidator.verifyAuthenticationByEmail(signUpDto.email) } returns authentication
        every { publisher.publishEvent(deleteAuthenticationEvent) } returns Unit
        every { passwordEncoderPort.passwordEncode(signUpDto.password) } returns encodePassword
        every { accountRepository.save(any()) } returns account

        When("회원가입 요청을 하면") {
            signUpUseCase.execute(signUpDto)

            Then("Account 가 저장이 되어야 한다.") {
                verify(exactly = 1) { accountRepository.save(any()) }
            }
        }

        When("이미 존재하는 email로 요청을 하면") {
            every { accountRepository.existsByEmail(signUpDto.email) } returns true

            Then("AlreadyExistEmailException이 터져야 한다.") {
                shouldThrow<AlreadyExistEmailException> {
                    signUpUseCase.execute(signUpDto)
                }
            }
        }
    }
})

 

위의 코드를 설명해보겠습니다.

Given (주어진 상황)

테스트의 초기 상태를 설정합니다. 여기서는 테스트에 필요한 객체들을 Mock(가짜 객체)으로 생성하고, 필요한 입력값과 초기 설정을 준비합니다. 예를 들어, SignUpDto 객체에 이메일 주소를 설정하고, 가짜 accountRepository가 특정 이메일에 대해 false를 반환하도록 설정하여, 해당 이메일이 아직 등록되지 않았다고 가정합니다.

When (특정 조건이 실행될 때)

실제로 테스트하고자 하는 동작이나 메서드를 실행합니다. 이 경우에는 signUpUseCase.execute(signUpDto) 메서드를 호출하여, 주어진 SignUpDto를 사용해서 회원가입 요청을 시뮬레이션합니다.

Then (그 결과로 기대되는 것)

When 절에서 실행된 동작의 결과를 검증합니다. 기대하는 결과가 실제와 일치하는지 확인하여, 테스트의 성공 여부를 결정합니다. 첫 번째 When-Then 구조에서는 accountRepository.save(any())가 정확히 한 번 호출되었는지를 검증하여, Account 객체가 저장되었는지 확인합니다. 두 번째 When-Then 구조에서는 이미 존재하는 이메일로 회원가입을 시도할 경우 AlreadyExistEmailException 예외가 발생하는지를 검증합니다.

 

Controller 테스트 스타일 결정

HTTP Request/Response를 직접 처리하는 Controller 클래스는, 'Describe-Context-It' 패턴으로 테스트하기로 결정.

 

controller 테스트코드에서 describe를 사용한 이유는 request를 받고 response만하는 비교적 간단한 기능을 수행하기 때문에 describeSpec을 사용하여 테스트코드를 작성하였습니다. 사용자의 동작 결과에 집중하는 controller코드의 특징이 describe spec과 맞다고 판단하였습니다.

 

class AuthControllerTest: DescribeSpec({
    lateinit var mockMvc: MockMvc
    val authDataMapper = mockk<AuthDataMapper>()
    val authCodeDataMapper = mockk<AuthCodeDataMapper>()
    val signUpUseCase = mockk<SignUpUseCase>()

    describe("/api/v2/auth/signup 으로 post 요청을 했을때") {
        val url = "/api/v2/auth/signup"

        context("회원가입 요청이 전달 되면") {
            val signUpHttpRequest = SignUpHttpRequest(
                email = "s22039@gsm.hs.kr",
                password = "gomstest1234!",
                name = "김경수",
                major = Major.SMART_IOT,
                gender = Gender.MAN
            )
            val signUpDto = SignUpDto(
                email = "s22039@gsm.hs.kr",
                password = "gomstest1234!",
                name = "김경수",
                major = Major.SMART_IOT,
                gender = Gender.MAN
            )

            every { authDataMapper.toDto(signUpHttpRequest) } returns signUpDto
            every { signUpUseCase.execute(signUpDto) } returns Unit

            val jsonRequestBody = jacksonObjectMapper().writeValueAsString(signUpHttpRequest)

            it("201 상태코드를 반환한다.") {

                mockMvc.perform(
                    post(url)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(jsonRequestBody)
                )
                    .andExpect(status().`is`(201))
            }
        }
    }
   })

 

describe (설명)

이 부분은 특정 엔드포인트(/api/v2/auth/signup)에 대한 테스트를 그룹화합니다.

이 부분에서는 이 엔드포인트에 POST 요청을 보냈을 때의 행동을 테스트합니다.

context (특정한 상황)

이 부분은 "회원가입 요청이 전달되었을 때"라는 특정 상황 또는 조건을 그룹화합니다. 즉, 회원가입 요청이 정상적으로 이루어졌을 때를 테스트하는 것입니다.

it (기능)

이 부분은 특정 조건을 검증하는 단일 테스트 케이스를 정의합니다. 여기서는 POST 요청을 보내고, 반환된 상태 코드가 201(CREATED)인지 확인합니다.

728x90