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:
Kirill Kamakin
2024-02-17 10:43:36 +01:00
committed by GitHub
parent 80baf11ec4
commit c03c65a96b
12 changed files with 431 additions and 59 deletions

View File

@@ -23,10 +23,13 @@ jobs:
- name: Setup Gradle
uses: gradle/gradle-build-action@842c587ad8aa4c68eeba24c396e15af4c2e9f30a
- name: Run tests
- name: Checks
run: ./gradlew check :app:koverXmlReportRelease :app:koverVerifyRelease
- name: SonarCloud
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: ./gradlew check sonar --no-configuration-cache --no-daemon
run: ./gradlew sonar
- name: Publish test reports
uses: mikepenz/action-junit-report@0a8a5ba57593d67b2e45de2c543b438412382b7b

View File

@@ -84,71 +84,63 @@ ksp {
}
dependencies {
implementation(project(":architecture"))
implementation(project(":database"))
testImplementation(project(":database_test"))
implementation(project(":datastore"))
testImplementation(project(":datastore_test"))
implementation(project(":datasource"))
testImplementation(project(":datasource_test"))
implementation(project(":logging"))
implementation(project(":ui"))
implementation(project(":features:shopping_lists"))
implementation(project(":model_mapper"))
testImplementation(project(":testing"))
implementation(libs.android.material.material)
implementation(libs.androidx.coreKtx)
implementation(libs.androidx.splashScreen)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.lifecycle.viewmodelKtx)
implementation(libs.androidx.shareTarget)
implementation(libs.androidx.compose.materialIconsExtended)
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.compose)
testImplementation(libs.androidx.paging.commonKtx)
implementation(libs.jetbrains.kotlinx.datetime)
implementation(libs.androidx.datastore.preferences)
implementation(libs.coil)
implementation(libs.coil.compose)
implementation(libs.androidx.compose.animation)
implementation(libs.androidx.hilt.navigationCompose)
testImplementation(libs.junit)
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)
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.androidx.test.junit)
androidTestImplementation(libs.kaspersky.kaspresso)
@@ -157,5 +149,41 @@ dependencies {
androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.google.dagger.hiltAndroidTesting)
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)
}
}
}
}

View File

@@ -12,6 +12,7 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
with(pluginManager) {
apply("com.android.application")
apply("org.jetbrains.kotlin.android")
apply("org.jetbrains.kotlinx.kover")
}
extensions.configure<BaseAppModuleExtension> {

View File

@@ -11,6 +11,7 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
with(pluginManager) {
apply("com.android.library")
apply("org.jetbrains.kotlin.android")
apply("org.jetbrains.kotlinx.kover")
}
extensions.configure<LibraryExtension> {

View File

@@ -16,6 +16,7 @@ buildscript {
plugins {
alias(libs.plugins.sonarqube)
alias(libs.plugins.ksp) apply false
alias(libs.plugins.kover) apply false
}
sonarqube {
@@ -23,6 +24,10 @@ sonarqube {
property("sonar.projectKey", "kirmanak_Mealient")
property("sonar.organization", "kirmanak")
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",
"${projectDir.path}/build/reports/lint-results-debug.xml"
)
}
}
}

View File

@@ -1,11 +1,13 @@
package gq.kirmanak.mealient.datasource.ktor
import androidx.annotation.VisibleForTesting
import gq.kirmanak.mealient.datasource.AuthenticationProvider
import gq.kirmanak.mealient.logging.Logger
import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.plugins.auth.Auth
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.http.HttpStatusCode
import javax.inject.Inject
@@ -27,14 +29,7 @@ internal class AuthKtorConfiguration @Inject constructor(
}
refreshTokens {
val newTokens = getTokens()
val sameAccessToken = newTokens?.accessToken == oldTokens?.accessToken
if (sameAccessToken && response.status == HttpStatusCode.Unauthorized) {
authenticationProvider.logout()
null
} else {
newTokens
}
refreshTokens()
}
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()
logger.v { "getTokens(): token = $token" }
return token?.let { BearerTokens(accessToken = it, refreshToken = "") }

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -23,25 +23,22 @@ dependencies {
implementation(project(":database"))
implementation(project(":ui"))
implementation(project(":model_mapper"))
implementation(libs.android.material.material)
implementation(libs.androidx.compose.material)
implementation(libs.androidx.compose.materialIconsExtended)
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.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.androidx.test.junit)
testImplementation(libs.google.truth)
testImplementation(libs.io.mockk)
}

View File

@@ -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)
)

View File

@@ -83,6 +83,8 @@ androidxHilt = "1.1.0"
ktor = "2.3.7"
# https://github.com/coil-kt/coil/releases
coil = "2.5.0"
# https://github.com/Kotlin/kotlinx-kover/releases
kover = "0.7.5"
[libraries]
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" }
ksp = { id = "com.google.devtools.ksp", version.ref = "kspPlugin" }
protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" }
kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" }

View File

@@ -23,6 +23,8 @@ dependencyResolutionManagement {
rootProject.name = "Mealient"
System.setProperty("sonar.gradle.skipCompile", "true")
include(":app")
include(":architecture")
include(":database")