Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/android_ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,5 @@ dependencies {
implementation(libs.firebase.analytics)
implementation(libs.firebase.crashlytics)
implementation(libs.play.services.ads)
implementation(libs.kotlin.reflect)
}
24 changes: 17 additions & 7 deletions build-logic/src/main/java/com/yapp/convention/TestAndroid.kt
Original file line number Diff line number Diff line change
@@ -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())
}
}
55 changes: 55 additions & 0 deletions build-logic/src/main/java/com/yapp/convention/TestCoverage.kt
Original file line number Diff line number Diff line change
@@ -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<JacocoPluginExtension> {
toolVersion = libs.findVersion("jacoco").get().toString()
}

// 모든 유닛 테스트에 Jacoco 설정 적용
tasks.withType<Test>().configureEach {
extensions.configure<JacocoTaskExtension> {
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()
}
}
}
17 changes: 5 additions & 12 deletions build-logic/src/main/java/com/yapp/convention/TestKotlin.kt
Original file line number Diff line number Diff line change
@@ -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<Test>().configureEach {
useJUnitPlatform()
}
}
6 changes: 6 additions & 0 deletions build-logic/src/main/java/orbit.android.library.gradle.kts
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -9,3 +12,6 @@ plugins {
configureKotlinAndroid()
configureCoroutine()
configureHiltAndroid()
configureTestAndroid()
configureTestKotlin()
configureTestCoverage()
83 changes: 83 additions & 0 deletions data/src/test/kotlin/com/yapp/data/FortuneDataSourceImplTest.kt
Original file line number Diff line number Diff line change
@@ -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<FortuneResponse>()
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<FortuneResponse>()
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) }
}
}
57 changes: 57 additions & 0 deletions data/src/test/kotlin/com/yapp/data/FortuneMapperTest.kt
Original file line number Diff line number Diff line change
@@ -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."
)
}
69 changes: 69 additions & 0 deletions data/src/test/kotlin/com/yapp/data/FortuneRepositoryImplTest.kt
Original file line number Diff line number Diff line change
@@ -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<FortuneDataSource>()
private val localDataSource = mockk<FortuneLocalDataSource>(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"
)
}
Loading