diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/detail/DetailScreen.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/detail/DetailScreen.kt index 9089a6b4..51fdd8f7 100644 --- a/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/detail/DetailScreen.kt +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/detail/DetailScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +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 @@ -41,6 +42,13 @@ import me.bytebeats.views.charts.line.render.line.SolidLineDrawer import me.bytebeats.views.charts.line.render.point.EmptyPointDrawer import me.bytebeats.views.charts.simpleChartAnimation +const val TestTagDetailLogo = "DetailLogo" +const val TestTagDetailCircularProgress = "DetailCircularProgress" +const val TestTagDetailLineChart = "DetailLineChart" +const val TestTagDetailChartInterval = "DetailChartInterval" +const val TestTagDetailCoinInfo = "DetailCoinInfo" +const val TestTagDetailSellBuyGroup = "DetailSellBuyGroup" + @Composable fun DetailScreen( viewModel: DetailViewModel = hiltViewModel(), @@ -127,7 +135,8 @@ private fun DetailScreenContent( top.linkTo(appBar.bottom) linkTo(start = parent.start, end = parent.end) } - .padding(top = Dp8), + .padding(top = Dp8) + .testTag(tag = TestTagDetailLogo), painter = rememberAsyncImagePainter(coinDetailUiModel.image), contentDescription = null ) @@ -164,7 +173,8 @@ private fun DetailScreenContent( top.linkTo(priceChangePercent.bottom, margin = Dp24) start.linkTo(parent.start) end.linkTo(parent.end) - }, + } + .testTag(tag = TestTagDetailLineChart), lineChartData = LineChartData( points = coinPrices.map { coinPrice -> val price = stringResource( @@ -189,11 +199,13 @@ private fun DetailScreenContent( // Chart intervals ChartIntervalsButtonGroup( - modifier = Modifier.constrainAs(intervals) { - top.linkTo(graph.bottom, margin = Dp24) - start.linkTo(parent.start) - end.linkTo(parent.end) - }, + modifier = Modifier + .constrainAs(intervals) { + top.linkTo(graph.bottom, margin = Dp24) + start.linkTo(parent.start) + end.linkTo(parent.end) + } + .testTag(tag = TestTagDetailChartInterval), onIntervalChanged = onTimeIntervalsChanged::invoke ) @@ -216,7 +228,8 @@ private fun DetailScreenContent( top = parent.top, bottom = parent.bottom ) - }, + } + .testTag(tag = TestTagDetailCircularProgress), ) } } @@ -225,7 +238,8 @@ private fun DetailScreenContent( Box( modifier = Modifier .fillMaxSize() - .navigationBarsPadding(), + .navigationBarsPadding() + .testTag(tag = TestTagDetailSellBuyGroup), contentAlignment = Alignment.BottomEnd ) { SellBuyGroup( @@ -244,7 +258,11 @@ private fun CoinInfo( sellBuyLayoutHeight: Dp, coinDetailUiModel: CoinDetailUiModel ) { - Column(modifier = modifier.padding(start = Dp16, end = Dp16, bottom = sellBuyLayoutHeight)) { + Column( + modifier = modifier + .padding(start = Dp16, end = Dp16, bottom = sellBuyLayoutHeight) + .testTag(tag = TestTagDetailCoinInfo) + ) { DetailItem( modifier = Modifier, title = stringResource(id = R.string.detail_market_cap_title), diff --git a/app/src/test/java/co/nimblehq/compose/crypto/ui/screens/detail/DetailScreenTest.kt b/app/src/test/java/co/nimblehq/compose/crypto/ui/screens/detail/DetailScreenTest.kt new file mode 100644 index 00000000..515a1cf6 --- /dev/null +++ b/app/src/test/java/co/nimblehq/compose/crypto/ui/screens/detail/DetailScreenTest.kt @@ -0,0 +1,176 @@ +package co.nimblehq.compose.crypto.ui.screens.detail + +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.GetCoinDetailUseCase +import co.nimblehq.compose.crypto.domain.usecase.GetCoinPricesUseCase +import co.nimblehq.compose.crypto.extension.toFormattedString +import co.nimblehq.compose.crypto.test.MockUtil +import co.nimblehq.compose.crypto.ui.components.chartintervals.TimeIntervals +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 co.nimblehq.compose.crypto.ui.uimodel.toUiModel +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +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 +import kotlin.math.abs + +@RunWith(RobolectricTestRunner::class) +@ExperimentalCoroutinesApi +class DetailScreenTest : BaseViewModelTest() { + + @get:Rule + val composeAndroidTestRule = createAndroidComposeRule() + + private val marketCapTitle: String + get() = composeAndroidTestRule.activity.getString(R.string.detail_market_cap_title) + + private val allTimeHighTitle: String + get() = composeAndroidTestRule.activity.getString(R.string.detail_all_time_high_title) + + private val allTimeLowTitle: String + get() = composeAndroidTestRule.activity.getString(R.string.detail_all_time_low_title) + + private val errorGeneric: String + get() = composeAndroidTestRule.activity.getString(R.string.error_generic) + + private val mockGetCoinDetailUseCase = mockk() + private val mockGetCoinPricesUseCase = mockk() + + private lateinit var viewModel: DetailViewModel + + private var appDestination: AppDestination? = null + + private val coinDetailUiModel = MockUtil.coinDetail.toUiModel() + + @Before + fun setUp() { + every { mockGetCoinDetailUseCase.execute(any()) } returns flowOf(MockUtil.coinDetail) + every { mockGetCoinPricesUseCase.execute(any()) } returns flowOf(emptyList()) + + initViewModel() + + composeAndroidTestRule.activity.setContent { + DetailScreen( + viewModel = viewModel, + navigator = { destination -> appDestination = destination }, + coinId = "Bitcoin" + ) + } + } + + @Test + fun `When entering to the DetailScreen, it shows the loading properly`() { + every { mockGetCoinDetailUseCase.execute(any()) } returns flow { delay(100) } + + composeAndroidTestRule.onNodeWithTag( + testTag = TestTagDetailCircularProgress + ).assertIsDisplayed() + } + + @Test + fun `When entering to the DetailScreen and GetCoinDetail successfully, it renders the HeaderDetail properly`() { + with(composeAndroidTestRule) { + onNodeWithTag(testTag = TestTagDetailLogo).assertIsDisplayed() + + onNodeWithText(coinDetailUiModel.coinName).assertIsDisplayed() + onNodeWithText( + "$${coinDetailUiModel.currentPrice.toFormattedString()}" + ).assertIsDisplayed() + onNodeWithText( + "${abs(coinDetailUiModel.priceChangePercentage24hInCurrency).toFormattedString()}%" + ).assertIsDisplayed() + } + } + + @Test + fun `When entering to the DetailScreen and GetCoinDetail successfully, it renders the Chart properly`() { + with(composeAndroidTestRule) { + onNodeWithTag(testTag = TestTagDetailLineChart).assertExists() + onNodeWithTag(testTag = TestTagDetailChartInterval).assertExists() + + onNodeWithText(TimeIntervals.ONE_DAY.text).assertIsDisplayed().assertHasClickAction() + onNodeWithText(TimeIntervals.ONE_WEEK.text).assertIsDisplayed().assertHasClickAction() + onNodeWithText(TimeIntervals.ONE_MONTH.text).assertIsDisplayed().assertHasClickAction() + onNodeWithText(TimeIntervals.ONE_YEAR.text).assertIsDisplayed().assertHasClickAction() + onNodeWithText(TimeIntervals.FIVE_YEAR.text).assertIsDisplayed().assertHasClickAction() + } + } + + @Test + fun `When entering to the DetailScreen and GetCoinDetail successfully, it renders the CoinInfo properly`() { + with(composeAndroidTestRule) { + onNodeWithTag(testTag = TestTagDetailCoinInfo).assertExists() + + onNodeWithText(marketCapTitle).assertIsDisplayed() + onNodeWithText(allTimeHighTitle).assertIsDisplayed() + onNodeWithText(allTimeLowTitle).assertIsDisplayed() + + onNodeWithText("$${coinDetailUiModel.marketCap.toFormattedString()}").assertIsDisplayed() + onNodeWithText("$${coinDetailUiModel.ath.toFormattedString()}").assertIsDisplayed() + onNodeWithText("$${coinDetailUiModel.atl.toFormattedString()}").assertIsDisplayed() + + onNodeWithText( + "${abs(coinDetailUiModel.marketCapChangePercentage24h).toFormattedString()}%" + ).assertIsDisplayed() + onNodeWithText( + "${abs(coinDetailUiModel.athChangePercentage).toFormattedString()}%" + ).assertIsDisplayed() + onNodeWithText( + "${abs(coinDetailUiModel.atlChangePercentage).toFormattedString()}%" + ).assertIsDisplayed() + } + } + + @Test + fun `When entering to the DetailScreen and GetCoinDetail successfully, it renders the SellBuyGroup properly`() { + composeAndroidTestRule.onNodeWithTag(testTag = TestTagDetailSellBuyGroup).assertExists() + } + + @Test + fun `When entering to the DetailScreen and GetCoinDetail failed, it shows the error message properly`() { + every { mockGetCoinDetailUseCase.execute(any()) } returns flow { + throw Throwable(errorGeneric) + } + + with(composeAndroidTestRule) { + onNodeWithText(coinDetailUiModel.coinName).assertDoesNotExist() + onNodeWithText( + "$${coinDetailUiModel.currentPrice.toFormattedString()}" + ).assertDoesNotExist() + onNodeWithText( + "${abs(coinDetailUiModel.priceChangePercentage24hInCurrency).toFormattedString()}%" + ).assertDoesNotExist() + + onNodeWithTag(testTag = TestTagDetailLogo).assertDoesNotExist() + onNodeWithTag(testTag = TestTagDetailLineChart).assertDoesNotExist() + onNodeWithTag(testTag = TestTagDetailChartInterval).assertDoesNotExist() + onNodeWithTag(testTag = TestTagDetailCoinInfo).assertDoesNotExist() + onNodeWithTag(testTag = TestTagDetailSellBuyGroup).assertDoesNotExist() + } + + ShadowToast.getTextOfLatestToast() shouldBe errorGeneric + } + + private fun initViewModel() { + viewModel = DetailViewModel( + dispatchers = testDispatcherProvider, + getCoinDetailUseCase = mockGetCoinDetailUseCase, + getCoinPricesUseCase = mockGetCoinPricesUseCase + ) + } +}