Add Kotlinx Kover test coverage calculator (#199)
* Add Kotlin Kover * Add AuthKtorConfiguration tests * Ensure at least 25% code coverage * Exclude Previews from code coverage * Specify Kover report path for SonarQube * Add Kover xml report task * Extract sonar to a separate step * Add some exclusions and minimum coverage * Exclude Hilt-generated classes * Add shopping list view model tests * Reduce the coverage requirement
This commit is contained in:
7
.github/workflows/check.yml
vendored
7
.github/workflows/check.yml
vendored
@@ -23,10 +23,13 @@ jobs:
|
|||||||
- name: Setup Gradle
|
- name: Setup Gradle
|
||||||
uses: gradle/gradle-build-action@842c587ad8aa4c68eeba24c396e15af4c2e9f30a
|
uses: gradle/gradle-build-action@842c587ad8aa4c68eeba24c396e15af4c2e9f30a
|
||||||
|
|
||||||
- name: Run tests
|
- name: Checks
|
||||||
|
run: ./gradlew check :app:koverXmlReportRelease :app:koverVerifyRelease
|
||||||
|
|
||||||
|
- name: SonarCloud
|
||||||
env:
|
env:
|
||||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
run: ./gradlew check sonar --no-configuration-cache --no-daemon
|
run: ./gradlew sonar
|
||||||
|
|
||||||
- name: Publish test reports
|
- name: Publish test reports
|
||||||
uses: mikepenz/action-junit-report@0a8a5ba57593d67b2e45de2c543b438412382b7b
|
uses: mikepenz/action-junit-report@0a8a5ba57593d67b2e45de2c543b438412382b7b
|
||||||
|
|||||||
@@ -84,71 +84,63 @@ ksp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
|
||||||
implementation(project(":architecture"))
|
implementation(project(":architecture"))
|
||||||
implementation(project(":database"))
|
implementation(project(":database"))
|
||||||
testImplementation(project(":database_test"))
|
|
||||||
implementation(project(":datastore"))
|
implementation(project(":datastore"))
|
||||||
testImplementation(project(":datastore_test"))
|
|
||||||
implementation(project(":datasource"))
|
implementation(project(":datasource"))
|
||||||
testImplementation(project(":datasource_test"))
|
|
||||||
implementation(project(":logging"))
|
implementation(project(":logging"))
|
||||||
implementation(project(":ui"))
|
implementation(project(":ui"))
|
||||||
implementation(project(":features:shopping_lists"))
|
implementation(project(":features:shopping_lists"))
|
||||||
implementation(project(":model_mapper"))
|
implementation(project(":model_mapper"))
|
||||||
testImplementation(project(":testing"))
|
|
||||||
|
|
||||||
implementation(libs.android.material.material)
|
implementation(libs.android.material.material)
|
||||||
|
|
||||||
implementation(libs.androidx.coreKtx)
|
implementation(libs.androidx.coreKtx)
|
||||||
implementation(libs.androidx.splashScreen)
|
implementation(libs.androidx.splashScreen)
|
||||||
|
|
||||||
implementation(libs.androidx.appcompat)
|
implementation(libs.androidx.appcompat)
|
||||||
|
|
||||||
implementation(libs.androidx.lifecycle.viewmodelKtx)
|
implementation(libs.androidx.lifecycle.viewmodelKtx)
|
||||||
|
|
||||||
implementation(libs.androidx.shareTarget)
|
implementation(libs.androidx.shareTarget)
|
||||||
|
|
||||||
implementation(libs.androidx.compose.materialIconsExtended)
|
implementation(libs.androidx.compose.materialIconsExtended)
|
||||||
|
|
||||||
implementation(libs.google.dagger.hiltAndroid)
|
implementation(libs.google.dagger.hiltAndroid)
|
||||||
kapt(libs.google.dagger.hiltCompiler)
|
|
||||||
kaptTest(libs.google.dagger.hiltAndroidCompiler)
|
|
||||||
testImplementation(libs.google.dagger.hiltAndroidTesting)
|
|
||||||
kaptAndroidTest(libs.google.dagger.hiltAndroidCompiler)
|
|
||||||
androidTestImplementation(libs.google.dagger.hiltAndroidTesting)
|
|
||||||
|
|
||||||
implementation(libs.androidx.paging.runtimeKtx)
|
implementation(libs.androidx.paging.runtimeKtx)
|
||||||
implementation(libs.androidx.paging.compose)
|
implementation(libs.androidx.paging.compose)
|
||||||
testImplementation(libs.androidx.paging.commonKtx)
|
|
||||||
|
|
||||||
implementation(libs.jetbrains.kotlinx.datetime)
|
implementation(libs.jetbrains.kotlinx.datetime)
|
||||||
|
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
|
||||||
implementation(libs.coil)
|
implementation(libs.coil)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
|
||||||
implementation(libs.androidx.compose.animation)
|
implementation(libs.androidx.compose.animation)
|
||||||
|
|
||||||
implementation(libs.androidx.hilt.navigationCompose)
|
implementation(libs.androidx.hilt.navigationCompose)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
|
||||||
|
|
||||||
implementation(libs.jetbrains.kotlinx.coroutinesAndroid)
|
implementation(libs.jetbrains.kotlinx.coroutinesAndroid)
|
||||||
testImplementation(libs.jetbrains.kotlinx.coroutinesTest)
|
|
||||||
|
|
||||||
testImplementation(libs.robolectric)
|
|
||||||
|
|
||||||
testImplementation(libs.androidx.test.junit)
|
|
||||||
testImplementation(libs.androidx.coreTesting)
|
|
||||||
|
|
||||||
testImplementation(libs.google.truth)
|
|
||||||
|
|
||||||
testImplementation(libs.io.mockk)
|
|
||||||
|
|
||||||
debugImplementation(libs.squareup.leakcanary)
|
debugImplementation(libs.squareup.leakcanary)
|
||||||
|
|
||||||
|
kover(project(":model_mapper"))
|
||||||
|
kover(project(":features:shopping_lists"))
|
||||||
|
kover(project(":ui"))
|
||||||
|
kover(project(":logging"))
|
||||||
|
kover(project(":architecture"))
|
||||||
|
kover(project(":database"))
|
||||||
|
kover(project(":datastore"))
|
||||||
|
kover(project(":datasource"))
|
||||||
|
|
||||||
|
kapt(libs.google.dagger.hiltCompiler)
|
||||||
|
|
||||||
|
kaptTest(libs.google.dagger.hiltAndroidCompiler)
|
||||||
|
|
||||||
|
kaptAndroidTest(libs.google.dagger.hiltAndroidCompiler)
|
||||||
|
|
||||||
|
testImplementation(project(":datasource_test"))
|
||||||
|
testImplementation(project(":database_test"))
|
||||||
|
testImplementation(project(":datastore_test"))
|
||||||
|
testImplementation(project(":testing"))
|
||||||
|
testImplementation(libs.androidx.paging.commonKtx)
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
testImplementation(libs.jetbrains.kotlinx.coroutinesTest)
|
||||||
|
testImplementation(libs.robolectric)
|
||||||
|
testImplementation(libs.androidx.test.junit)
|
||||||
|
testImplementation(libs.androidx.coreTesting)
|
||||||
|
testImplementation(libs.google.truth)
|
||||||
|
testImplementation(libs.io.mockk)
|
||||||
|
testImplementation(libs.google.dagger.hiltAndroidTesting)
|
||||||
|
|
||||||
androidTestImplementation(libs.junit)
|
androidTestImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.test.junit)
|
androidTestImplementation(libs.androidx.test.junit)
|
||||||
androidTestImplementation(libs.kaspersky.kaspresso)
|
androidTestImplementation(libs.kaspersky.kaspresso)
|
||||||
@@ -157,5 +149,41 @@ dependencies {
|
|||||||
androidTestImplementation(libs.androidx.test.core)
|
androidTestImplementation(libs.androidx.test.core)
|
||||||
androidTestImplementation(libs.androidx.test.rules)
|
androidTestImplementation(libs.androidx.test.rules)
|
||||||
androidTestImplementation(libs.androidx.test.runner)
|
androidTestImplementation(libs.androidx.test.runner)
|
||||||
|
androidTestImplementation(libs.google.dagger.hiltAndroidTesting)
|
||||||
|
|
||||||
androidTestUtil(libs.androidx.test.orchestrator)
|
androidTestUtil(libs.androidx.test.orchestrator)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
koverReport {
|
||||||
|
filters {
|
||||||
|
excludes {
|
||||||
|
classes(
|
||||||
|
"gq.kirmanak.mealient.datastore.recipe.AddRecipeInput*", // generated by data store
|
||||||
|
"*ComposableSingletons*", // generated by Compose
|
||||||
|
"gq.kirmanak.mealient.database.AppDb_Impl*", // generated by Room
|
||||||
|
"*Dao_Impl*", // generated by Room
|
||||||
|
"*Hilt_*", // generated by Hilt
|
||||||
|
)
|
||||||
|
packages(
|
||||||
|
"gq.kirmanak.mealient*.destinations", // generated by Compose destinations
|
||||||
|
)
|
||||||
|
annotatedBy(
|
||||||
|
"androidx.compose.ui.tooling.preview.Preview",
|
||||||
|
"gq.kirmanak.mealient.ui.preview.ColorSchemePreview",
|
||||||
|
"androidx.compose.runtime.Composable",
|
||||||
|
"dagger.Module",
|
||||||
|
"dagger.internal.DaggerGenerated",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
includes {
|
||||||
|
packages("gq.kirmanak.mealient")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
androidReports("release") {
|
||||||
|
verify {
|
||||||
|
rule {
|
||||||
|
minBound(30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
|
|||||||
with(pluginManager) {
|
with(pluginManager) {
|
||||||
apply("com.android.application")
|
apply("com.android.application")
|
||||||
apply("org.jetbrains.kotlin.android")
|
apply("org.jetbrains.kotlin.android")
|
||||||
|
apply("org.jetbrains.kotlinx.kover")
|
||||||
}
|
}
|
||||||
|
|
||||||
extensions.configure<BaseAppModuleExtension> {
|
extensions.configure<BaseAppModuleExtension> {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
|
|||||||
with(pluginManager) {
|
with(pluginManager) {
|
||||||
apply("com.android.library")
|
apply("com.android.library")
|
||||||
apply("org.jetbrains.kotlin.android")
|
apply("org.jetbrains.kotlin.android")
|
||||||
|
apply("org.jetbrains.kotlinx.kover")
|
||||||
}
|
}
|
||||||
|
|
||||||
extensions.configure<LibraryExtension> {
|
extensions.configure<LibraryExtension> {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ buildscript {
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.sonarqube)
|
alias(libs.plugins.sonarqube)
|
||||||
alias(libs.plugins.ksp) apply false
|
alias(libs.plugins.ksp) apply false
|
||||||
|
alias(libs.plugins.kover) apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
sonarqube {
|
sonarqube {
|
||||||
@@ -23,6 +24,10 @@ sonarqube {
|
|||||||
property("sonar.projectKey", "kirmanak_Mealient")
|
property("sonar.projectKey", "kirmanak_Mealient")
|
||||||
property("sonar.organization", "kirmanak")
|
property("sonar.organization", "kirmanak")
|
||||||
property("sonar.host.url", "https://sonarcloud.io")
|
property("sonar.host.url", "https://sonarcloud.io")
|
||||||
|
property(
|
||||||
|
"sonar.coverage.jacoco.xmlReportPaths",
|
||||||
|
"${projectDir.path}/app/build/reports/kover/reportRelease.xml"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +38,7 @@ subprojects {
|
|||||||
"sonar.androidLint.reportPaths",
|
"sonar.androidLint.reportPaths",
|
||||||
"${projectDir.path}/build/reports/lint-results-debug.xml"
|
"${projectDir.path}/build/reports/lint-results-debug.xml"
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
package gq.kirmanak.mealient.datasource.ktor
|
package gq.kirmanak.mealient.datasource.ktor
|
||||||
|
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
import gq.kirmanak.mealient.datasource.AuthenticationProvider
|
import gq.kirmanak.mealient.datasource.AuthenticationProvider
|
||||||
import gq.kirmanak.mealient.logging.Logger
|
import gq.kirmanak.mealient.logging.Logger
|
||||||
import io.ktor.client.HttpClientConfig
|
import io.ktor.client.HttpClientConfig
|
||||||
import io.ktor.client.engine.HttpClientEngineConfig
|
import io.ktor.client.engine.HttpClientEngineConfig
|
||||||
import io.ktor.client.plugins.auth.Auth
|
import io.ktor.client.plugins.auth.Auth
|
||||||
import io.ktor.client.plugins.auth.providers.BearerTokens
|
import io.ktor.client.plugins.auth.providers.BearerTokens
|
||||||
|
import io.ktor.client.plugins.auth.providers.RefreshTokensParams
|
||||||
import io.ktor.client.plugins.auth.providers.bearer
|
import io.ktor.client.plugins.auth.providers.bearer
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.http.HttpStatusCode
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -27,14 +29,7 @@ internal class AuthKtorConfiguration @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
refreshTokens {
|
refreshTokens {
|
||||||
val newTokens = getTokens()
|
refreshTokens()
|
||||||
val sameAccessToken = newTokens?.accessToken == oldTokens?.accessToken
|
|
||||||
if (sameAccessToken && response.status == HttpStatusCode.Unauthorized) {
|
|
||||||
authenticationProvider.logout()
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
newTokens
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sendWithoutRequest { true }
|
sendWithoutRequest { true }
|
||||||
@@ -42,7 +37,20 @@ internal class AuthKtorConfiguration @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getTokens(): BearerTokens? {
|
@VisibleForTesting
|
||||||
|
suspend fun RefreshTokensParams.refreshTokens(): BearerTokens? {
|
||||||
|
val newTokens = getTokens()
|
||||||
|
val sameAccessToken = newTokens?.accessToken == oldTokens?.accessToken
|
||||||
|
return if (sameAccessToken && response.status == HttpStatusCode.Unauthorized) {
|
||||||
|
authenticationProvider.logout()
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
newTokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
suspend fun getTokens(): BearerTokens? {
|
||||||
val token = authenticationProvider.getAuthToken()
|
val token = authenticationProvider.getAuthToken()
|
||||||
logger.v { "getTokens(): token = $token" }
|
logger.v { "getTokens(): token = $token" }
|
||||||
return token?.let { BearerTokens(accessToken = it, refreshToken = "") }
|
return token?.let { BearerTokens(accessToken = it, refreshToken = "") }
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package gq.kirmanak.mealient.datasource
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import gq.kirmanak.mealient.datasource.ktor.AuthKtorConfiguration
|
||||||
|
import gq.kirmanak.mealient.test.BaseUnitTest
|
||||||
|
import io.ktor.client.plugins.auth.providers.BearerTokens
|
||||||
|
import io.ktor.client.plugins.auth.providers.RefreshTokensParams
|
||||||
|
import io.ktor.client.statement.HttpResponse
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.impl.annotations.MockK
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
private const val AUTH_TOKEN = "token"
|
||||||
|
|
||||||
|
internal class AuthKtorConfigurationTest : BaseUnitTest() {
|
||||||
|
|
||||||
|
@MockK(relaxUnitFun = true)
|
||||||
|
lateinit var authenticationProvider: AuthenticationProvider
|
||||||
|
|
||||||
|
private lateinit var subject: AuthKtorConfiguration
|
||||||
|
|
||||||
|
@Before
|
||||||
|
override fun setUp() {
|
||||||
|
super.setUp()
|
||||||
|
coEvery { authenticationProvider.getAuthToken() } returns AUTH_TOKEN
|
||||||
|
subject = AuthKtorConfiguration(FakeProvider(authenticationProvider), logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getTokens returns BearerTokens with auth token`() = runTest {
|
||||||
|
val bearerTokens = subject.getTokens()
|
||||||
|
assertThat(bearerTokens?.accessToken).isEqualTo(AUTH_TOKEN)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getTokens returns BearerTokens without refresh token`() = runTest {
|
||||||
|
val bearerTokens = subject.getTokens()
|
||||||
|
assertThat(bearerTokens?.refreshToken).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `refreshTokens returns new auth token if it doesn't match old`() = runTest {
|
||||||
|
val refreshTokensParams = mockRefreshTokenParams(HttpStatusCode.Unauthorized, "old token")
|
||||||
|
val actual = with(subject) { refreshTokensParams.refreshTokens() }
|
||||||
|
assertThat(actual?.accessToken).isEqualTo(AUTH_TOKEN)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `refreshTokens returns empty refresh token if auth token doesn't match old`() = runTest {
|
||||||
|
val refreshTokensParams = mockRefreshTokenParams(HttpStatusCode.Unauthorized, "old token")
|
||||||
|
val actual = with(subject) { refreshTokensParams.refreshTokens() }
|
||||||
|
assertThat(actual?.refreshToken).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `refreshTokens returns null if auth token matches old`() = runTest {
|
||||||
|
val refreshTokensParams = mockRefreshTokenParams(HttpStatusCode.Unauthorized, AUTH_TOKEN)
|
||||||
|
val actual = with(subject) { refreshTokensParams.refreshTokens() }
|
||||||
|
assertThat(actual).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `refreshTokens calls logout if auth token matches old`() = runTest {
|
||||||
|
val refreshTokensParams = mockRefreshTokenParams(HttpStatusCode.Unauthorized, AUTH_TOKEN)
|
||||||
|
with(subject) { refreshTokensParams.refreshTokens() }
|
||||||
|
coVerify { authenticationProvider.logout() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `refreshTokens does not logout if status code is not found`() = runTest {
|
||||||
|
val refreshTokensParams = mockRefreshTokenParams(HttpStatusCode.NotFound, AUTH_TOKEN)
|
||||||
|
with(subject) { refreshTokensParams.refreshTokens() }
|
||||||
|
coVerify(inverse = true) { authenticationProvider.logout() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `refreshTokens returns same access token if status code is not found`() = runTest {
|
||||||
|
val refreshTokensParams = mockRefreshTokenParams(HttpStatusCode.NotFound, AUTH_TOKEN)
|
||||||
|
val actual = with(subject) { refreshTokensParams.refreshTokens() }
|
||||||
|
assertThat(actual?.accessToken).isEqualTo(AUTH_TOKEN)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `refreshTokens returns empty refresh token if status code is not found`() = runTest {
|
||||||
|
val refreshTokensParams = mockRefreshTokenParams(HttpStatusCode.NotFound, AUTH_TOKEN)
|
||||||
|
val actual = with(subject) { refreshTokensParams.refreshTokens() }
|
||||||
|
assertThat(actual?.refreshToken).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mockRefreshTokenParams(
|
||||||
|
responseStatusCode: HttpStatusCode,
|
||||||
|
oldAccessToken: String,
|
||||||
|
): RefreshTokensParams {
|
||||||
|
val notFoundResponse = mockk<HttpResponse> {
|
||||||
|
every { status } returns responseStatusCode
|
||||||
|
}
|
||||||
|
val refreshTokensParams = mockk<RefreshTokensParams> {
|
||||||
|
every { response } returns notFoundResponse
|
||||||
|
every { oldTokens } returns BearerTokens(oldAccessToken, "")
|
||||||
|
}
|
||||||
|
return refreshTokensParams
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package gq.kirmanak.mealient.datasource
|
||||||
|
|
||||||
|
import javax.inject.Provider
|
||||||
|
|
||||||
|
data class FakeProvider<T>(
|
||||||
|
val value: T,
|
||||||
|
) : Provider<T> {
|
||||||
|
|
||||||
|
override fun get(): T = value
|
||||||
|
}
|
||||||
@@ -23,25 +23,22 @@ dependencies {
|
|||||||
implementation(project(":database"))
|
implementation(project(":database"))
|
||||||
implementation(project(":ui"))
|
implementation(project(":ui"))
|
||||||
implementation(project(":model_mapper"))
|
implementation(project(":model_mapper"))
|
||||||
|
|
||||||
implementation(libs.android.material.material)
|
implementation(libs.android.material.material)
|
||||||
implementation(libs.androidx.compose.material)
|
implementation(libs.androidx.compose.material)
|
||||||
implementation(libs.androidx.compose.materialIconsExtended)
|
implementation(libs.androidx.compose.materialIconsExtended)
|
||||||
|
|
||||||
implementation(libs.google.dagger.hiltAndroid)
|
implementation(libs.google.dagger.hiltAndroid)
|
||||||
kapt(libs.google.dagger.hiltCompiler)
|
|
||||||
kaptTest(libs.google.dagger.hiltAndroidCompiler)
|
|
||||||
testImplementation(libs.google.dagger.hiltAndroidTesting)
|
|
||||||
|
|
||||||
implementation(libs.androidx.hilt.navigationCompose)
|
implementation(libs.androidx.hilt.navigationCompose)
|
||||||
|
|
||||||
implementation(libs.jetbrains.kotlinx.coroutinesAndroid)
|
implementation(libs.jetbrains.kotlinx.coroutinesAndroid)
|
||||||
|
|
||||||
|
kapt(libs.google.dagger.hiltCompiler)
|
||||||
|
|
||||||
|
kaptTest(libs.google.dagger.hiltAndroidCompiler)
|
||||||
|
|
||||||
|
testImplementation(project(":testing"))
|
||||||
|
testImplementation(libs.google.dagger.hiltAndroidTesting)
|
||||||
testImplementation(libs.jetbrains.kotlinx.coroutinesTest)
|
testImplementation(libs.jetbrains.kotlinx.coroutinesTest)
|
||||||
|
|
||||||
testImplementation(libs.androidx.test.junit)
|
testImplementation(libs.androidx.test.junit)
|
||||||
|
|
||||||
testImplementation(libs.google.truth)
|
testImplementation(libs.google.truth)
|
||||||
|
|
||||||
testImplementation(libs.io.mockk)
|
testImplementation(libs.io.mockk)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package gq.kirmanak.mealient.shopping_lists.ui.details
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import gq.kirmanak.mealient.datasource.models.GetFoodResponse
|
||||||
|
import gq.kirmanak.mealient.datasource.models.GetShoppingListItemRecipeReferenceResponse
|
||||||
|
import gq.kirmanak.mealient.datasource.models.GetShoppingListItemResponse
|
||||||
|
import gq.kirmanak.mealient.datasource.models.GetShoppingListResponse
|
||||||
|
import gq.kirmanak.mealient.datasource.models.GetUnitResponse
|
||||||
|
import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsAuthRepo
|
||||||
|
import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsRepo
|
||||||
|
import gq.kirmanak.mealient.test.BaseUnitTest
|
||||||
|
import gq.kirmanak.mealient.ui.util.LoadingHelper
|
||||||
|
import gq.kirmanak.mealient.ui.util.LoadingHelperFactory
|
||||||
|
import gq.kirmanak.mealient.ui.util.LoadingState
|
||||||
|
import gq.kirmanak.mealient.ui.util.LoadingStateNoData
|
||||||
|
import gq.kirmanak.mealient.ui.util.LoadingStateWithData
|
||||||
|
import io.mockk.CapturingSlot
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.impl.annotations.MockK
|
||||||
|
import io.mockk.slot
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertSame
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
internal class ShoppingListViewModelTest : BaseUnitTest() {
|
||||||
|
|
||||||
|
@MockK(relaxUnitFun = true)
|
||||||
|
lateinit var shoppingListsRepo: ShoppingListsRepo
|
||||||
|
|
||||||
|
@MockK(relaxUnitFun = true)
|
||||||
|
lateinit var authRepo: ShoppingListsAuthRepo
|
||||||
|
|
||||||
|
@MockK(relaxUnitFun = true)
|
||||||
|
lateinit var loadingHelperFactory: LoadingHelperFactory
|
||||||
|
|
||||||
|
@MockK(relaxUnitFun = true)
|
||||||
|
lateinit var loadingHelper: LoadingHelper<ShoppingListData>
|
||||||
|
|
||||||
|
lateinit var subject: ShoppingListViewModel
|
||||||
|
|
||||||
|
private val loadingState = MutableStateFlow<LoadingState<ShoppingListData>>(
|
||||||
|
LoadingStateNoData.InitialLoad
|
||||||
|
)
|
||||||
|
|
||||||
|
private val isAuthorized = MutableStateFlow(false)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when view model is created then the list is refreshed`() {
|
||||||
|
createViewModel()
|
||||||
|
coVerify { loadingHelper.refresh() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when user authenticates then the list is refreshed`() {
|
||||||
|
createViewModel()
|
||||||
|
isAuthorized.value = true
|
||||||
|
coVerify {
|
||||||
|
loadingHelper.refresh() // On create
|
||||||
|
loadingHelper.refresh() // On authentication
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when refresh fails then snackbar is shown`() {
|
||||||
|
val error = IOException()
|
||||||
|
createViewModel(
|
||||||
|
refreshResult = Result.failure(error)
|
||||||
|
)
|
||||||
|
assertSame(error, subject.errorToShowInSnackbar)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when refresh succeeds then no snackbar shown`() {
|
||||||
|
createViewModel()
|
||||||
|
assertNull(subject.errorToShowInSnackbar)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when loading starts then state is initial load`() {
|
||||||
|
createViewModel()
|
||||||
|
assertEquals(LoadingStateNoData.InitialLoad, subject.loadingState.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when loading succeeds then data is shown`() {
|
||||||
|
createViewModel()
|
||||||
|
loadingState.value = LoadingStateWithData.Success(shoppingListData)
|
||||||
|
assertEquals(LoadingStateWithData.Success(shoppingListScreen), subject.loadingState.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when load data is requested then repo is queried`() = runTest {
|
||||||
|
val lambdaSlot = slot<suspend () -> Result<ShoppingListData>>()
|
||||||
|
createViewModel(
|
||||||
|
lambdaSlot = lambdaSlot
|
||||||
|
)
|
||||||
|
val lambda = lambdaSlot.captured
|
||||||
|
val actualResult = lambda()
|
||||||
|
assertEquals(Result.success(shoppingListData), actualResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createViewModel(
|
||||||
|
shoppingListId: String = "shoppingListId",
|
||||||
|
refreshResult: Result<ShoppingListData> = Result.success(shoppingListData),
|
||||||
|
lambdaSlot: CapturingSlot<suspend () -> Result<ShoppingListData>> = slot<suspend () -> Result<ShoppingListData>>(),
|
||||||
|
) {
|
||||||
|
val savedStateHandle = SavedStateHandle().also {
|
||||||
|
it["shoppingListId"] = shoppingListId
|
||||||
|
}
|
||||||
|
every { loadingHelperFactory.create(any(), capture(lambdaSlot)) } returns loadingHelper
|
||||||
|
every { loadingHelper.loadingState } returns loadingState
|
||||||
|
coEvery { loadingHelper.refresh() } returns refreshResult
|
||||||
|
every { authRepo.isAuthorizedFlow } returns isAuthorized
|
||||||
|
coEvery { shoppingListsRepo.getFoods() } returns listOf(milkFood)
|
||||||
|
coEvery { shoppingListsRepo.getUnits() } returns listOf(mlUnit)
|
||||||
|
coEvery { shoppingListsRepo.getShoppingList(any()) } returns shoppingListResponse
|
||||||
|
subject = ShoppingListViewModel(
|
||||||
|
shoppingListsRepo = shoppingListsRepo,
|
||||||
|
logger = logger,
|
||||||
|
authRepo = authRepo,
|
||||||
|
loadingHelperFactory = loadingHelperFactory,
|
||||||
|
savedStateHandle = savedStateHandle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private val mlUnit = GetUnitResponse("ml", "")
|
||||||
|
|
||||||
|
private val milkFood = GetFoodResponse("Milk", "")
|
||||||
|
|
||||||
|
private val blackTeaBags = GetShoppingListItemResponse(
|
||||||
|
id = "1",
|
||||||
|
shoppingListId = "1",
|
||||||
|
checked = false,
|
||||||
|
position = 0,
|
||||||
|
isFood = false,
|
||||||
|
note = "Black tea bags",
|
||||||
|
quantity = 30.0,
|
||||||
|
unit = null,
|
||||||
|
food = null,
|
||||||
|
recipeReferences = listOf(
|
||||||
|
GetShoppingListItemRecipeReferenceResponse(
|
||||||
|
recipeId = "1",
|
||||||
|
recipeQuantity = 1.0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val milk = GetShoppingListItemResponse(
|
||||||
|
id = "2",
|
||||||
|
shoppingListId = "1",
|
||||||
|
checked = true,
|
||||||
|
position = 0,
|
||||||
|
isFood = true,
|
||||||
|
note = "Cold",
|
||||||
|
quantity = 500.0,
|
||||||
|
unit = mlUnit,
|
||||||
|
food = milkFood,
|
||||||
|
recipeReferences = listOf(
|
||||||
|
GetShoppingListItemRecipeReferenceResponse(
|
||||||
|
recipeId = "1",
|
||||||
|
recipeQuantity = 500.0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val shoppingListResponse = GetShoppingListResponse(
|
||||||
|
id = "shoppingListId",
|
||||||
|
groupId = "shoppingListGroupId",
|
||||||
|
name = "shoppingListName",
|
||||||
|
listItems = listOf(blackTeaBags, milk),
|
||||||
|
recipeReferences = listOf()
|
||||||
|
)
|
||||||
|
|
||||||
|
private val shoppingListData = ShoppingListData(
|
||||||
|
foods = listOf(milkFood),
|
||||||
|
units = listOf(mlUnit),
|
||||||
|
shoppingList = shoppingListResponse
|
||||||
|
)
|
||||||
|
|
||||||
|
private val shoppingListScreen = ShoppingListScreenState(
|
||||||
|
name = "shoppingListName",
|
||||||
|
listId = "shoppingListId",
|
||||||
|
items = listOf(
|
||||||
|
ShoppingListItemState.ExistingItem(
|
||||||
|
item = blackTeaBags,
|
||||||
|
isEditing = false
|
||||||
|
),
|
||||||
|
ShoppingListItemState.ExistingItem(
|
||||||
|
item = milk,
|
||||||
|
isEditing = false
|
||||||
|
)
|
||||||
|
),
|
||||||
|
foods = listOf(milkFood),
|
||||||
|
units = listOf(mlUnit)
|
||||||
|
)
|
||||||
@@ -83,6 +83,8 @@ androidxHilt = "1.1.0"
|
|||||||
ktor = "2.3.7"
|
ktor = "2.3.7"
|
||||||
# https://github.com/coil-kt/coil/releases
|
# https://github.com/coil-kt/coil/releases
|
||||||
coil = "2.5.0"
|
coil = "2.5.0"
|
||||||
|
# https://github.com/Kotlin/kotlinx-kover/releases
|
||||||
|
kover = "0.7.5"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" }
|
android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" }
|
||||||
@@ -191,3 +193,4 @@ sonarqube = { id = "org.sonarqube", version.ref = "sonarqube" }
|
|||||||
appsweep = { id = "com.guardsquare.appsweep", version.ref = "appsweep" }
|
appsweep = { id = "com.guardsquare.appsweep", version.ref = "appsweep" }
|
||||||
ksp = { id = "com.google.devtools.ksp", version.ref = "kspPlugin" }
|
ksp = { id = "com.google.devtools.ksp", version.ref = "kspPlugin" }
|
||||||
protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" }
|
protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" }
|
||||||
|
kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" }
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ dependencyResolutionManagement {
|
|||||||
|
|
||||||
rootProject.name = "Mealient"
|
rootProject.name = "Mealient"
|
||||||
|
|
||||||
|
System.setProperty("sonar.gradle.skipCompile", "true")
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
include(":architecture")
|
include(":architecture")
|
||||||
include(":database")
|
include(":database")
|
||||||
|
|||||||
Reference in New Issue
Block a user