diff --git a/.github/workflows/android_ci.yml b/.github/workflows/android_ci.yml index b8974a0d..d8ca94cd 100644 --- a/.github/workflows/android_ci.yml +++ b/.github/workflows/android_ci.yml @@ -80,3 +80,26 @@ jobs: # Run Lint and Build - name: Run lint and build run: ./gradlew ktlintCheck assembleDebug + + # Run Unit Test and Generate Coverage + - name: Run unit tests and generate coverage + run: ./gradlew generateTestCoverageReport + + # Upload Coverage Report + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: data/build/reports/coverage/test/debug/ + + # Comment PR with coverage result + - name: Comment coverage report in PR + if: github.event_name == 'pull_request' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + COMMENT="๐Ÿš€ ํ…Œ์ŠคํŠธ ์™„๋ฃŒ ๋ฐ ์ปค๋ฒ„๋ฆฌ์ง€ ๋ฆฌํฌํŠธ๊ฐ€ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n\nโžก๏ธ [ํด๋ฆญํ•˜์—ฌ ์ปค๋ฒ„๋ฆฌ์ง€ ๋ฆฌํฌํŠธ ๋‹ค์šด๋กœ๋“œ](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})" + curl -s -H "Authorization: token $GITHUB_TOKEN" \ + -X POST \ + -d "{\"body\": \"$COMMENT\"}" \ + "https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 19b02048..b4897244 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -50,4 +50,5 @@ dependencies { implementation(libs.firebase.analytics) implementation(libs.firebase.crashlytics) implementation(libs.play.services.ads) + implementation(libs.kotlin.reflect) } diff --git a/build-logic/src/main/java/com/yapp/convention/TestAndroid.kt b/build-logic/src/main/java/com/yapp/convention/TestAndroid.kt index 9fe992f6..55bb6d48 100644 --- a/build-logic/src/main/java/com/yapp/convention/TestAndroid.kt +++ b/build-logic/src/main/java/com/yapp/convention/TestAndroid.kt @@ -1,16 +1,26 @@ package com.yapp.convention import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies + internal fun Project.configureTestAndroid() { - configureJUnitAndroid() + // feature ๋ชจ๋“ˆ์—๋งŒ ํ…Œ์ŠคํŠธ ๊ด€๋ จ ์„ค์ • ์ ์šฉ + if (path.startsWith(":feature:")) { + configureComposeUiTest() + } } -@Suppress("UnstableApiUsage") -internal fun Project.configureJUnitAndroid() { - androidExtension.apply { - testOptions { - unitTests.all { it.useJUnitPlatform() } - } +internal fun Project.configureComposeUiTest() { + val libs = extensions.libs + dependencies { + // Jetpack Compose UI ํ…Œ์ŠคํŠธ์šฉ + "androidTestImplementation"(libs.findLibrary("compose-ui-test-junit4").get()) + // ํ…Œ์ŠคํŠธ์šฉ AndroidManifest ์ œ๊ณตํ•ด์ฃผ๋Š” ๊ฑฐ (debug ๋นŒ๋“œ์—์„œ๋งŒ ์‚ฌ์šฉ, ํ…Œ์ŠคํŠธ ์‹œ Activity ์‹คํ–‰ ์ง€์›) + "debugImplementation"(libs.findLibrary("compose-ui-test-manifest").get()) + // ํ…Œ์ŠคํŠธ๋ฅผ ์‹ค์ œ๋กœ ๋Œ๋ ค์ฃผ๋Š” ์‹คํ–‰๊ธฐ + "androidTestImplementation"(libs.findLibrary("androidx-test-runner").get()) + // JUnit4 ๊ธฐ๋Šฅ์„ ์•ˆ๋“œ๋กœ์ด๋“œ ํ…Œ์ŠคํŠธ์— ์—ฐ๊ฒฐํ•ด์ฃผ๋Š” ์–ด๋Œ‘ํ„ฐ + "androidTestImplementation"(libs.findLibrary("androidx-test-ext-junit").get()) } } diff --git a/build-logic/src/main/java/com/yapp/convention/TestCoverage.kt b/build-logic/src/main/java/com/yapp/convention/TestCoverage.kt new file mode 100644 index 00000000..fd54ed18 --- /dev/null +++ b/build-logic/src/main/java/com/yapp/convention/TestCoverage.kt @@ -0,0 +1,55 @@ +package com.yapp.convention + +import com.android.build.api.dsl.ApplicationExtension +import com.android.build.api.dsl.LibraryExtension +import org.gradle.api.Project +import org.gradle.api.tasks.testing.Test +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.withType +import org.gradle.testing.jacoco.plugins.JacocoPluginExtension +import org.gradle.testing.jacoco.plugins.JacocoTaskExtension + +internal fun Project.configureTestCoverage() { + pluginManager.apply("jacoco") + + val libs = extensions.libs + extensions.configure { + toolVersion = libs.findVersion("jacoco").get().toString() + } + + // ๋ชจ๋“  ์œ ๋‹› ํ…Œ์ŠคํŠธ์— Jacoco ์„ค์ • ์ ์šฉ + tasks.withType().configureEach { + extensions.configure { + isIncludeNoLocationClasses = true + excludes = listOf("jdk.internal.*") + } + } + + // Android ๋ชจ๋“ˆ์ด๋ฉด ์ปค๋ฒ„๋ฆฌ์ง€ ์„ค์ • ์ถ”๊ฐ€ + extensions.findByType(ApplicationExtension::class.java)?.buildTypes?.configureEach { + enableUnitTestCoverage = true + } + + extensions.findByType(LibraryExtension::class.java)?.buildTypes?.configureEach { + enableUnitTestCoverage = true + } + + // ์ปค๋ฒ„๋ฆฌ์ง€ ๋ฆฌํฌํŠธ Task ๋“ฑ๋ก + tasks.register("generateTestCoverageReport") { + group = "verification" + description = "Run unit tests and generate coverage report." + + dependsOn("testDebugUnitTest") + dependsOn("createDebugUnitTestCoverageReport") + } + + // .exec ํŒŒ์ผ ์—†์„ ๊ฒฝ์šฐ createDebugUnitTestCoverageReport task ์Šคํ‚ต + tasks.matching { it.name == "createDebugUnitTestCoverageReport" }.configureEach { + onlyIf { + val execFile = layout.buildDirectory + .file("outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec") + .get().asFile + execFile.exists() + } + } +} diff --git a/build-logic/src/main/java/com/yapp/convention/TestKotlin.kt b/build-logic/src/main/java/com/yapp/convention/TestKotlin.kt index 3b7d98c7..790833c0 100644 --- a/build-logic/src/main/java/com/yapp/convention/TestKotlin.kt +++ b/build-logic/src/main/java/com/yapp/convention/TestKotlin.kt @@ -1,23 +1,16 @@ package com.yapp.convention import org.gradle.api.Project -import org.gradle.api.tasks.testing.Test import org.gradle.kotlin.dsl.dependencies -import org.gradle.kotlin.dsl.withType -internal fun Project.configureTest() { - configureJUnit() +internal fun Project.configureTestKotlin() { val libs = extensions.libs dependencies { + // JUnit4 ๋‹จ์œ„ ํ…Œ์ŠคํŠธ ํ”„๋ ˆ์ž„์›Œํฌ "testImplementation"(libs.findLibrary("junit4").get()) - "testImplementation"(libs.findLibrary("junit-jupiter").get()) - "testImplementation"(libs.findLibrary("coroutines-test").get()) + // ์ฝ”๋ฃจํ‹ด ๊ด€๋ จ ํ…Œ์ŠคํŠธ ๋„๊ตฌ (TestCoroutineScope, runTest ๋“ฑ..) + "testImplementation"(libs.findLibrary("kotlinx-coroutines-test").get()) + // Kotlin ๊ธฐ๋ฐ˜ mock ๊ฐ์ฒด ์ƒ์„ฑ, ํ–‰์œ„ ๊ฒ€์ฆ "testImplementation"(libs.findLibrary("mockk").get()) } } - -internal fun Project.configureJUnit() { - tasks.withType().configureEach { - useJUnitPlatform() - } -} diff --git a/build-logic/src/main/java/orbit.android.library.gradle.kts b/build-logic/src/main/java/orbit.android.library.gradle.kts index 3ee18d12..f63f2be4 100644 --- a/build-logic/src/main/java/orbit.android.library.gradle.kts +++ b/build-logic/src/main/java/orbit.android.library.gradle.kts @@ -1,6 +1,9 @@ import com.yapp.convention.configureCoroutine import com.yapp.convention.configureHiltAndroid import com.yapp.convention.configureKotlinAndroid +import com.yapp.convention.configureTestAndroid +import com.yapp.convention.configureTestCoverage +import com.yapp.convention.configureTestKotlin plugins { id("com.android.library") @@ -9,3 +12,6 @@ plugins { configureKotlinAndroid() configureCoroutine() configureHiltAndroid() +configureTestAndroid() +configureTestKotlin() +configureTestCoverage() diff --git a/data/src/test/kotlin/com/yapp/data/FortuneDataSourceImplTest.kt b/data/src/test/kotlin/com/yapp/data/FortuneDataSourceImplTest.kt new file mode 100644 index 00000000..b7d9d0f4 --- /dev/null +++ b/data/src/test/kotlin/com/yapp/data/FortuneDataSourceImplTest.kt @@ -0,0 +1,83 @@ +package com.yapp.data + +import com.yapp.data.remote.datasource.FortuneDataSourceImpl +import com.yapp.data.remote.dto.response.FortuneResponse +import com.yapp.data.remote.service.ApiService +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +class FortuneDataSourceImplTest { + + private lateinit var dataSource: FortuneDataSourceImpl + private val apiService: ApiService = mockk() + + @Before + fun setup() { + dataSource = FortuneDataSourceImpl(apiService) + } + + @Test + fun `์šด์„ธ ๋“ฑ๋ก์— ์„ฑ๊ณตํ•˜๋ฉด ์„ฑ๊ณต Result๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค`() = runTest { + // Given + val userId = 1L + val mockResponse = mockk() + coEvery { apiService.postFortune(userId) } returns mockResponse + + // When + val result = dataSource.postFortune(userId) + + // Then + assertTrue(result.isSuccess) + assertEquals(mockResponse, result.getOrNull()) + coVerify { apiService.postFortune(userId) } + } + + @Test + fun `์šด์„ธ ๋“ฑ๋ก ์ค‘ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด ์‹คํŒจ Result๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค`() = runTest { + // Given + val userId = 1L + coEvery { apiService.postFortune(userId) } throws RuntimeException("Network Error") + + // When + val result = dataSource.postFortune(userId) + + // Then + assertTrue(result.isFailure) + coVerify { apiService.postFortune(userId) } + } + + @Test + fun `์šด์„ธ ์กฐํšŒ์— ์„ฑ๊ณตํ•˜๋ฉด ์„ฑ๊ณต Result๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค`() = runTest { + // Given + val fortuneId = 10L + val mockResponse = mockk() + coEvery { apiService.getFortune(fortuneId) } returns mockResponse + + // When + val result = dataSource.getFortune(fortuneId) + + // Then + assertTrue(result.isSuccess) + assertEquals(mockResponse, result.getOrNull()) + coVerify { apiService.getFortune(fortuneId) } + } + + @Test + fun `์šด์„ธ ์กฐํšŒ ์ค‘ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด ์‹คํŒจ Result๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค`() = runTest { + // Given + val fortuneId = 10L + coEvery { apiService.getFortune(fortuneId) } throws RuntimeException("Network Error") + + // When + val result = dataSource.getFortune(fortuneId) + + // Then + assertTrue(result.isFailure) + coVerify { apiService.getFortune(fortuneId) } + } +} diff --git a/data/src/test/kotlin/com/yapp/data/FortuneMapperTest.kt b/data/src/test/kotlin/com/yapp/data/FortuneMapperTest.kt new file mode 100644 index 00000000..6c3313b7 --- /dev/null +++ b/data/src/test/kotlin/com/yapp/data/FortuneMapperTest.kt @@ -0,0 +1,57 @@ +package com.yapp.data + +import com.yapp.data.remote.dto.response.FortuneDetail +import com.yapp.data.remote.dto.response.FortuneResponse +import com.yapp.data.remote.dto.response.toDomain +import org.junit.Assert.assertEquals +import org.junit.Test + +class FortuneMapperTest { + + @Test + fun `FortuneResponse๋ฅผ ๋„๋ฉ”์ธ ๋ชจ๋ธ๋กœ ๋งคํ•‘ํ•˜๋ฉด ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๋ณ€ํ™˜๋œ๋‹ค`() { + val response = dummyFortuneResponse() + val domain = response.toDomain() + + assertEquals(response.id, domain.id) + assertEquals(response.dailyFortune, domain.dailyFortuneTitle) + assertEquals(response.dailyFortuneDescription, domain.dailyFortuneDescription) + assertEquals(response.avgFortuneScore, domain.avgFortuneScore) + assertEquals(response.studyCareerFortune.toDomain(), domain.studyCareerFortune) + assertEquals(response.luckyFood, domain.luckyFood) + } + + @Test + fun `FortuneDetail์„ ๋„๋ฉ”์ธ ๋ชจ๋ธ๋กœ ๋งคํ•‘ํ•˜๋ฉด ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๋ณ€ํ™˜๋œ๋‹ค`() { + val detail = FortuneDetail(score = 85, title = "Success", description = "Great things happen") + val domain = detail.toDomain() + + assertEquals(85, domain.score) + assertEquals("Success", domain.title) + assertEquals("Great things happen", domain.description) + } + + private fun dummyFortuneResponse() = FortuneResponse( + id = 123, + dailyFortune = "Today is your lucky day", + dailyFortuneDescription = "You'll find success in your endeavors.", + avgFortuneScore = 88, + studyCareerFortune = dummyDetail(), + wealthFortune = dummyDetail(), + healthFortune = dummyDetail(), + loveFortune = dummyDetail(), + luckyOutfitTop = "T-shirt", + luckyOutfitBottom = "Shorts", + luckyOutfitShoes = "Sneakers", + luckyOutfitAccessory = "Bracelet", + unluckyColor = "Gray", + luckyColor = "Yellow", + luckyFood = "Sushi" + ) + + private fun dummyDetail() = FortuneDetail( + score = 90, + title = "High Energy", + description = "You will feel energetic all day." + ) +} diff --git a/data/src/test/kotlin/com/yapp/data/FortuneRepositoryImplTest.kt b/data/src/test/kotlin/com/yapp/data/FortuneRepositoryImplTest.kt new file mode 100644 index 00000000..7abaf7b3 --- /dev/null +++ b/data/src/test/kotlin/com/yapp/data/FortuneRepositoryImplTest.kt @@ -0,0 +1,69 @@ +package com.yapp.data + +import com.yapp.data.local.datasource.FortuneLocalDataSource +import com.yapp.data.remote.datasource.FortuneDataSource +import com.yapp.data.remote.dto.response.FortuneDetail +import com.yapp.data.remote.dto.response.FortuneResponse +import com.yapp.data.remote.dto.response.toDomain +import com.yapp.data.repositoryimpl.FortuneRepositoryImpl +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class FortuneRepositoryImplTest { + + private val remoteDataSource = mockk() + private val localDataSource = mockk(relaxed = true) + + private val repository = FortuneRepositoryImpl( + fortuneRemoteDataSource = remoteDataSource, + fortuneLocalDataSource = localDataSource, + ) + + @Test + fun `์šด์„ธ ์š”์ฒญ์— ์„ฑ๊ณตํ•˜๋ฉด ๋„๋ฉ”์ธ ๋ชจ๋ธ๋กœ ๋ฐ˜ํ™˜๋œ๋‹ค`() = runTest { + val response = dummyFortuneResponse() + coEvery { remoteDataSource.postFortune(1L) } returns Result.success(response) + + val result = repository.postFortune(1L) + + assert(result.isSuccess) + assertEquals(response.toDomain(), result.getOrNull()) + } + + @Test + fun `์šด์„ธ ์ƒ์„ธ ์กฐํšŒ์— ์‹คํŒจํ•˜๋ฉด ์‹คํŒจ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค`() = runTest { + val exception = RuntimeException("Not found") + coEvery { remoteDataSource.getFortune(2L) } returns Result.failure(exception) + + val result = repository.getFortune(2L) + + assert(result.isFailure) + } + + private fun dummyFortuneResponse() = FortuneResponse( + id = 1L, + dailyFortune = "Good luck", + dailyFortuneDescription = "You will be lucky today", + avgFortuneScore = 90, + studyCareerFortune = dummyDetail(), + wealthFortune = dummyDetail(), + healthFortune = dummyDetail(), + loveFortune = dummyDetail(), + luckyOutfitTop = "Hoodie", + luckyOutfitBottom = "Jeans", + luckyOutfitShoes = "Sneakers", + luckyOutfitAccessory = "Watch", + unluckyColor = "Black", + luckyColor = "White", + luckyFood = "Pizza", + ) + + private fun dummyDetail() = FortuneDetail( + score = 100, + title = "Title", + description = "Description" + ) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 51c70bb2..1c8ef8ab 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -64,12 +64,14 @@ firebase-app-distribution = "5.1.0" firebase-crashlytics = "3.0.3" ## Test -junit = "4.13.2" +junit4 = "4.13.2" mockito = "3.3.3" +mockk = "1.13.9" robolectric = "4.9" androidx-test-ext-junit = "1.2.0" androidx-test-runner = "1.6.0" androidx-test = "1.6.0" +jacoco = "0.8.10" ## Others timber = "5.0.1" @@ -163,8 +165,9 @@ timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "tim coil = { group = "io.coil-kt", name = "coil", version.ref = "coil" } ## Test Libraries -junit = { group = "junit", name = "junit", version.ref = "junit" } +junit4 = { group = "junit", name = "junit", version.ref = "junit4" } mockito = { group = "org.mockito", name = "mockito-core", version.ref = "mockito" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidx-test-runner" }