Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature/52-add-ui-tests #53

Merged
merged 1 commit into from
Apr 26, 2024
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
8 changes: 7 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ android {
targetSdk = 34
versionCode = 3
versionName = "1.1.0"
testInstrumentationRunner = "com.manuelnunez.apps.navigation.CustomTestRunner"
}

signingConfigs {
Expand Down Expand Up @@ -69,9 +70,10 @@ android {
}

dependencies {
implementation(projects.core.data) // Added only for testing using the TestDataModule
implementation(projects.core.ui)
implementation(projects.core.data)
implementation(projects.core.domain)

implementation(projects.features.home.ui)
implementation(projects.features.detail.ui)
implementation(projects.features.seemore.ui)
Expand All @@ -86,6 +88,7 @@ dependencies {
implementation(libs.androidx.navigation.compose)

implementation(libs.coil.kt)
implementation(libs.androidx.test.runner)

// Compose
val composeBom = platform(libs.androidx.compose.bom)
Expand All @@ -94,4 +97,7 @@ dependencies {
// Hilt Dependency Injection
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)

androidTestImplementation(libs.androidx.navigation.testing)
androidTestImplementation(libs.hilt.android.testing)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.manuelnunez.apps.navigation

import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication

// A custom runner to set up the instrumented application class for tests.
class CustomTestRunner : AndroidJUnitRunner() {

override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package com.manuelnunez.apps.navigation

import androidx.annotation.StringRes
import androidx.compose.ui.test.assertIsNotSelected
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.rules.ActivityScenarioRule
import com.manuelnunez.apps.MainActivity
import com.manuelnunez.apps.core.ui.component.CustomCardAutomation
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import kotlin.properties.ReadOnlyProperty
import com.manuelnunez.apps.core.ui.R as CoreUIR
import com.manuelnunez.apps.features.detail.ui.R as DetailR
import com.manuelnunez.apps.features.favorites.ui.R as FavorR
import com.manuelnunez.apps.features.home.ui.R as HomeR

@HiltAndroidTest
class MainNavigationTest {
private fun AndroidComposeTestRule<*, *>.stringResource(@StringRes resId: Int) =
ReadOnlyProperty<Any, String> { _, _ -> activity.getString(resId) }

@get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)

@get:Rule(order = 1) val composeTestRule = createAndroidComposeRule<MainActivity>()

private val navigateUp by composeTestRule.stringResource(resId = CoreUIR.string.button_back)
private val home by composeTestRule.stringResource(resId = RootScreen.HOME.contentDescription)
private val favorite by
composeTestRule.stringResource(resId = RootScreen.FAVORITES.contentDescription)
private val details by
composeTestRule.stringResource(resId = DetailR.string.section_details_title)
private val featureHomeTitle by composeTestRule.stringResource(HomeR.string.section_feature)
private val featureFavoriteTitle by
composeTestRule.stringResource(FavorR.string.section_favorites)
private val featureDetailFavoriteButton by
composeTestRule.stringResource(DetailR.string.button_favorite)

@Before
fun init() {
hiltRule.inject()
}

@Test
fun firstTabScreen_startDestination_isHomeNotArrowShown() {
composeTestRule.apply {
// Tab Home selected
onNodeWithContentDescription(home).assertIsSelected()
onNodeWithContentDescription(favorite).assertIsNotSelected()

// HomeTitle
onNodeWithContentDescription(featureHomeTitle).assertExists()

// GIVEN the user is on any of the top level destinations, THEN the Up arrow is not shown.
onNodeWithContentDescription(navigateUp).assertDoesNotExist()
}
}

@Test
fun secondTabScreen_navigateFromHome_isFavoriteNotArrowShown() {
composeTestRule.apply {
// Navigate to tab favorites
onNodeWithContentDescription(favorite).performClick().assertIsSelected()

// Favorite title
onNodeWithContentDescription(featureFavoriteTitle).assertExists()

// Home tab not selected
onNodeWithContentDescription(home).assertIsNotSelected()

// GIVEN the user is on any of the top level destinations, THEN the Up arrow is not shown.
onNodeWithContentDescription(navigateUp).assertDoesNotExist()
}
}

@Test
fun detailScreen_navigateFromHome_onClickAnyPhoto() {
composeTestRule.apply {
// Home tab selected
onNodeWithContentDescription(home).assertIsSelected()

// Click on any photo and navigate to details
onAllNodesWithTag(CustomCardAutomation.IMAGE_CARD_PREFIX).onFirst().performClick()

// We are in Details Screen
onNodeWithContentDescription(details).performClick()

// GIVEN the user is not on any top level destinations, THEN the Up arrow is shown.
onNodeWithContentDescription(navigateUp).assertExists().performClick()

// We are in HomeScreen again
onNodeWithContentDescription(featureHomeTitle).assertExists()
}
}

@Test
fun detailScreen_navigateFromFavorite_onClickAnyPhoto() {
composeTestRule.apply {
// Home tab selected
onNodeWithContentDescription(home).assertIsSelected()
addItemToFavorites()

// Move to tab favorites
onNodeWithContentDescription(favorite).performClick()
onNodeWithContentDescription(featureFavoriteTitle).assertExists()

// Click on any photo and navigate to details
onAllNodesWithTag(CustomCardAutomation.IMAGE_CARD_PREFIX).onFirst().performClick()

// We are in Details Screen
onNodeWithContentDescription(details).performClick()

// GIVEN the user is not on any top level destinations, THEN the Up arrow is shown.
onNodeWithContentDescription(navigateUp).assertExists().performClick()

// We are in HomeScreen again
onNodeWithContentDescription(featureFavoriteTitle).assertExists()
}
}

@Test
fun navigationBar_reselectTab_keepsState() {
composeTestRule.apply {
// Home tab selected
onNodeWithContentDescription(home).assertIsSelected()

// Click on any photo and navigate to details
onAllNodesWithTag(CustomCardAutomation.IMAGE_CARD_PREFIX).onFirst().performClick()

// We are in Details Screen
onNodeWithContentDescription(details).performClick()

// Move to tab favorites
onNodeWithContentDescription(favorite).performClick()

// Move back to home
onNodeWithContentDescription(home).performClick()

// We are stills in Details Screen
onNodeWithContentDescription(details).performClick()
}
}

private fun AndroidComposeTestRule<ActivityScenarioRule<MainActivity>, MainActivity>
.addItemToFavorites() {
// Click on any photo and navigate to details
onAllNodesWithTag(CustomCardAutomation.IMAGE_CARD_PREFIX).onFirst().performClick()
// Add to favorites
onNodeWithContentDescription(featureDetailFavoriteButton).performClick()
// Back
onNodeWithContentDescription(navigateUp).assertExists().performClick()
}
}
7 changes: 7 additions & 0 deletions app/src/main/kotlin/com/manuelnunez/apps/MainApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.navigation.NavController
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.compose.rememberNavController
Expand Down Expand Up @@ -71,7 +73,10 @@ fun MainApp() {
private fun MainBottomNavBar(navController: NavController, currentSelectedScreen: RootScreen) {
MainNavigationBar {
mainDestinations().forEach { rootScreen ->
val contentDescriptionScreen = stringResource(id = rootScreen.contentDescription)

MainNavigationBarItem(
modifier = Modifier.semantics { contentDescription = contentDescriptionScreen },
selected = currentSelectedScreen == rootScreen,
onClick = { navController.navigateToRootScreen(rootScreen) },
icon = {
Expand All @@ -95,7 +100,9 @@ private fun MainBottomNavBar(navController: NavController, currentSelectedScreen
private fun MainNavRail(navController: NavController, currentSelectedScreen: RootScreen) {
MainNavigationRail {
mainDestinations().forEach { rootScreen ->
val contentDescriptionScreen = stringResource(id = rootScreen.contentDescription)
MainNavigationRailItem(
modifier = Modifier.semantics { contentDescription = contentDescriptionScreen },
selected = currentSelectedScreen == rootScreen,
onClick = { navController.navigateToRootScreen(rootScreen) },
icon = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,22 @@ enum class RootScreen(
val unselectedIcon: ImageVector,
val iconTextId: Int,
val titleTextId: Int,
val contentDescription: Int
) {
HOME(
route = "home_root",
selectedIcon = Icons.Default.Home,
unselectedIcon = Icons.Outlined.Home,
iconTextId = R.string.home_destination_title,
titleTextId = R.string.home_destination_title),
titleTextId = R.string.home_destination_title,
contentDescription = R.string.home_destination_content_description),
FAVORITES(
route = "favorite_root",
selectedIcon = Icons.Default.Favorite,
unselectedIcon = Icons.Outlined.FavoriteBorder,
iconTextId = R.string.favorites_destination_title,
titleTextId = R.string.favorites_destination_title)
titleTextId = R.string.favorites_destination_title,
contentDescription = R.string.favorites_destination_content_description)
}

fun NavController.navigateToRootScreen(rootScreen: RootScreen) {
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<resources>
<string name="app_name">Purrfect Pics</string>
<string name="home_destination_title">Home</string>
<string name="home_destination_content_description">HomeTabItem</string>
<string name="favorites_destination_title">Favorites</string>
<string name="favorites_destination_content_description">FavoritesTabItem</string>
</resources>
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.manuelnunez.apps.core.common.test
package com.manuelnunez.apps.core.common.testRule

import com.manuelnunez.apps.core.common.dispatcher.CoroutineDispatcherProvider
import io.mockk.unmockkAll
Expand All @@ -14,7 +14,7 @@ import org.junit.jupiter.api.extension.AfterEachCallback
import org.junit.jupiter.api.extension.BeforeEachCallback
import org.junit.jupiter.api.extension.ExtensionContext

@ExperimentalCoroutinesApi
@OptIn(ExperimentalCoroutinesApi::class)
class MockkAllRule : BeforeEachCallback, AfterEachCallback {

private val testCoroutinesDispatcher = StandardTestDispatcher()
Expand Down
5 changes: 4 additions & 1 deletion core/data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ android {
dependencies {
implementation(projects.core.common)
implementation(projects.core.services)
implementation(projects.core.domain)
implementation(
projects.core.domain) // Added only for the domain Item model. It could be moved to common
implementation(projects.core.datastoreProto)
// Added features domain only for the repository interface
implementation(projects.features.home.domain)
implementation(projects.features.seemore.domain)
implementation(projects.features.favorites.domain)
Expand All @@ -40,6 +42,7 @@ dependencies {

implementation(libs.androidx.paging.common.ktx)
// HILT
implementation(libs.hilt.android.testing)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.manuelnunez.apps.core.data.di

import com.manuelnunez.apps.core.data.repository.fake.FakeDetailRepository
import com.manuelnunez.apps.core.data.repository.fake.FakeFavoritesRepository
import com.manuelnunez.apps.core.data.repository.fake.FakeHomeRepository
import com.manuelnunez.apps.core.data.repository.fake.FakeSeeMoreRepository
import com.manuelnunez.apps.features.detail.domain.repository.DetailRepository
import com.manuelnunez.apps.features.favorites.domain.repository.FavoritesRepository
import com.manuelnunez.apps.features.home.domain.repository.HomeRepository
import com.manuelnunez.apps.features.seemore.domain.repository.SeeMoreRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import javax.inject.Singleton

/**
* This Test Module is used as replace of the DataModule when testing is done. In case of the
* MainNavigationTest, this avoid to call network and emit fake data instead.
*/
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [DataModule::class],
)
internal interface TestDataModule {
@Singleton
@Binds
abstract fun bindsHomeRepository(homeRepository: FakeHomeRepository): HomeRepository

@Singleton
@Binds
abstract fun bindsSeeMoreRepository(seeMoreRepository: FakeSeeMoreRepository): SeeMoreRepository

@Singleton
@Binds
abstract fun bindsDetailRepository(detailRepository: FakeDetailRepository): DetailRepository

@Singleton
@Binds
abstract fun bindsFavoritesRepository(
seeMoreRepository: FakeFavoritesRepository
): FavoritesRepository
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.manuelnunez.apps.core.data.repository.fake

import com.manuelnunez.apps.core.domain.model.Item
import com.manuelnunez.apps.features.detail.domain.repository.DetailRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import javax.inject.Inject

class FakeDetailRepository @Inject constructor() : DetailRepository {
private val items = mutableListOf<Item>()

override suspend fun saveFavoriteItem(favoriteItem: Item) {
items.add(favoriteItem)
}

override suspend fun removeFavoriteItem(favoriteItem: Item) {
items.remove(favoriteItem)
}

override fun isItemFavorite(itemPhotoId: String): Flow<Boolean> = flowOf(true)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.manuelnunez.apps.core.data.repository.fake

import com.manuelnunez.apps.core.domain.model.Item
import com.manuelnunez.apps.core.domain.utils.mockItems
import com.manuelnunez.apps.features.favorites.domain.repository.FavoritesRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import javax.inject.Inject

class FakeFavoritesRepository @Inject constructor() : FavoritesRepository {
override fun getAllFavorites(): Flow<List<Item>> = flowOf(mockItems)
}
Loading
Loading