Skip to content

Commit

Permalink
Merge pull request #53 from manununhez/feature/52-add-ui-tests
Browse files Browse the repository at this point in the history
feature/52-add-ui-tests
  • Loading branch information
manununhez authored Apr 26, 2024
2 parents 2e3d9f7 + 3c23a1a commit 3be3eb1
Show file tree
Hide file tree
Showing 43 changed files with 717 additions and 170 deletions.
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

0 comments on commit 3be3eb1

Please sign in to comment.