Skip to content

Commit

Permalink
Merge pull request #23 from azizutku/update-unit-tests
Browse files Browse the repository at this point in the history
Enhance testing suite
  • Loading branch information
azizutku committed Oct 30, 2023
2 parents 210c62c + 3579316 commit 80297b5
Show file tree
Hide file tree
Showing 48 changed files with 2,182 additions and 86 deletions.
56 changes: 53 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@

# Movie App ![Kotlin](https://img.shields.io/badge/kotlin-1.9.10-orange) ![Android Gradle Plugin](https://img.shields.io/badge/agp-8.1.2-blue)
# Movie App ![Kotlin](https://img.shields.io/badge/kotlin-1.9.10-orange) ![Android Gradle Plugin](https://img.shields.io/badge/agp-8.1.2-blue)
![Movie App](images/project_showcase.png "Clean Movie App")

<img src="images/app_tour.gif" width="250" align="right" hspace="0">

Expand All @@ -15,17 +16,17 @@
- Migrated from single module structure. Check [related PR for migration](https://github.com/azizutku/Modular-Clean-Arch-Movie-App/pull/3)
- Composite builds with convention plugins
- Migrated from buildSrc. Check [related PR for migration](https://github.com/azizutku/Modular-Clean-Arch-Movie-App/pull/2)
- Unit tests with high coverage, including tests for Room and Paging.
- Baseline Profiles and Macrobenchmark
- Version Catalogs
- Supporting fully offline usage with Room
- Pagination from Local and Remote (RemoteMediator) and only from Local
- Pagination from Local and Remote (RemoteMediator) and only from Local with Paging 3
- Dependency injection with Hilt
- Dark and Light theme
- Kotlin Coroutines and Flows
- Kotlinx Serialization
- New SplashScreen API
- Different product flavors (dummy)
- Unit tests for ViewModels and data layer and tests for Room.
- Deep links

## Screenshots
Expand All @@ -43,6 +44,48 @@ The project is divided into feature and core modules, following the rule: if a c
## Navigation
When feature modules are compiled, they work in isolation and cannot access each other, making it impossible to navigate to destinations in other modules using IDs. To address this, deep links are used to enable direct navigation to a destination feature. This ensures that users can access features located in different modules without any issues.

## Testing
The project employs a range of strategies and tools to ensure that every component operates as intended and integrates flawlessly with the system.

#### Strategies and Tools
- **AAA Pattern**: The project employs the Arrange-Act-Assert pattern throughout to maintain clarity and efficiency in tests.
- **Fakes**: These are used to simulate functionalities that can produce consistent results, favoring simplicity.
- **Mocks**: Facilitated by the [Mockk](https://github.com/mockk/mockk) library, mocks are employed when interactions need verification, allowing confirmation that our code behaves correctly in a controlled environment.
- **Robolectric**: Enables us to run Android-specific tests without the need for actual devices, speeding up the testing process.
- **Parameterized tests**: Help in executing the same test with different inputs, ensuring a broader test coverage.
- **JUnit4**: Testing framework orchestrates our testing, providing a stable and feature-rich platform to assert the correctness of our code.

#### Fakes over Mocks
Fakes are preferred because they have a "working" implementation of the class, but it's constructed in a manner that's ideal for testing purposes and not suitable for production. While fakes are the first choice due to their lightweight nature and speed, there are scenarios where it's necessary to use mocks. Mocks are employed when the interaction with the external system is complex and behavior needs to be validated precisely.

#### Coverage Tools: JaCoCo vs Kover
JaCoCo has been the standard for a long time, providing detailed coverage reports, whereas Kover is Kotlin-specific and integrates more seamlessly with Kotlin projects, potentially offering more accurate coverage metrics for Kotlin code.

#### Executing Tests & Reports
Execute all unit tests using the following command:
```bash
./gradlew testDevDebugUnitTest
```
To generate a coverage report with **JaCoCo**, use:
```bash
./gradlew jacocoTestDevDebugUnitTestReport
```
The reports are available in the `/build/reports/jacoco/jacocoTestDevDebugUnitTestReport/html` folder.

For coverage reports via the Kover plugin, the command is:
```bash
./gradlew koverHtmlReportDevDebug
```

Find these reports in the `/build/reports/kover/htmlDevDebug` folder.

#### Android-Specific Tests
For performing UI tests and testing components that require an Android environment, such as Room and Paging, use:
```bash
./gradlew connectedDevDebugAndroidTest
```
or you can use `./gradlew pixel4Api31AospDevDebugAndroidTest` without connected device

## Baseline Profiles
The app's baseline profile is located in the `app/src/main/baseline-prof.txt` directory, and is responsible for allowing the Ahead-of-Time (AOT) compilation of the app's critical user path during launch. To generate baseline profiles, run the following Gradle command in your terminal:
```
Expand Down Expand Up @@ -83,12 +126,19 @@ Please refer to [`lib.versions.toml`](https://github.com/azizutku/Modular-Clean-

- [JUnit](https://github.com/junit-team/junit4)
- [Turbine](https://github.com/cashapp/turbine)
- [Mockk](https://github.com/mockk/mockk)
- [Robolectric](https://github.com/robolectric/robolectric)
- [Coroutines-test](https://github.com/Kotlin/kotlinx.coroutines/tree/master/kotlinx-coroutines-test)
- [Hilt-test](https://dagger.dev/hilt/testing.html)
- [Room-test](https://developer.android.com/training/data-storage/room/testing-db)
- [Paging-test](https://developer.android.com/topic/libraries/architecture/paging/test)

### Plugins

- [Detekt](https://github.com/arturbosch/detekt)
- [Ktlint](https://github.com/pinterest/ktlint)
- [Jacoco](https://github.com/jacoco/jacoco)
- [Kover](https://github.com/Kotlin/kotlinx-kover)
- [Gradle Version Plugin](https://github.com/ben-manes/gradle-versions-plugin)

To run detekt use `detekt` task.
Expand Down
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ plugins {
id("movie.android.application")
id("movie.android.hilt")
id("movie.android.application.jacoco")
id("movie.android.application.kover")
}

dependencies {
Expand Down
9 changes: 9 additions & 0 deletions build-logic/convention/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dependencies {
implementation(libs.detekt.plugin)
implementation(libs.javapoet.plugin)
implementation(libs.navigation.safeargs.plugin)
implementation(libs.kotlinx.kover.plugin)
}

gradlePlugin {
Expand Down Expand Up @@ -60,5 +61,13 @@ gradlePlugin {
id = "movie.detekt"
implementationClass = "DetektConventionPlugin"
}
register("androidKoverLibrary") {
id = "movie.android.library.kover"
implementationClass = "AndroidLibraryKoverConventionPlugin"
}
register("androidKoverApplication") {
id = "movie.android.application.kover"
implementationClass = "AndroidApplicationKoverConventionPlugin"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.getByType
import org.gradle.kotlin.dsl.kotlin

class AndroidApplicationConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
Expand All @@ -40,13 +41,19 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
packaging {
resources.excludes.add("META-INF/AL2.0")
resources.excludes.add("META-INF/LGPL2.1")
resources.excludes.add("META-INF/LICENSE.md")
resources.excludes.add("META-INF/LICENSE-notice.md")
}
configureGradleManagedDevices(this)
namespace = AndroidConfig.NAMESPACE
}
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
dependencies {
add("detektPlugins", libs.findBundle("detekt").get())
add("testImplementation", kotlin("test"))
add("testImplementation", project(":core:testing"))
add("androidTestImplementation", kotlin("test"))
add("androidTestImplementation", project(":core:testing"))
}
kotlin {
jvmToolchain(JDK_VERSION)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import com.azizutku.movie.BuildPlugins
import com.azizutku.movie.extensions.configureKover
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.getByType

class AndroidApplicationKoverConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply(BuildPlugins.ANDROID_APPLICATION)
apply(BuildPlugins.KOTLINX_KOVER)
}
val extension = extensions.getByType<ApplicationAndroidComponentsExtension>()
configureKover(extension)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,18 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
with(pluginManager) {
apply(BuildPlugins.ANDROID_LIBRARY)
apply(BuildPlugins.KOTLIN_ANDROID)
apply("movie.android.library.jacoco")
apply("movie.android.library.kover")
}
extensions.configure<LibraryExtension> {
configureKotlinAndroid(this)
defaultConfig.targetSdk = AndroidConfig.TARGET_SDK
configureFlavors(this)
configureGradleManagedDevices(this)
packaging {
resources.excludes.add("META-INF/LICENSE.md")
resources.excludes.add("META-INF/LICENSE-notice.md")
}
}
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
configurations.configureEach {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import com.android.build.api.variant.LibraryAndroidComponentsExtension
import com.azizutku.movie.BuildPlugins
import com.azizutku.movie.extensions.configureKover
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.getByType

class AndroidLibraryKoverConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply(BuildPlugins.ANDROID_LIBRARY)
apply(BuildPlugins.KOTLINX_KOVER)
}
val extension = extensions.getByType<LibraryAndroidComponentsExtension>()
configureKover(extension)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ object BuildPlugins {
const val NAVIGATION_SAFEARGS = "androidx.navigation.safeargs.kotlin"
const val ANDROID_TEST = "com.android.test"
const val GRADLE_JACOCO = "org.gradle.jacoco"
const val KOTLINX_KOVER = "org.jetbrains.kotlinx.kover"
const val KSP = "com.google.devtools.ksp"
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ import java.util.Locale

private val coverageExclusions = listOf(
// Android
"**/*Fragment.*",
"**/*Fragment\$*",
"**/*Activity.*",
"**/*Activity\$*",
"**/*Adapter.*",
"**/*Adapter\$*",
"**/*ViewHolder.*",
"**/*ViewHolder\$*",
"**/*Dialog.*",
"**/*Dialog\$*",
"**/R.class",
"**/R\$*.class",
"**/BuildConfig.*",
Expand All @@ -26,25 +36,27 @@ private val coverageExclusions = listOf(
"**/BR.*",
"**/*Test*.*",
"android/**/*.*",
// kotlin
// Kotlin
"**/*MapperImpl*.*",
"**/BuildConfig.*",
"**/*Component*.*",
"**/*BR*.*",
"**/Manifest*.*",
"**/*Companion*.*",
"**/*Module*.*",
"**/*Dagger*.*",
"**/*Hilt*.*",
"**/*MembersInjector*.*",
"**/*_MembersInjector.class",
"**/*_Factory*.*",
"**/*_Provide*Factory*.*",
// sealed and data classes
// Sealed and data classes
"**/*$Result.*",
"**/*$Result$*.*",
"**/*Dto*.*",
"**/*Entity*.*",
// Autogenerated classes by 'kotlinx.serialization'
"**/*serializer*.*",
"**/*LocalDataSourceImpl.*", // Covered in androidTest
)

internal fun Project.configureJacoco(
Expand All @@ -67,12 +79,12 @@ internal fun Project.configureJacoco(
html.required.set(true)
}
classDirectories.setFrom(
fileTree("${project.layout.buildDirectory}/tmp/kotlin-classes/${variant.name}") {
fileTree(layout.buildDirectory.dir("tmp/kotlin-classes/${variant.name}")) {
exclude(coverageExclusions)
}
)
sourceDirectories.setFrom(files("$projectDir/src/main/java", "$projectDir/src/main/kotlin"))
executionData.setFrom(file("${project.layout.buildDirectory}/jacoco/$testTaskName.exec"))
executionData.setFrom(file(layout.buildDirectory.dir("jacoco/$testTaskName.exec")))
}
jacocoTestReport.dependsOn(reportTask)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.azizutku.movie.extensions

import com.android.build.api.variant.AndroidComponentsExtension
import kotlinx.kover.gradle.plugin.dsl.KoverReportExtension
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure

private val coverageExclusions = listOf(
// Android
"*Fragment",
"*Fragment\$*",
"*Activity",
"*Activity\$*",
"*Adapter",
"*Adapter\$*",
"*ViewHolder",
"*ViewHolder\$*",
"*Dialog",
"*Dialog\$*",
"*R.class",
"*R\$*.class",
"*.BuildConfig",
"*Manifest*.*",
"*.databinding.*",
"*BR.*",
"*Test*.*",
"android/**/*.*",
// Kotlin
"*MapperImpl*.*",
"*Component*.*",
"*BR*.*",
"*Manifest*.*",
"*Args*",
"*Module*.*",
"*Dagger*.*",
"*Hilt*.*",
"*_HiltModules*",
"*MembersInjector*.*",
"*_MembersInjector.class",
"*_Factory*.*",
"*_Factory\$*",
"*_Provide*Factory*.*",
// Sealed and data classes
"*$Result.*",
"*$Result$*.*",
"*Dto*",
"*Entity*",
// Autogenerated classes by 'kotlinx.serialization'
"*serializer*.*",
"*LocalDataSourceImpl.*", // Covered in androidTest
)

internal fun Project.configureKover(
androidComponentsExtension: AndroidComponentsExtension<*, *, *>,
) {
androidComponentsExtension.onVariants { variant ->
configure<KoverReportExtension> {
filters {
androidReports(variant.name) {
filters {
excludes {
classes(coverageExclusions)
packages("*.di", "hilt_aggregated_deps")
annotatedBy("*Generated*")
}
}
}
}
}
}
}
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ plugins {
alias(libs.plugins.kotlin.serialization).apply(false)
alias(libs.plugins.ksp).apply(false)
alias(libs.plugins.kotlin.jvm).apply(false)
alias(libs.plugins.kotlinx.kover).apply(false)
id("movie.git.hooks").apply(false)
id("movie.detekt").apply(false)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package com.azizutku.movie.core.common.base
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.azizutku.movie.core.common.vo.DataState
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
Expand All @@ -17,7 +16,6 @@ interface ErrorOwner {
}

context(ViewModel)
@OptIn(FlowPreview::class)
fun <T> ErrorOwner.flattenMergeForError(vararg flows: Flow<T>) = flowOf(*flows)
.flattenMerge()
.filterIsInstance<DataState.Error>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import androidx.fragment.app.Fragment
import androidx.navigation.NavController
import androidx.navigation.NavDeepLinkRequest
import androidx.navigation.NavOptions
import androidx.navigation.fragment.findNavController
import com.azizutku.movie.core.common.R

context(Fragment)
Expand All @@ -19,7 +18,7 @@ fun NavController.navigateToMovie(movieId: Int) {
val request = NavDeepLinkRequest.Builder
.fromUri(deeplinkUri)
.build()
findNavController().navigate(request, getNavOptionsWithAnimation())
navigate(request, getNavOptionsWithAnimation())
}

private fun getNavOptionsWithAnimation(): NavOptions = NavOptions.Builder()
Expand Down
Loading

0 comments on commit 80297b5

Please sign in to comment.