diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d2a4a114..1229e7b5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -102,6 +102,12 @@ android { xmlReport = true xmlOutput = file("build/reports/lint/lint-result.xml") } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } kapt { @@ -158,6 +164,8 @@ dependencies { testImplementation("io.mockk:mockk:${Versions.TEST_MOCKK_VERSION}") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:${Versions.TEST_COROUTINES_VERSION}") testImplementation("app.cash.turbine:turbine:${Versions.TEST_TURBINE_VERSION}") + testImplementation ("androidx.compose.ui:ui-test-junit4:${Versions.COMPOSE_VERSION}") + testImplementation ("org.robolectric:robolectric:${Versions.TEST_ROBOLECTRIC_VERSION}") kaptTest("com.google.dagger:hilt-android-compiler:${Versions.HILT_VERSION}") testAnnotationProcessor("com.google.dagger:hilt-android-compiler:${Versions.HILT_VERSION}") diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/CoinItem.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/CoinItem.kt index cfb21501..3af47e19 100644 --- a/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/CoinItem.kt +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/CoinItem.kt @@ -14,6 +14,7 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -38,13 +39,19 @@ import co.nimblehq.compose.crypto.ui.theme.Style.textColor import co.nimblehq.compose.crypto.ui.uimodel.CoinItemUiModel import coil.compose.rememberAsyncImagePainter +const val TestTagCoinItemSymbol = "CoinItemCoinSymbol" +const val TestTagCoinItemCoinName = "CoinItemCoinName" +const val TestTagCoinItemPrice = "CoinItemPrice" +const val TestTagCoinItemPriceChange = "CoinItemPriceChange" + @Composable fun CoinItem( + modifier: Modifier = Modifier, coinItem: CoinItemUiModel, onItemClick: () -> Unit ) { ConstraintLayout( - modifier = Modifier + modifier = modifier .wrapContentWidth() .clip(RoundedCornerShape(Dp12)) .clickable { onItemClick.invoke() } @@ -76,7 +83,8 @@ fun CoinItem( .constrainAs(coinSymbol) { top.linkTo(parent.top) start.linkTo(anchor = logo.end, margin = Dp16) - }, + } + .testTag(tag = TestTagCoinItemSymbol), text = coinItem.symbol.uppercase(), color = MaterialTheme.colors.textColor, style = Style.semiBold16() @@ -89,7 +97,8 @@ fun CoinItem( start.linkTo(coinSymbol.start) top.linkTo(coinSymbol.bottom) width = Dimension.preferredWrapContent - }, + } + .testTag(tag = TestTagCoinItemCoinName), text = coinItem.coinName, color = MaterialTheme.colors.coinNameColor, style = Style.medium14() @@ -101,7 +110,8 @@ fun CoinItem( start.linkTo(logo.start) top.linkTo(anchor = coinName.bottom, margin = Dp14) width = Dimension.preferredWrapContent - }, + } + .testTag(tag = TestTagCoinItemPrice), text = stringResource( R.string.coin_currency, coinItem.currentPrice.toFormattedString() @@ -119,6 +129,7 @@ fun CoinItem( bottom.linkTo(parent.bottom) width = Dimension.preferredWrapContent } + .testTag(tag = TestTagCoinItemPriceChange) ) } } diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/HomeScreen.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/HomeScreen.kt index cbf02107..bf748cf4 100644 --- a/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/HomeScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -42,6 +43,11 @@ import timber.log.Timber private const val LIST_ITEM_LOAD_MORE_THRESHOLD = 0 +const val TestTagHomeTitle = "HomeTitle" +const val TestTagTrendingItem = "TrendingItem" +const val TestTagCoinItem = "CoinItem" +const val TestTagCoinsLoader = "CoinsLoader" + @Composable fun HomeScreen( viewModel: HomeViewModel = hiltViewModel(), @@ -49,14 +55,11 @@ fun HomeScreen( ) { val context = LocalContext.current var rememberRefreshing by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - viewModel.output.error.collect { error -> - val message = error.userReadableMessage(context) - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() - } - } + LaunchedEffect(viewModel) { - viewModel.output.navigator.collect { destination -> navigator(destination) } + viewModel.output.navigator.collect { destination -> + navigator(destination) + } } LaunchedEffect(viewModel.showLoading) { viewModel.showLoading.collect { isRefreshing -> @@ -68,6 +71,16 @@ fun HomeScreen( val showTrendingCoinsLoading: LoadingState by viewModel.output.showTrendingCoinsLoading.collectAsState() val myCoins: List by viewModel.output.myCoins.collectAsState() val trendingCoins: List by viewModel.output.trendingCoins.collectAsState() + val myCoinsError: Throwable? by viewModel.output.myCoinsError.collectAsState() + val trendingCoinsError: Throwable? by viewModel.output.trendingCoinsError.collectAsState() + + myCoinsError?.let { error -> + Toast.makeText(context, error.userReadableMessage(context), Toast.LENGTH_SHORT).show() + } + + trendingCoinsError?.let { error -> + Toast.makeText(context, error.userReadableMessage(context), Toast.LENGTH_SHORT).show() + } HomeScreenContent( showMyCoinsLoading = showMyCoinsLoading, @@ -117,7 +130,8 @@ private fun HomeScreenContent( Text( modifier = Modifier .fillMaxWidth() - .padding(top = Dp16), + .padding(top = Dp16) + .testTag(TestTagHomeTitle), text = stringResource(id = R.string.home_title), textAlign = TextAlign.Center, style = Style.semiBold24(), @@ -173,7 +187,8 @@ private fun HomeScreenContent( CircularProgressIndicator( modifier = Modifier .fillMaxWidth() - .wrapContentWidth(align = Alignment.CenterHorizontally), + .wrapContentWidth(align = Alignment.CenterHorizontally) + .testTag(tag = TestTagCoinsLoader), ) } } else { @@ -191,6 +206,7 @@ private fun HomeScreenContent( ) ) { TrendingItem( + modifier = Modifier.testTag(tag = TestTagTrendingItem), coinItem = coin, onItemClick = { onTrendingItemClick.invoke(coin) } ) @@ -206,6 +222,7 @@ private fun HomeScreenContent( .fillMaxWidth() .wrapContentWidth(align = Alignment.CenterHorizontally) .padding(bottom = Dp16) + .testTag(tag = TestTagCoinsLoader), ) } } @@ -269,7 +286,8 @@ private fun MyCoins( .constrainAs(myCoins) { top.linkTo(myCoinsTitle.bottom, margin = Dp16) linkTo(start = parent.start, end = parent.end) - }, + } + .testTag(tag = TestTagCoinsLoader), ) } else { LazyRow( @@ -283,6 +301,7 @@ private fun MyCoins( ) { items(coins) { coin -> CoinItem( + modifier = Modifier.testTag(tag = TestTagCoinItem), coinItem = coin, onItemClick = { onMyCoinsItemClick.invoke(coin) } ) diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/HomeViewModel.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/HomeViewModel.kt index b1c45813..a75e3322 100644 --- a/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/HomeViewModel.kt @@ -38,6 +38,10 @@ interface Output : BaseOutput { val myCoins: StateFlow> val trendingCoins: StateFlow> + + val myCoinsError: SharedFlow + + val trendingCoinsError: SharedFlow } @HiltViewModel @@ -66,6 +70,14 @@ class HomeViewModel @Inject constructor( override val trendingCoins: StateFlow> get() = _trendingCoins + private val _myCoinsError = MutableStateFlow(null) + override val myCoinsError: StateFlow + get() = _myCoinsError + + private val _trendingCoinsError = MutableStateFlow(null) + override val trendingCoinsError: StateFlow + get() = _trendingCoinsError + private var trendingCoinsPage = MY_COINS_INITIAL_PAGE init { @@ -81,7 +93,11 @@ class HomeViewModel @Inject constructor( } private fun getMyCoins(isRefreshing: Boolean) = execute { - if (isRefreshing) showLoading() else _showMyCoinsLoading.value = true + if (isRefreshing) { + showLoading() + } else { + _showMyCoinsLoading.value = true + } getMyCoinsUseCase.execute( GetMyCoinsUseCase.Input( currency = FIAT_CURRENCY, @@ -92,7 +108,7 @@ class HomeViewModel @Inject constructor( ) ) .catch { e -> - _error.emit(e) + _myCoinsError.emit(e) } .collect { coins -> _myCoins.emit(coins.map { it.toUiModel() }) @@ -115,7 +131,7 @@ class HomeViewModel @Inject constructor( ) ) .catch { e -> - _error.emit(e) + _trendingCoinsError.emit(e) } .collect { coins -> val newCoinList = coins.map { it.toUiModel() } @@ -126,8 +142,7 @@ class HomeViewModel @Inject constructor( } trendingCoinsPage++ } - if (isRefreshing) hideLoading() else - _showTrendingCoinsLoading.value = LoadingState.Idle + if (isRefreshing) hideLoading() else _showTrendingCoinsLoading.value = LoadingState.Idle } } diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/PortfolioCard.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/PortfolioCard.kt index 95a1286a..ca443556 100644 --- a/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/PortfolioCard.kt +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/PortfolioCard.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.constraintlayout.compose.ConstraintLayout @@ -20,6 +21,11 @@ import co.nimblehq.compose.crypto.ui.theme.Dimension.Dp16 import co.nimblehq.compose.crypto.ui.theme.Dimension.Dp40 import co.nimblehq.compose.crypto.ui.theme.Dimension.Dp8 +const val TestTagTotalCoinsLabel = "CardTotalCoinsLabel" +const val TestTagTodayCoinProfitLabel = "todayProfitLabel" +const val TestTagCardTotalCoins = "CardTotalCoins" +const val TestTagCardTodayProfit = "CardTodayProfit" + @Composable fun PortfolioCard( modifier: Modifier @@ -47,7 +53,8 @@ fun PortfolioCard( modifier = Modifier .constrainAs(totalCoinsLabel) { start.linkTo(parent.start) - }, + } + .testTag(TestTagTotalCoinsLabel), text = stringResource(R.string.portfolio_card_total_coin_label), style = Style.lightSilverMedium16() ) @@ -56,7 +63,8 @@ fun PortfolioCard( modifier = Modifier .constrainAs(totalCoins) { top.linkTo(totalCoinsLabel.bottom, margin = Dp8) - }, + } + .testTag(tag = TestTagCardTotalCoins), // TODO: Remove dummy value when work on Integrate. text = stringResource(R.string.coin_currency, "7,273,291"), style = Style.whiteSemiBold24() @@ -66,7 +74,8 @@ fun PortfolioCard( modifier = Modifier .constrainAs(todayProfitLabel) { top.linkTo(totalCoins.bottom, margin = Dp40) - }, + } + .testTag(tag = TestTagTodayCoinProfitLabel), text = stringResource(R.string.portfolio_card_today_profit_label), style = Style.lightSilverMedium16() ) @@ -75,7 +84,8 @@ fun PortfolioCard( modifier = Modifier .constrainAs(todayProfit) { top.linkTo(todayProfitLabel.bottom, margin = Dp8) - }, + } + .testTag(tag = TestTagCardTodayProfit), // TODO: Remove dummy value when work on Integrate. text = stringResource(R.string.coin_currency, "193,280"), style = Style.whiteSemiBold24() @@ -95,7 +105,7 @@ fun PortfolioCard( @Composable @Preview -fun PortfolioCardPreview() { +private fun PortfolioCardPreview() { ComposeTheme { PortfolioCard( modifier = Modifier diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/TrendingItem.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/TrendingItem.kt index 70754669..2424fe1a 100644 --- a/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/TrendingItem.kt +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/TrendingItem.kt @@ -14,6 +14,7 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.constraintlayout.compose.ConstraintLayout @@ -33,14 +34,20 @@ import co.nimblehq.compose.crypto.ui.theme.Style.textColor import co.nimblehq.compose.crypto.ui.uimodel.CoinItemUiModel import coil.compose.rememberAsyncImagePainter +const val TestTagTrendingItemSymbol = "TrendingItemSymbol" +const val TestTagTrendingItemCoinName = "TrendingItemCoinName" +const val TestTagTrendingItemPriceChange = "TrendingItemPriceChange" + +@Suppress("LongMethod") @Composable fun TrendingItem( + modifier: Modifier = Modifier, coinItem: CoinItemUiModel, onItemClick: () -> Unit ) { ConstraintLayout( - modifier = Modifier + modifier = modifier .fillMaxWidth() .clip(RoundedCornerShape(Dp12)) .clickable { onItemClick.invoke() } @@ -76,7 +83,8 @@ fun TrendingItem( top.linkTo(parent.top) bottom.linkTo(coinName.top) start.linkTo(anchor = logo.end, margin = Dp16) - }, + } + .testTag(tag = TestTagTrendingItemSymbol), text = coinItem.symbol.uppercase(), color = MaterialTheme.colors.textColor, style = Style.semiBold16() @@ -89,7 +97,8 @@ fun TrendingItem( top.linkTo(coinSymbol.bottom) bottom.linkTo(parent.bottom) width = Dimension.preferredWrapContent - }, + } + .testTag(tag = TestTagTrendingItemCoinName), text = coinItem.coinName, color = MaterialTheme.colors.coinNameColor, style = Style.medium14() @@ -104,6 +113,7 @@ fun TrendingItem( bottom.linkTo(coinName.bottom) width = Dimension.preferredWrapContent } + .testTag(tag = TestTagTrendingItemPriceChange) ) } } diff --git a/app/src/test/java/co/nimblehq/compose/crypto/ui/screens/home/HomeScreenTest.kt b/app/src/test/java/co/nimblehq/compose/crypto/ui/screens/home/HomeScreenTest.kt new file mode 100644 index 00000000..298cceaf --- /dev/null +++ b/app/src/test/java/co/nimblehq/compose/crypto/ui/screens/home/HomeScreenTest.kt @@ -0,0 +1,266 @@ +package co.nimblehq.compose.crypto.ui.screens.home + +import androidx.activity.compose.setContent +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.* +import androidx.navigation.* +import co.nimblehq.compose.crypto.R +import co.nimblehq.compose.crypto.domain.usecase.GetMyCoinsUseCase +import co.nimblehq.compose.crypto.domain.usecase.GetTrendingCoinsUseCase +import co.nimblehq.compose.crypto.extension.toFormattedString +import co.nimblehq.compose.crypto.test.MockUtil +import co.nimblehq.compose.crypto.ui.navigation.AppDestination +import co.nimblehq.compose.crypto.ui.screens.BaseViewModelTest +import co.nimblehq.compose.crypto.ui.screens.MainActivity +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.shadows.ShadowToast + +@RunWith(RobolectricTestRunner::class) +@ExperimentalCoroutinesApi +class HomeScreenTest : BaseViewModelTest() { + + @get:Rule + val composeAndroidTestRule = createAndroidComposeRule() + + private val homeTitle: String + get() = composeAndroidTestRule.activity.getString(R.string.home_title) + + private val totalCoinsLabel: String + get() = composeAndroidTestRule.activity.getString(R.string.portfolio_card_total_coin_label) + + private val todayProfitLabel: String + get() = composeAndroidTestRule.activity.getString(R.string.portfolio_card_today_profit_label) + + private val errorGeneric: String + get() = composeAndroidTestRule.activity.getString(R.string.error_generic) + + private val expectedPriceChange: String + get() = composeAndroidTestRule.activity.getString( + R.string.coin_profit_percent, + MockUtil.trendingCoins.first().priceChangePercentage24hInCurrency.toFormattedString() + ) + + private val mockGetMyCoinsUseCase = mockk() + private val mockGetTrendingCoinsUseCase = mockk() + + private lateinit var viewModel: HomeViewModel + + private var appDestination: AppDestination? = null + + @Before + fun setUp() { + composeAndroidTestRule.activity.setContent { + HomeScreen( + viewModel = viewModel, + navigator = { destination -> appDestination = destination } + ) + } + } + + @Test + fun `When enter to HomeScreen, it render the PortfolioCard properly`() { + initViewModel() + + with(composeAndroidTestRule) { + onNodeWithTag(testTag = TestTagHomeTitle).assertTextEquals(homeTitle) + onNodeWithTag(testTag = TestTagTotalCoinsLabel).assertTextEquals(totalCoinsLabel) + onNodeWithTag(testTag = TestTagTodayCoinProfitLabel).assertTextEquals(todayProfitLabel) + onNodeWithTag(testTag = TestTagCardTotalCoins).assertTextEquals("$7,273,291") + onNodeWithTag(testTag = TestTagCardTodayProfit).assertTextEquals("$193,280") + } + } + + @Test + fun `When enter to HomeScreen and load MyCoins successfully, it render the UI properly`() { + every { mockGetMyCoinsUseCase.execute(any()) } returns flowOf(MockUtil.myCoins) + + initViewModel() + + with(composeAndroidTestRule) { + onNodeWithTag(testTag = TestTagCoinsLoader).assertIsDisplayed() + + with(MockUtil.myCoins.first()) { + onAllNodesWithTag( + testTag = TestTagCoinItemSymbol, + useUnmergedTree = true + ).onFirst().assertTextEquals(symbol.uppercase()) + + onAllNodesWithTag( + testTag = TestTagCoinItemCoinName, + useUnmergedTree = true + ).onFirst().assertTextEquals(coinName) + + onAllNodesWithTag( + testTag = TestTagCoinItemPriceChange, + useUnmergedTree = true + ).onFirst().onChild().assertTextEquals(expectedPriceChange) + } + } + } + + @Test + fun `When enter to HomeScreen and load TrendingCoins successfully, it render the UI properly`() { + every { mockGetTrendingCoinsUseCase.execute(any()) } returns flowOf(MockUtil.trendingCoins) + + initViewModel() + + with(composeAndroidTestRule) { + onNodeWithTag(testTag = TestTagCoinsLoader).assertIsDisplayed() + + with(MockUtil.trendingCoins.first()) { + onAllNodesWithTag( + testTag = TestTagTrendingItemSymbol, + useUnmergedTree = true + ).onFirst().assertTextEquals(symbol.uppercase()) + + onAllNodesWithTag( + testTag = TestTagTrendingItemCoinName, + useUnmergedTree = true + ).onFirst().assertTextEquals(coinName) + + onAllNodesWithTag( + testTag = TestTagTrendingItemPriceChange, + useUnmergedTree = true + ).onFirst().onChild().assertTextEquals(expectedPriceChange) + } + } + } + + @Test + fun `When clicked on MyCoin item, it navigates to DetailScreen`() { + every { mockGetMyCoinsUseCase.execute(any()) } returns flowOf(MockUtil.myCoins) + + initViewModel() + + composeAndroidTestRule.onAllNodesWithTag(testTag = TestTagCoinItem).onFirst().performClick() + + appDestination shouldBe AppDestination.CoinDetail + } + + @Test + fun `When clicked on TrendingCoin item, it navigates to DetailScreen`() { + every { mockGetTrendingCoinsUseCase.execute(any()) } returns flowOf(MockUtil.trendingCoins) + + initViewModel() + + composeAndroidTestRule.onAllNodesWithTag( + testTag = TestTagTrendingItem + ).onFirst().performClick() + + appDestination shouldBe AppDestination.CoinDetail + } + + @Test + fun `When enter to HomeScreen and load MyCoins failed, it shows the Toast properly`() { + every { mockGetMyCoinsUseCase.execute(any()) } returns flow { + throw Throwable(errorGeneric) + } + + initViewModel() + + composeAndroidTestRule.onNodeWithTag( + testTag = TestTagCoinItem, + useUnmergedTree = true + ).assertDoesNotExist() + + ShadowToast.getTextOfLatestToast() shouldBe errorGeneric + } + + @Test + fun `When enter to HomeScreen and load TrendingCoins failed, it shows the Toast properly`() { + every { mockGetTrendingCoinsUseCase.execute(any()) } returns flow { + throw Throwable(errorGeneric) + } + + initViewModel() + + composeAndroidTestRule.onNodeWithTag( + testTag = TestTagTrendingItem, + useUnmergedTree = true + ).assertDoesNotExist() + + ShadowToast.getTextOfLatestToast() shouldBe errorGeneric + } + + @Test + fun `When pulled to refresh and load MyCoins successfully, it render the UI properly`() { + every { mockGetMyCoinsUseCase.execute(any()) } returns flowOf(MockUtil.myCoins) + + initViewModel() + + with(composeAndroidTestRule) { + with(MockUtil.myCoins.first()) { + onAllNodesWithTag( + testTag = TestTagCoinItemSymbol, + useUnmergedTree = true + ).onFirst().assertTextEquals(symbol.uppercase()) + + onAllNodesWithTag( + testTag = TestTagCoinItemCoinName, + useUnmergedTree = true + ).onFirst().assertTextEquals(coinName) + + onAllNodesWithTag( + testTag = TestTagCoinItemPriceChange, + useUnmergedTree = true + ).onFirst().onChild().assertTextEquals(expectedPriceChange) + } + + onRoot().performTouchInput { swipeDown() } + } + + verify(exactly = 2) { mockGetMyCoinsUseCase.execute(any()) } + } + + @Test + fun `When pulled to refresh and load TrendingCoins successfully, it render the UI properly`() { + every { mockGetTrendingCoinsUseCase.execute(any()) } returns flowOf(MockUtil.trendingCoins) + + initViewModel() + + with(composeAndroidTestRule) { + onNodeWithTag(testTag = TestTagCoinsLoader).assertIsDisplayed() + + with(MockUtil.trendingCoins.first()) { + onAllNodesWithTag( + testTag = TestTagTrendingItemSymbol, + useUnmergedTree = true + ).onFirst().assertTextEquals(symbol.uppercase()) + + onAllNodesWithTag( + testTag = TestTagTrendingItemCoinName, + useUnmergedTree = true + ).onFirst().assertTextEquals(coinName) + + onAllNodesWithTag( + testTag = TestTagTrendingItemPriceChange, + useUnmergedTree = true + ).onFirst().onChild().assertTextEquals(expectedPriceChange) + } + + onRoot().performTouchInput { swipeDown() } + } + + verify(exactly = 5) { mockGetTrendingCoinsUseCase.execute(any()) } + } + + private fun initViewModel() { + viewModel = HomeViewModel( + dispatchers = testDispatcherProvider, + getMyCoinsUseCase = mockGetMyCoinsUseCase, + getTrendingCoinsUseCase = mockGetTrendingCoinsUseCase + ) + } +} diff --git a/app/src/test/java/co/nimblehq/compose/crypto/ui/screens/home/HomeViewModelTest.kt b/app/src/test/java/co/nimblehq/compose/crypto/ui/screens/home/HomeViewModelTest.kt index af2a57ff..dd3a3d1c 100644 --- a/app/src/test/java/co/nimblehq/compose/crypto/ui/screens/home/HomeViewModelTest.kt +++ b/app/src/test/java/co/nimblehq/compose/crypto/ui/screens/home/HomeViewModelTest.kt @@ -60,7 +60,7 @@ class HomeViewModelTest : BaseViewModelTest() { val error = Throwable() every { mockGetMyCoinsUseCase.execute(any()) } returns flow { throw error } - viewModel.output.error.test { + viewModel.output.myCoinsError.test { testDispatcher.resumeDispatcher() expectMostRecentItem() shouldBe error cancelAndIgnoreRemainingEvents() @@ -88,7 +88,7 @@ class HomeViewModelTest : BaseViewModelTest() { val error = Throwable() every { mockGetTrendingCoinsUseCase.execute(any()) } returns flow { throw error } - viewModel.output.error.test { + viewModel.output.trendingCoinsError.test { testDispatcher.resumeDispatcher() expectMostRecentItem() shouldBe error cancelAndIgnoreRemainingEvents() diff --git a/app/src/test/resources/robolectric.properties b/app/src/test/resources/robolectric.properties new file mode 100644 index 00000000..d64ece90 --- /dev/null +++ b/app/src/test/resources/robolectric.properties @@ -0,0 +1,5 @@ +# Similar to Galaxy Nexus device profile +qualifiers=w360dp-h640dp-xhdpi +# Workaround for issue https://github.com/robolectric/robolectric/issues/6593 +instrumentedPackages=androidx.loader.content +sdk=30 diff --git a/buildSrc/src/main/java/Versions.kt b/buildSrc/src/main/java/Versions.kt index f54fc0dc..dd1367d6 100644 --- a/buildSrc/src/main/java/Versions.kt +++ b/buildSrc/src/main/java/Versions.kt @@ -51,6 +51,7 @@ object Versions { const val TEST_JUNIT_VERSION = "4.13.2" const val TEST_KOTEST_VERSION = "4.6.3" const val TEST_MOCKK_VERSION = "1.10.6" + const val TEST_ROBOLECTRIC_VERSION = "4.8.2" const val TEST_RUNNER_VERSION = "1.3.0" const val TEST_TURBINE_VERSION = "0.7.0" }