Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

코틀린 테스트 - 단위 테스트, 모의 테스트, 통합 테스트 #33

Open
SuyeonChoi opened this issue Nov 3, 2022 · 0 comments

Comments

@SuyeonChoi
Copy link
Owner

SuyeonChoi commented Nov 3, 2022

1. 단위테스트

  • 코틀린으로 테스트를 작성하는 것은 재미있지만 JUnit, Mockito와 같은 기존 Java 라이브러리 및 프레임워크를 사용하여 테스트하기 때문에 Kotlin 다운 코드를 작성하는 것은 어렵다.

  • Kotlin은 읽기 쉽고 간결하다는 장점을 이용하여 단위 테스트를 작성할 수 있다

    • 코틀린은 역따옴표(백틱)로 묶인 함수 이름은 한글과 공백으로 표현할 수 있다(@DisplayName 탈출 가능!)
    • 읽기 쉬운 AssertJ를 사용하더라도 예외 테스트만큼은 JUnit5의 assertThrows 및 assertDoesNotThrows와 같은 제공된 Kotlin 함수를 사용하면 더 간결하다.
        // Java 스타일
        @DisplayName("히원의 비밀번호와 다를 경우 예외가 발생한다")
        @Test
        fun authenticate() {
            val User = createUser()
            assertThatExceptionOfType(UnidentifiedUserException::class.java)
                .isThrownBy { user.authenticate(WRONG_PASSWORD) }
        }
        
        // Kotlin 스타일
        @Test
        fun `회원의 비밀번호와 다를 경우 예외가 발생한다`() {
            val User = createUser()
            assertThrows<UnidentifiedUserException> {
                user.authenticate(WRONG_PASSWORD)
            }
        }
  • 테스트 팩토리

    • 테스트 픽스처를 반환하는 팩토리 함수를 만들 수 있다
    • Kotlin의 기본 인자를 사용하면 빌더 패턴처럼 다양한 경우를 처리할 수 있다
    • 기본 인자와 이름 붙인 인자를 적절히 사용하면 테스트하려는 관심사를 드러내는데 사용할 수 있다
      • 테스트와 관련이 없는 인자에는 합리적인 기본값을 사용해야한다!
    // 팩토리 함수만 보고 어떤 미션을 생성하는지 쉽게 알 수 있다.
    createMission(submittable = true) // 과제 제출물을 제출할 수 있는 과제 
    createMission(submittable = false) // 과제 제출물을 제출할 수 없는 과제 
    createMission(title = "과제1", evaluationId = 1L) // 특정 평가에 대한 과제 
    createMission(hidden = false) // 숨기지 않은 과제 
    
    fun createMission(
        title: String = MISSION_TITLE,
        description: String = MISSION_DESCRIPTION,
        evaluationId: Long = 1L,
        startDateTime: LocalDateTime = START_DATE_TIME,
        endDateTime: LocalDateTime = END_DATE_TIME,
        submittable: Boolean = true,
        hidden: Boolean = false,
        id: Long = 0L
    ): Mission {
        return Mission(title, description, evaluationId, startDateTime, endDateTime, submittable, hidden, id)
    }
  • 테스트 확장 함수

    • 코틀린의 확장 함수를 사용하여 값을 더 쉽게 표현할 수 있다
    • 해당 기능이 어떻게 작동하는지 쉽게 확인 가능하다
    private val Int.ms: Long get() = milliseconds.inWholeMilliseconds
    
        @Test
        "초당 1회로 제한할 경우 1초 간격 요청은 성공한다" {
            val limiter = RateLimiter(1)
            listOf(0.ms, 1000.ms, 2000.ms).forAll {
                shouldNotThrowAny { limiter.acquire(it) }
            }
        }
    
        @Test
        "초당 1회로 제한할 경우 초당 2번 요청하면 실패한다" {
            val limiter = RateLimiter(1).apply {
                acquire(0.ms)
            }
            listOf(100.ms, 500.ms, 900.ms).forAll {
                shouldThrow<ExceededRequestException> { limiter.acquire(it) }
            }
        }
    • 확장함수를 사용하여 검증 부분의 가독상 향상시키기
    private fun ApplicantAndFormResponse.hasKeywordInNameOrEmail(keyword: String): Boolean {
        return name.contains(keyword) || email.contains(keyword)
    }
    
    @Test
    fun `모집에 지원한 지원 정보를 특정 키워드로 조회하면 이름이나 이메일에 해당 키워드가 포함된 지원자를 확인할 수 있다`() {
        val recruitmentId = 1L
        val keyword = "amazzi"
        val actual = applicantService.findAllByRecruitmentIdAndKeyword(recruitmentId, keyword) // 테스트 함수의 내용과 테�스트 이름이 동일!
        assertThat(actual).allMatch {
            it.hasKeywordInNameOrEmail(keyword)
        }
    }
  • 테스트 어설션

    • 주로 JUnit의 Assetions 대신 읽기 쉬운 AssetJ의 Assertions를 사용한다.
    • 여러 Assertions 중 어떤 것을 사용해야할지 혼란스럽다
      • 잘못된 Import로 인해 테스트가 실패할 수 있다
    • 함수와 프로퍼티 중 어느 것을 사용해야할까
      • isNull, isTrue와 같은 유효성 검사를 수행할 때 함수를 사용하냐 프로퍼티를 사용하냐
    • 소프트 어설션을 사용하면 작성해야 하는 코드의 양이 기하 급수적으로 늘어난다
  • Kotest 어설션

    • Kotest 어설션은 간결하고 기존 JUnit 5와 혼용 가능
    • 스마트 캐스트와 같은 Kotlin 기능을 지원받을 수 있다.
      • ex) shouldBeNull, shouldNotBeNull. 한번 스마트 캐스팅이되었다면 굳이 null에 대해 다루지 않아도 된다
    • 사소해보일 수도 있지만 여러 사람과 함께 하는 작업 코드에는 자유로운 표현 방식보다 제한된 표현 방식이 필요할 수 있다.

Kotest

  • Kotlin에는 많은 테스트 라이브러리가 있으며 그중 Kotest가 가장 많이 사용된다
  • Kotlin은 다양한 스타일의 테스트 레이아웃을 제공한다. 이중에서 StringSpec을 사용하면 애너테이션을 사용하지 않아도 된다
// JUnit5
@Test
fun `인증 코드를 생성한다`() {
val authenticationCode = AuthenticationCode(EMAIL)
assertAll(
  { assertThat(authenticationCode.code).isNotNull() }
  { assertThat(authenticationCode.authenticated).isFalse() }
  { assertThat(authenticationCode.createdDateTime).isNotNull }
)
}
// Kotest
class AuthenticationCodeTest : StringSpec({
  "인증 코드를 생성한다" {
      val authenticationCode = AuthenticationCode(EMAIL)
      assertSoftly(authenticationCode) {
          code.shouldNotBeNull()
          authenticated.shouldBeFalse()
          createdDateTime.shouldNotBeNull()
      }
  }

2. 모의 테스트

  • Mockito
    • Mockito는 상속 또는 ByteBuddy를 사용하여 Mock 객체를 생성하지만, final클래스와 final 메서드를 모의할 수 없다
    • Kotlin 확장함수는 Java의 정적 메서드이며 Mockito는 이를 스텁할 수 없다
    • 최상위 함수는 특정 클래스에 속하지 않기 때문에 스텁할 수 없다

MockK

  • DSL 기반의 Kotlin 모의 라이브러리
  • 코드 기반, 애너테이션 기반 등 대부분 Mockito와 동일한 사용 방식
  • MockK를 이용한 연쇄 스터빙 방식
    • MockK로 정적 함수를 모의하지 않고 확장 함수를 스텁하여 내부의 멤버 함수를 스텁하는 방법이 있다. 즉, 실제 확장 함수를 스텁하는 것이 아니라, 내부의 호출되는 메서드를 스텁한다는 것을 주의해야한다. 하지만 도구가 기능을 제공한다고 해서 항상 사용해야 하는 것은 아니다. 가짜 객체를 사용하는 것도 좋은 방법이다

Kotest를 이용한 모의 테스트

  • BDD를 지원하는 BehaviorSpec, DescribeSpec이 존재한다
    • 두 스펙은 중첩 테스트. 테스트 수명 주기를 이해하는 것이 매우 중요
      • 수명 주기에 대해 알아야 어느 시점에 트랜잭션을 롤백해야할지, Mock 객체를 초기화해야할지 경계를 알 수 있다.
  • 테스트가 하나의 문서로 더욱 풍부해진다
  • Kotest의 수명 주기
    • 전체적으로 Given, When, Then을 이루는 모든 것을 beforeSpec, afterSpec으로 관리
    • 각 Given, When, Then은 beforeContainer, afterContainer로 수명 주기 관리
    • Then은 beforeEach, afterEach로 수명 주기 관리

image

  • Junit5 + Mockito
    • JUnit5에서 Given-When-Then을 표현하기 위해 Nested 클래스 사용
    • Mockito의 When은 Kotlin의 예약어라 백틱 사용

스크린샷 2022-11-05 오후 2 15 15

  • Kotest + Mockito
    • 깔끔!
      Given("지원할 수 있는 모집이 있고 특정 회원이 있는 경우") {
          val recruitment = createRecruitment(recruitable = true, id = 1L)
          val userId = 1L
    
          every { recruitmentRepository.findByIdOrNull(any()) } returns recruitment
          every { applicationFormRepository.existsByRecruitmentIdAndUserId(any(), any()) } returns false
          every { applicationValidator.validate(any(), any()) } just Runs
          every { applicationFormRepository.save(any()) } returns createApplicationForm(userId, recruitment.id)
    
          When("특정 회원이 해당 모집에 대한 지원서를 생성하면") {
              val actual = applicationFormService.create(userId, CreateApplicationFormRequest(recruitment.id))
    
              Then("임시 저장된 지원서가 생성된다") {
                  actual.submitted.shouldBeFalse()
              }
          }
      }

Kotest의 격리 모드

  • SingleInstance, InstancePerLeaf, InstancePerTest 세 가지 값이 있다
  • 기본은 SingleInstance이며 테스트 상황에 맞게 격리 모드를 선택한다
    • 예를 들어 테스트 클래스 전체에 모의 객체를 만드는데 비용이 많이 든다면 SingleInstance를 선택하고 clearMocks()을 호출하는 것이 더 나을 수 있다
  • 프로젝트 구성에서 시스템 속성을 통해 전역적으로 설정 가능

3. 통합 테스트

  • Spring 5.2부터 @TestConstructor를 통해 생성자 주입 가능
  • Kotest는 Spring의 통합 테스트를 지원하기 위해 SpringExtension 제공. 별도의 애너테이션(@TestConstructor) 없이 생성자 주입이 가능하며, SpringExtension을 통해서 트랜잭션 롤백 가능

인수 테스트

  • 사용자 관점에서 기능이 올바르게 작동하는지 확인하기 위한 테스트
  • 일반적으로 사용자 스토리에 따라 Given - When - Then 스타일로 작성
  • 연관관계가 복잡할 수록 테스트 픽스처를 만들기가 더 어려워진다

인수 테스트를 위한 DSL

  • 연관 관계를 도메인에 특화된 언어로 표현하고 필요한 데이터가 생성되도록 할 수 있다.
  • 코드를 처음 접하는 사람들의 도메인 학습에 많은 도움이 된다
  • 만드는 사람이 수고로우면 쓰는 사람이 편하고, 만드는 사람이 편하면 쓰는 사람이 수고롭다
// 모집(recruitment)이 생성되기 위해 기술하는 것이 필요한 경우, 그대로 코드로 표현 가능

// 모집을 생성하기 위해 내가 직접 기수를 생성할 수 있음
val recruitment1 = with(client) {
    recruitment {
      title = "웹 백엔드 5기"
      term = term {
          name = "5기"
      }
      startDateTime = createLocalDateTime(2022, 10, 17)
      endDateTime = createLocalDateTime(2022, 10, 24)
    }
}

// 이미 생성된 기수를 넣어줄 수도 있음
val recruitment2 = with(client) {
    recruitment {
      title = "웹 프론트엔드 5기"
      term = 5L
      startDateTime = createLocalDateTime(2022, 10, 17)
      endDateTime = createLocalDateTime(2022, 10, 24)
    }
}

// 모집이 생성될 때 자동으로 기수가 생성되도록 할 수도 있음!
val recruitment3 = client.recruitment()

부록

  • Kover
    • IntelliJ, JaCoCo 에이전트를 사용하는 Kotlin 코드 커버리지 도구
    • Kotlin이 생성한 바이트코드로 측정하면 인라인 함수와 같이 JaCoCo로 측정할 수 없는 영역까지 측정!

결론

  • 테스트의 중요성이 높아짐에 따라 개발 과정에서 많은 테스트 코드가 작성된다. 어쩌면 구현 코드보다 테스트 코드를 더 많이 작성할 수도 있다
  • 기존의 JUnit5를 잘 사용해왔다면, 새로운 도구(Kotest, MockK)로 모두 무조건 전환하는 것이 아닌, 팀의 숙련도와 상황을 고려애해야한다
  • 어설션부터 BDD까지 점진적으로 도입하는 것이 좋다
  • DRY와 DAMP 코드 사이의 균형을 찾자

Ref.
고품격 Kotlin 개발: 테스트 코드를 우아하게 작성하는 방법

@SuyeonChoi SuyeonChoi changed the title Kotest 코틀린 테스트 - 단위 테스트, 모의 테스트, 통합 테스트 Nov 5, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant