개요

  • Testing 프레임워크로 Kotest 에 대해 소개하고, 간단한 test 코드를 작성해본다.
  • Mockk library를 통해 mocking 객체를 생성 할 수 있다.

특징

  • multi-platform

    • JVM, Javascript and Native 을 지원한다.
    • Gradle 4.6 이상에서, useJUnitPlatform()을 추가한 다음 Kotest junit5 depencency를 추가하기만 하면 된다.
  • 코틀린 DSL 지원

    • 기존에 사용하던 Junit과 AssertJ, Mockito를 사용하면 Mocking이나 Assertion 과정에서 코틀린 DSL 을 활용할 수 있다.
    • Kotest Mockk와 같은 도구들을 사용하면 아래처럼 코틀린 DSLInfix를 사용해 코틀린 스타일의 테스트 코드를 작성할 수 있다.
    • fb86a00a-6ea5-4017-8fbe-58af26dc7f3b.png
  • 다양한, Kotest Testing Styles

    • Fun Spec : ScalaTest
    • Describe Spec : Javascript frameworks and RSpec
    • Should Spec : A Kotest original
    • String Spec : A Kotest original
    • Behavior Spec : BDD frameworks
    • Free Spec : ScalaTest
    • Word Spec : ScalaTest
    • Feature Spec : Cucumber
    • Expect Spec : A Kotest original
    • Annotation Spec : JUnit
  • Conditional tests with enabled flags

    • Kotest는 테스트에 구성 플래그를 설정하여 테스트를 비활성화할 수 있도록 지원한다.
  • Spec ordering

    • 기본적으로 Spec 클래스의 순서는 정의되어 있지 않다.
    • @Order( ) 로 순서를 지정할 수 있다.
    1
    2
    3
    4
    
    @Order(1)
    class FooTest : FunSpec() { }
    @Order(0)
    class BarTest: FunSpec() {}
    

Assertion

  • Assertion 모듈은 상태를 테스트하는 함수의 모음이다.
  • Kotest는 이러한 유형의 상태 Assertion 함수를 Matcher 라고 부른다.

Core Matchers

kotest-assertions-core module에서 제공한다.

General
obj.shouldBe(other)General purpose assertion that the given obj and other are both equal
expr.shouldBeTrue()Convenience assertion that the expression is true. Equivalent to expr.shouldBe(true)
expr.shouldBeFalse()Convenience assertion that the expression is false. Equivalent to expr.shouldBe(false)
shouldThrow<T> { block }General purpose construct that asserts that the block throws a T Throwable or a subtype of T
shouldThrowExactly<T> { block }General purpose construct that asserts that the block throws exactly T
shouldThrowAny { block }General purpose construct that asserts that the block throws a Throwable of any type
shouldThrowMessage(message) { block }Verifies that a block of code throws any Throwable with given message

Inspectors

Inspectors를 사용하면 Collection 요소를 테스트할 수 있다.

  • forAll : asserts every element passes the assertions
  • forNone : asserts no element passes
  • forOne : asserts only a single element passed
  • forAtMostOne : asserts that either 0 or 1 elements pass
  • forAtLeastOne : asserts that 1 or more elements passed
  • forAtLeast(k) : is a generalization that k or more elements passed
  • forAtMost(k) : is a generalization that k or fewer elements passed
  • forAny : is an alias for forAtLeastOne
  • forSome : asserts that between 1 and n-1 elements passed. Ie, if NONE pass or ALL pass then we consider that a failure.
  • forExactly(k) : is a generalization that exactly k elements passed. This is the basis for the implementation of the other methods

Setting

libs.versions.toml

1
2
3
4
5
6
7
kotest = "5.8.0"
mockk = "1.13.8"

kotest-runner = { group = "io.kotest", name = "kotest-runner-junit5", version.ref = "kotest" }
kotest-property = { group = "io.kotest", name = "kotest-property", version.ref = "kotest" }
kotest-extentions-junitxml = { group = "io.kotest", name = "kotest-extensions-junitxml", version.ref = "kotest" }
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }

testing을 수행하는 모듈의 build.gradle.kts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
tasks.withType<Test>().configureEach {
    useJUnitPlatform()
}

tasks.getByName<Test>("test") {
    useJUnitPlatform()
    reports {
        junitXml.required.set(false)
    }
    systemProperty("gradle.build.dir", project.buildDir)
}

dependencies {
    api(libs.coroutines)

    implementation(libs.inject)

    testImplementation(libs.kotest.runner)
    testImplementation(libs.kotest.property)
    testImplementation(libs.kotest.extentions.junitxml)
    testImplementation(libs.mockk)
}

~~test-result.xml 를 얻기 위한 코드

1
2
3
4
5
6
7
8
9
class KoTestConfig : AbstractProjectConfig() {
    override fun extensions(): List<Extension> = listOf(
        JunitXmlReporter(
            includeContainers = false, // don't write out status for all tests
            useTestPathAsName = true, // use the full test path (ie, includes parent test names)
            outputDir = "../build/test-results"
        )
    )
}

구현한 Test 코드

Coroutine Flow throttleFirst 확장 함수 Unit Test

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class CoroutineUtilsKtTest : BehaviorSpec({

    given("throttleFirst 테스트하기 위해, delay(100)인 flow를 생성한다") {
        val testFlow = flow {
            repeat(3) { num ->
                emit(num)
                delay(100)
            }
        }

        `when`("windowDuration 을 400 만큼 주면") {
            val result = mutableListOf<Int>()
            runTest {
                testFlow.throttleFirst(400)
                    .onEach { result.add(it) }
                    .launchIn(this)
            }

            then("result는 [0]이 반환 된다.") { result shouldBe listOf(0) }
        }

        `when`("windowDuration 을 190 만큼 주면") {
            val result = mutableListOf<Int>()
            runTest {
                testFlow.throttleFirst(190)
                    .onEach { result.add(it) }
                    .launchIn(this)
            }

            then("result는 [0,2]이 반환 된다.") { result shouldBe listOf(0, 2) }
        }

        `when`("windowDuration 을 30 만큼 주면") {
            val result = mutableListOf<Int>()
            runTest {
                testFlow.throttleFirst(30)
                    .onEach { result.add(it) }
                    .launchIn(this)
            }

            then("result는 [0,1,2]이 반환 된다.") { result shouldBe listOf(0, 1, 2) }
        }
    }
})

2db9177c-7613-4807-8c2f-7497b378ef36.png

GetPlaylistsUseCase Unit Test, Mocking 사용

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
const val RECENT_PLAYLIST_ID = 0

class GetPlaylistsUseCase @Inject constructor(
    private val playlistRepository: PlaylistRepository
) {
    operator fun invoke(): Flow<List<Playlist>> = combine(
        playlistRepository.getPlaylists(),
        playlistRepository.getRecentPlaylist()
    ) { playlists, recentPlaylist ->
        (playlists + Playlist(
            id = RECENT_PLAYLIST_ID,
            title = "최근 재생 목록",
            thumbnailUrl = recentPlaylist.firstOrNull()?.imageUrl ?: "",
            trackSize = recentPlaylist.size,
        )).sortedBy { it.id }
    }
}

class GetPlaylistsUseCaseTest : BehaviorSpec({
    given("GetPlaylistsUseCase 호출 하는 상황에서") {
        val playlistRepository: PlaylistRepository = mockk()
        val getPlaylistsUseCase = GetPlaylistsUseCase(playlistRepository)
        val dummyRecentMusics = listOf(
            Music(
                id = "odio",
                title = "dis",
                artist = "epicurei",
                imageUrl = "https://duckduckgo.com/?q=dolorum",
                musicUrl = "https://search.yahoo.com/search?p=volutpat"
            ),
            Music(
                id = "quot",
                title = "reque",
                artist = "iuvaret",
                imageUrl = "http://www.bing.com/search?q=efficitur",
                musicUrl = "https://www.google.com/#q=maximus"
            )
        )

        val dummyPlaylists = listOf(
            Playlist(
                id = 7316,
                title = "maiorum",
                thumbnailUrl = "http://www.bing.com/search?q=novum",
                trackSize = 5275
            ),
            Playlist(
                id = 7862,
                title = "dictum",
                thumbnailUrl = "https://duckduckgo.com/?q=commune",
                trackSize = 2537
            )
        )

        `when`("playlistId이 RECENT_PLAYLIST_ID 이라면") {
            every { playlistRepository.getPlaylists() } returns flow { emit(dummyPlaylists) }
            every { playlistRepository.getRecentPlaylist() } returns flow { emit(dummyRecentMusics) }
            val result = getPlaylistsUseCase.invoke().first()
            val excepted = Playlist(
                id = RECENT_PLAYLIST_ID,
                title = "최근 재생 목록",
                thumbnailUrl = "https://duckduckgo.com/?q=dolorum",
                trackSize = 2,
            )

            then("recentMusics 들로 새로운 Playlist를 만들고 합치고, id로 정렬한 새로운 Playlist를 반환한다.") {
                result.first() shouldBe excepted
            }
        }
    }

})

020215f7-ae4e-4866-8849-3f419118b734.png

Reference