Kotlin Test DeepDive

Home

목차


TL;DR;

Kotlin 테스트의 Assertion을 쉽게 도와주는 Kluent를 소개합니다.

UnitTest(Mock Test)Integration Test를 기준으로 나누어 설명합니다.

Common하게 쓰이는 Service LayerUnitTest를 실제 Github Repo 예제를 통해 학습합니다.



Kluent

MarkusAmshove/Kluent

Kluent 는 Kotlin을 위해 특별히 작성된 "Fluent Assertions"라이브러리입니다.

Kotlin 의 Infix-Notations 및 Extension Functions 를 사용하여 JUnit-Assertions에 대한 유창한 래퍼를 제공합니다.

Example

// 자기 자신 assertion
mapper.shouldNotBeNull()
context.getBeansOfType(OpenAPI::class.java).shouldNotBeNull()
context.getBeansOfType(MeterFilter::class.java).shouldNotBeEmpty()

// 비교 assertion, `Using backticks`
"hello" `should be equal to` "hello"
"hello" `should not be equal to` "world"
actual.shouldNotBeNull() shouldBeEqualTo expected



Integration Test

IT Test는 WebTestClient를 이용합니다.

Coffee-Street/strada

Config

WebTestClient를 설정합니다.

@ActiveProfiles("test")
@SpringBootTest(classes = [TestApplication::class], webEnvironment = WebEnvironment.RANDOM_PORT)
class IntegrationTest

@SpringBootApplication
class TestApplication

Example

다음과 같이 api를 직접 호출하여 결과값을 Assertion하며 테스트를 진행할 수 있습니다.

ConsmeWith를 통해 Kluent로 Body값을 Assertion하여 테스트를 진행할 수 있습니다.

@Test
fun `account controller get token`() {
    val accessTokenRequest = AccessTokenRequest("010-1234-1234")

    client.post()
        .uri(AccountController.ACCOUNT_BASE_URL)
        .contentType(MediaType.APPLICATION_JSON)
        .bodyValue(accessTokenRequest)
        .exchange()
        .expectStatus().is2xxSuccessful
        .expectBody<AccessTokenResponse>()
}

@Test
fun `health check`() {
    client.get()
        .uri(HealthCheckController.HEALTH_BASE_URL)
        .exchange()
        .expectStatus().is2xxSuccessful
}

@Test
fun `get ping auth fail, forbidden`() {
    client.get()
        .uri(IndexController.INDEX_BASE_URL)
        .exchange()
        .expectStatus().isForbidden
}

@Test
fun `get ping auth success`() {
    val accessToken = authHelper.getAccessToken()

    client.get()
        .uri(IndexController.INDEX_BASE_URL)
        .header("Authorization", "Bearer $accessToken")
        .exchange()
        .expectStatus().is2xxSuccessful
        .expectBody<String>()
        .consumeWith { result ->
            result.responseBody shouldBeEqualTo "PONG"
            logger.debug { "result=${result.responseBody}" }
        }
}



UnitTest (Mock Test)

MockK

Mockking은 왜할까?

  • mockking에 대한 여러 사용처가 있지만, 여기서는 Service Layer 테스트하는 것을 주로 설명합니다.
  • 테스트 할 시, 통합적인 환경을 테스트하고 싶은 것이 아닌, 특정 부분만 테스트 하고 싶을 경우가 있습니다.
  • 보통 Service Layer가 그에 해당하며, 해당 부분을 제외한 것을 mockking 하여 Service만 테스트하기 위해 사용합니다.
  • 다른 모든 부분을 mockking 하였으니 테스트 자체도 매우 가볍고, CI 또한 구성하기 매우 쉽습니다.
  • 아래에서는 사용 방법을 설명합니다.


Mock DSL Basic

  • 기본적인 사용법입니다.
  • corutine(suspending) 함수의 경우 co를 prefix로 붙여진 함수를 사용합니다.
// 객체 생성
val car = mockk()

// 리턴 값 정의
every { car.drive(Direction.NORTH) } returns Outcome.OK
coEvery { car.drive(Direction.NORTH) } returns Outcome.OK

// 검증
verify { car.drive(Direction.NORTH) }
coVerify { car.drive(Direction.NORTH) }

// 호출 확인
// 모든 호출이 verify...구성에 의해 확인되었는지 검증할 수 있습니다.
confirmVerified(car)


Mock 객체 주입

아래와 같이 mock 객체를 생성합니다.

  • junit5를 기준으로 설명합니다.
  • mockiing 후 추가적으로 객체의 행위를 정의해줘야 합니다.
@BeforeEach
fun initialize() {
	addressBook = mockk()
}

아래 처럼 relaxed mock 을 사용할 수 있습니다.

  • relaxed mock는 모든 함수에 대해 간단한 값을 반환하는 모의 객체입니다.
  • 이를 통해 각 경우에 대한 동작 지정을 건너 뛰고 필요한 것을 스텁 할 수 있습니다.
@BeforeEach
fun initialize() {
	addressBook = mockk(relaxed = true)
}

아래 처럼 mocking을 해제할 수 있습니다.

@AfterEach
fun finalize() {
	unmockkAll()
    // or unmockkObject(MockObj)
}


Mock 행위 정의

함수 리턴 값 정의

  • returns는 컴파일되는 시점의 값을 단 한번만 인식하여 그대로 행하게 됩니다.
every { name } returns "John"

함수 행위 정의

  • justRun 또한 컴파일되는 시점의 값을 단 한번만 인식하여 그대로 행하게 됩니다.
// Unit을 반환합니다.
justRun { obj.sum(any(), 3) }

함수 전체 정의

  • answers를 이용하면 런타임 시 해당 함수를 호출하게 합니다.
every {
    name.makeNickname()
} answers {
    name + "aaaa"
	name
}

Varargs

  • 확장 된 variable 처리가 가능합니다.
every { obj.manyMany(5, 6, *anyVararg()) } returns 4

println(obj.manyMany(5, 6, 1, 7)) // 4
println(obj.manyMany(5, 6, 2, 3, 7)) // 4
println(obj.manyMany(5, 6, 4, 5, 6, 7)) // 4

Hierarchical Mocking

  • 객체 mocking 후 바로 행위를 정의할 수 있습니다.
@BeforeEach
fun initialize() {
	addressBook = mockk() {
    every { contacts } returns listOf(
        mockk {
            every { name } returns "John"
            every { telephone } returns "123-456-789"
            every { address.city } returns "New-York"
            every { address.zip } returns "123-45"
        },
        mockk {
            every { name } returns "Alex"
            every { telephone } returns "789-456-123"
            every { address } returns mockk {
                every { city } returns "Wroclaw"
                every { zip } returns "543-21"
            }
        }
    )
}


Mock Assertion

함수 호출 카운트 확인

  • atLeast, atMost또는 exactly매개 변수를 사용하여 호출 수를 확인할 수 있습니다 .
// all pass
verify(atLeast = 3) { car.accelerate(allAny()) }
verify(atMost  = 2) { car.accelerate(fromSpeed = 10, toSpeed = or(20, 30)) }
verify(exactly = 1) { car.accelerate(fromSpeed = 10, toSpeed = 20) }
verify(exactly = 0) { car.accelerate(fromSpeed = 30, toSpeed = 10) } // means no calls were performed

confirmVerified(car)

함수 호출 순서 확인

  • verifySequence: 호출이 지정된 순서로 발생했는지 확인합니다.
verifySequence {
    obj.sum(1, 2)
    obj.sum(1, 3)
    obj.sum(2, 2)
}

confirmVerified(obj)

시간 초과 확인

  • 동시 작업을 확인하려면 다음을 사용할 수 있습니다 timeout = xxx
mockk {
    every { sum(1, 2) } returns 4

    Thread {
        Thread.sleep(2000)
        sum(1, 2)
    }.start()

    verify(timeout = 3000) { sum(1, 2) }
}


Common Example

Repository Mocking

Apply Mocking UnitTest by wnsgml972 · Pull Request #55 · Coffee-Street/strada

  • 데이터 소스를 Mocking 합니다.
  • 데이터베이스의 행위를 인메모리 상에서 동작할 수 있게 MutableMap을 이용해 행위를 정의합니다.
  • Mocking된 함수의 호출 카운트를 검증하여 Service Logic이 정확하게 동작하는 것을 검증합니다.
class AccountServiceTest : AbstractWebTest() {

    lateinit var sut: UserService

    lateinit var userRepository: UserRepository
    private val userMap = mutableMapOf<String, User>()
    private var capturedId = ""

    @BeforeEach
    fun initialize() {
        // clear test environment
        userMap.clear()
        unmockkAll()

        // set mock test
        userRepository = mockk()
        every {
            userRepository.findById(any())
        } answers {
            Optional.of(userMap[capturedId] ?: throw NotFoundException())
        }

        every {
            userRepository.save(any())
        } answers {
            userMap[capturedId] = User.of(capturedId, true)
            userMap[capturedId] ?: throw NotFoundException()
        }

        every {
            userRepository.findAll()
        } answers {
            userMap.values.toList()
        }

        // set unit test service
        sut = UserService(userRepository)
    }

    @Test
    fun `sign up & get user`() {
        // given: 유저가 아이디를 입력합니다
        capturedId = "010-1234-5678"

        // when: 가입 후 유저를 가져옵니다.
        val user1 = sut.signUp(capturedId)
        val user2 = sut.findById(capturedId)
        val totalUserCount = sut.findAll().size

        // then: 검증합니다
        user1 `should be equal to` user2
        totalUserCount `should be equal to` (1)

        verify(exactly = 1) { userRepository.save(any()) }
        verify(exactly = 1) { userRepository.findById(any()) }
        verify(exactly = 1) { userRepository.findAll() }
        confirmVerified(userRepository)
    }
}