๋ณธ๋ฌธ ๋ฐ”๋กœ๊ฐ€๊ธฐ
Develop/KMP

[KMP] Kotest๋กœ ์•Œ์•„๋ณธ Stub vs Fake (ํ…Œ์ŠคํŠธ ๋”๋ธ”)

by bona.com 2025. 3. 22.

๐Ÿ“š<์ฝ”ํ‹€๋ฆฐ ์ฝ”๋ฃจํ‹ด์˜ ์ •์„> ์ฑ…์˜ ๊ฐ€์žฅ ๋งˆ์ง€๋ง‰ ์žฅ์ธ 12์žฅ์—์„œ๋Š” "์ฝ”๋ฃจํ‹ด ๋‹จ์œ„ ํ…Œ์ŠคํŠธ"์— ๋Œ€ํ•ด ๋‹ค๋ฃฌ๋‹ค.

์ด๋•Œ ํ…Œ์ŠคํŠธ ๋”๋ธ”์— ๋Œ€ํ•ด ์„ค๋ช…ํ•ด์ฃผ๋ฉด์„œ ๊ทธ ์ข…๋ฅ˜ ์ค‘ ํ•˜๋‚˜์ธ Stub์„ ๋“ค์–ด์ฃผ์—ˆ๋‹ค. ๊ทธ๋Ÿฌ๋‚˜ ์ฑ…์„ ์ฝ๋˜ ๋‹น์‹œ์˜ ๋‚˜๋Š” ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ์ฒ˜์Œ ์ ‘ํ–ˆ๊ธฐ์— Stub์„ ์™œ ์‚ฌ์šฉํ•˜๋Š”์ง€ ์ดํ•ด๊ฐ€ ๋˜์ง€ ์•Š์•˜๋‹ค. 

 

๊ทธ๋Ÿฌ๋‹ค, KMP ํ”„๋กœ์ ํŠธ์—์„œ kotest๋ฅผ ์ด์šฉํ•ด ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•  ๊ธฐํšŒ๊ฐ€ ์žˆ์—ˆ๋Š”๋ฐ ๊ธฐ์กด์— ์‚ฌ์šฉํ•œ Mockk ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋Š” ๋ฉ€ํ‹ฐํ”Œ๋žซํผ์—์„œ ์‚ฌ์šฉ์ด ๋ถˆ๊ฐ€ํ•˜๊ธฐ์— Stub์œผ๋กœ ๊ตฌํ˜„ํ•ด์•ผ๋งŒ ํ–ˆ์—ˆ๋‹ค. ์ด ๊ณผ์ • ๋•๋ถ„์— Stub์ด ๋ฌด์—‡์ธ์ง€ ์ดํ•ด๊ฐ€ ๋˜์—ˆ๊ณ  ๊ทธ ๊ณผ์ •์„ ์„ค๋ช…ํ•ด๋ณด๋ ค ํ•œ๋‹ค.

 

ํ…Œ์ŠคํŠธ ๋”๋ธ”

์šฐ์„  ํ…Œ์ŠคํŠธ ๋”๋ธ”์— ๋Œ€ํ•ด ๋จผ์ € ์•Œ์•„๋ณด์ž.

ํ…Œ์ŠคํŠธ ๋”๋ธ”์ด๋ž€, ๋‹ค๋ฅธ ๊ฐ์ฒด์™€ ์˜์กด์„ฑ์„ ๊ฐ€์ง„ ๊ฐ์ฒด๋ฅผ ํ…Œ์ŠคํŠธํ•˜๊ธฐ ์œ„ํ•ด ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์œผ๋กœ "๊ฐ์ฒด์— ๋Œ€ํ•œ ๋Œ€์ฒด๋ฌผ"์„ ์˜๋ฏธํ•œ๋‹ค.

<์ฝ”ํ‹€๋ฆฐ ์ฝ”๋ฃจํ‹ด์˜ ์ •์„> 12์žฅ - ์ฝ”๋ฃจํ‹ด ๋‹จ์œ„ ํ…Œ์ŠคํŠธ

์œ„ ๊ทธ๋ฆผ์ฒ˜๋Ÿผ "๊ฐ์ฒด"๋ฅผ ํ…Œ์ŠคํŠธ ํ•˜๊ณ  ์‹ถ์€๋ฐ ์ด "๊ฐ์ฒด"๊ฐ€ "๋‹ค๋ฅธ ๊ฐ์ฒด"์— ์˜์กด์„ฑ์ด ์žˆ๋‹ค๋ฉด ๊ทธ "๋‹ค๋ฅธ ๊ฐ์ฒด"์˜ ๊ตฌ์ฒด์ ์ธ ๊ตฌํ˜„๋„ ์ž‘์„ฑํ•ด ์ค˜์•ผ ํ•œ๋‹ค๋Š” ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.

์ด ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•ด์ฃผ๋Š” ๊ฒƒ์ด ๋ฐ”๋กœ "ํ…Œ์ŠคํŠธ ๋”๋ธ”"์ธ ๊ฒƒ์ด๋‹ค.

 

๋‚ด๊ฐ€ ์ ์šฉํ–ˆ๋˜ "๋“œ๋กœ์ด๋“œ๋‚˜์ด์ธ "์˜ ์ฝ”๋“œ๋ฅผ ์˜ˆ์‹œ๋กœ ๋“ค์–ด๋ณด๊ฒ ๋‹ค. 

@Factory
class GetSponsorsUseCase(
    private val sponsorRepository: SponsorRepository,
) {

    suspend operator fun invoke(): List<Sponsor> =
        sponsorRepository
            .getSponsors()
            .sortedBy { it.grade.priority }
}
interface SponsorRepository {

    suspend fun getSponsors(): List<Sponsor>
}

 

ํ•„์ž๋Š” GetSponsorsUseCase์— ๋Œ€ํ•œ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•˜๊ณ  ์‹ถ์—ˆ๋‹ค.

๊ทธ๋Ÿฌ๋‚˜ GetSponsorsUseCase๋Š” SponsorRepository์— ์˜์กด์„ฑ์ด ์žˆ๋Š” ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ๋‹ค.

์ด ๋•Œ๋ฌธ์— GetSponsorsUseCase๋ฅผ ํ…Œ์ŠคํŠธํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” SponsorRepository์— ๋Œ€ํ•œ ๊ตฌํ˜„์ฒด๊ฐ€ ์žˆ์–ด์•ผ ํ•˜๋Š” ๊ฒƒ์ด๋‹ค.

 

Stub

๊ทธ๋ ‡๋‹ค๋ฉด ์ด์ œ Stub์œผ๋กœ ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•ด ๋ณด์ž. 

Stub ๊ฐ์ฒด๋Š”, ๋ฏธ๋ฆฌ ์ •์˜๋œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋Š” ๋ชจ๋ฐฉ ๊ฐ์ฒด๋กœ ๋ฐ˜ํ™˜๊ฐ’์ด ์—†๋Š” ๋™์ž‘์€ ๊ตฌํ˜„ํ•˜์ง€ ์•Š์œผ๋ฉฐ, ๋ฐ˜ํ™˜๊ฐ’์ด ์žˆ๋Š” ๋™์ž‘๋งŒ ๋ฏธ๋ฆฌ ์ •์˜๋œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋„๋ก ๊ตฌํ˜„ํ•œ๋‹ค.

 

์ฆ‰, GetSponsorsUseCase๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด ๊ทธ ๋ชจ๋ฐฉ ๊ฐ์ฒด๋กœ ๋ฐ˜ํ™˜๊ฐ’์„ ์ž‘์„ฑํ•ด์ฃผ๋Š” ์ž‘์—…์„ ์ง„ํ–‰ํ•˜๋Š” ๊ฒƒ์ด๋‹ค.

 

๊ธฐ์กด์— ์žˆ๋˜ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋Š” ์ด๋žฌ๋‹ค.

private val getSponsorsUseCase: GetSponsorsUseCase = mockk()

 

Mockk ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ํ†ตํ•ด GetSponsorsUseCaseํƒ€์ž…์˜ ๊ฐ€์งœ ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ์žˆ์—ˆ๋‹ค.

 

class StubSponsorRepository(
    private val sponsors: List<Sponsor>
) : SponsorRepository {
    override suspend fun getSponsors(): List<Sponsor> {
        return sponsors
    }
}

 

์ด๋ฅผ Stub์œผ๋กœ ๊ตฌํ˜„ํ•ด์ค€๋‹ค๋ฉด ์œ„ ์ฝ”๋“œ์ฒ˜๋Ÿผ ๋œ๋‹ค.

๋ฐ˜ํ™˜๊ฐ’์ด ์žˆ๋Š” getSponsors ํ•จ์ˆ˜๋ฅผ ๊ตฌํ˜„ํ•ด์ฃผ๊ณ  ์žˆ๋Š” ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ๋‹ค.

๊ทธ๋ฆฌ๊ณ  sponsors ๋ฐ์ดํ„ฐ๋ฅผ ์ฃผ์ž…ํ•ด์คŒ์œผ๋กœ์จ ์Šคํ…์„ ๋”์šฑ ์œ ์—ฐํ•˜๊ฒŒ ๋งŒ๋“ค์–ด์ฃผ์—ˆ๋‹ค. ๊ทธ๋Ÿฌ๋ฉด ํ…Œ์ŠคํŠธ์— ํ•„์š”ํ•œ ๋ฐ์ดํ„ฐ๊ฐ€ ๋“ค์–ด๊ฐ„ sponsors๋ฅผ StubSponsorRepository ๊ฐ์ฒด ์ƒ์„ฑ ์‹œ ์„ค์ •ํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋œ๋‹ค.

 

given("ํ›„์›์‚ฌ ๋ฆฌ์ŠคํŠธ๊ฐ€ ์กด์žฌํ•œ๋‹ค๋ฉด") {
    beforeTest {
        val stubSponsorRepository = StubSponsorRepository(sponsors = fakeSponsors)
        viewModel = HomeViewModel(GetSponsorsUseCase(stubSponsorRepository))
    }

    then("ํ›„์›์‚ฌ ๋ฐ์ดํ„ฐ๋ฅผ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค") {
        runTest {
            viewModel.sponsorsUiState.test {
                awaitItem()
                val actual = awaitItem()
                actual.shouldBeInstanceOf<SponsorsUiState.Sponsors>()
            }
        }
    }
}

 

์ด๊ฒƒ์ด ์‹ค์ œ ์ ์šฉํ•œ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ์ธ๋ฐ, ์—ฌ๊ธฐ์„œ๋„ ๋ณผ ์ˆ˜ ์žˆ๋“ฏ์ด fakeSponsors ๋ฆฌ์ŠคํŠธ๋ฅผ ๋งŒ๋“ค์–ด ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•  ๋•Œ sponsors๋ฅผ ์„ค์ •ํ•  ์ˆ˜ ์žˆ๋Š” ๊ฒƒ์ด๋‹ค!

 

Fake

Fake๋„ ํ…Œ์ŠคํŠธ ๋”๋ธ”์˜ ์ข…๋ฅ˜ ์ค‘ ํ•˜๋‚˜์ด๋‹ค.

Fake ๊ฐ์ฒด๋Š”, ์‹ค์ œ ๊ฐ์ฒด์™€ ๋น„์Šทํ•˜๊ฒŒ ๋™์ž‘ํ•˜๋„๋ก ๊ตฌํ˜„๋œ ๋ชจ๋ฐฉ ๊ฐ์ฒด์ด๋‹ค.

 

์ฒ˜์Œ์—๋Š” Stub๊ณผ Fake์˜ ์ฐจ์ด๊ฐ€ ๋ฌด์—‡์ธ์ง€ ํ—ท๊ฐˆ๋ ธ๋Š”๋ฐ, Stub์€ ๋ฐ˜ํ™˜๊ฐ’์ด ๊ณ ์ •๋˜์–ด ์žˆ๋Š” ๋ฐ˜๋ฉด Fake๋Š” ๋” ์‹ค์ œ ๊ฐ์ฒด์ฒ˜๋Ÿผ ๋™์ž‘ํ•  ์ˆ˜ ์žˆ๋„๋ก ๋‚ด๋ถ€ ์ƒํƒœ๊ฐ€ ์ž˜ ๊ตฌ์„ฑ๋˜์–ด ์žˆ๋‹ค๋ฉด ๋ณด๋ฉด ๋œ๋‹ค.

 

์˜ˆ๋ฅผ ๋“ค์–ด๋ณด๋ฉด ๋‹ค์Œ๊ณผ ๊ฐ™๋‹ค.

class FakeSponsorRepository : SponsorRepository {
    private val sponsors = mutableListOf<Sponsor>()

    override suspend fun getSponsors(): List<Sponsor> {
        return sponsors.toList() 
    }

    suspend fun addSponsor(sponsor: Sponsor) {
        sponsors.add(sponsor)
    }
    
    suspend fun removeSponsor(sponsor: Sponsor) {
        sponsors.remove(sponsor)
    }
}

 

DB๋‚˜ ๋„คํŠธ์›Œํฌ๋ฅผ ํ†ตํ•œ ์ €์žฅ ๋Œ€์‹  ์ธ๋ฉ”๋ชจ๋ฆฌ์— ์ €์žฅํ•ด ์‹ค์ œ ๊ฐ์ฒด์ฒ˜๋Ÿผ ๋™์ž‘ํ•˜๋„๋ก ๋งŒ๋“  ๊ฒƒ์ด๋‹ค.

 

์–ด๋–ค ํ…Œ์ŠคํŠธ ๋”๋ธ”์„ ์‚ฌ์šฉํ• ์ง€๋Š” ์ƒํ™ฉ์— ๋”ฐ๋ผ ์„ ํƒํ•ด์ฃผ๋ฉด ๋  ๊ฒƒ ๊ฐ™๋‹ค.

๋‹จ์ˆœ ๊ณ ์ •๋œ ๊ฐ’๋งŒ ์ฃผ๋Š” ๊ฒฝ์šฐ๋ผ๋ฉด Stub์„, 

๋ฐ์ดํ„ฐ ์“ฐ๊ธฐ/์ฝ๊ธฐ ์ž‘์—…์ฒ˜๋Ÿผ ํ˜„์‹ค์ ์ธ ๋กœ์ง์ด ํ•„์š”ํ•˜๋‹ค๋ฉด Fake๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ๋˜๊ฒ ๋‹ค.

 

 

๐Ÿ“๊ด€๋ จ PR์€ ์˜† ๋งํฌ์—์„œ ํ™•์ธ ๊ฐ€๋Šฅํ•˜๋‹ค โžก๏ธ https://github.com/Kotlin-Multiplatform-Laboratory/DroidKnightsApp-KMP/pull/19

 

Reference

 

GitHub - droidknights/DroidKnightsApp: ๊ตญ๋‚ด ์ตœ๋Œ€ ๊ทœ๋ชจ ์•ˆ๋“œ๋กœ์ด๋“œ ์ปจํผ๋Ÿฐ์Šค ๋“œ๋กœ์ด๋“œ๋‚˜์ด์ธ  ์•ฑ

๊ตญ๋‚ด ์ตœ๋Œ€ ๊ทœ๋ชจ ์•ˆ๋“œ๋กœ์ด๋“œ ์ปจํผ๋Ÿฐ์Šค ๋“œ๋กœ์ด๋“œ๋‚˜์ด์ธ  ์•ฑ. Contribute to droidknights/DroidKnightsApp development by creating an account on GitHub.

github.com

 

GitHub - seyoungcho2/coroutinesbook: ใ€Ž์ฝ”ํ‹€๋ฆฐ ์ฝ”๋ฃจํ‹ด์˜ ์ •์„ใ€, ์กฐ์„ธ์˜, ์—์ด์ฝ˜ ์ถœํŒ์‚ฌ(2024) ์ €์žฅ์†Œ ์ž…๋‹ˆ

ใ€Ž์ฝ”ํ‹€๋ฆฐ ์ฝ”๋ฃจํ‹ด์˜ ์ •์„ใ€, ์กฐ์„ธ์˜, ์—์ด์ฝ˜ ์ถœํŒ์‚ฌ(2024) ์ €์žฅ์†Œ ์ž…๋‹ˆ๋‹ค. Contribute to seyoungcho2/coroutinesbook development by creating an account on GitHub.

github.com