Skip to content

Commit

Permalink
Add Kotlinx Kover test coverage calculator (#199)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
kirmanak authored Feb 17, 2024
1 parent 80baf11 commit c03c65a
Show file tree
Hide file tree
Showing 12 changed files with 425 additions and 53 deletions.
7 changes: 5 additions & 2 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
94 changes: 61 additions & 33 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -84,70 +84,62 @@ 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)
implementation(libs.jetbrains.kotlinx.coroutinesAndroid)

testImplementation(libs.junit)
debugImplementation(libs.squareup.leakcanary)

implementation(libs.jetbrains.kotlinx.coroutinesAndroid)
testImplementation(libs.jetbrains.kotlinx.coroutinesTest)
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"))

testImplementation(libs.robolectric)
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)

debugImplementation(libs.squareup.leakcanary)
testImplementation(libs.google.dagger.hiltAndroidTesting)

androidTestImplementation(libs.junit)
androidTestImplementation(libs.androidx.test.junit)
Expand All @@ -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)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down
6 changes: 6 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,18 @@ buildscript {
plugins {
alias(libs.plugins.sonarqube)
alias(libs.plugins.ksp) apply false
alias(libs.plugins.kover) apply false
}

sonarqube {
properties {
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"
)
}
}

Expand All @@ -33,6 +38,7 @@ subprojects {
"sonar.androidLint.reportPaths",
"${projectDir.path}/build/reports/lint-results-debug.xml"
)

}
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -27,22 +29,28 @@ 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 }
}
}
}

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 = "") }
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit c03c65a

Please sign in to comment.