I am trying to write down the UI test case for my composable i.e.
package com.lbg.project.presentation.ui.view
import NavigationScreens
import android.annotation.SuppressLint
import android.widget.Toast
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.lazy.staggeredgrid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import coil.annotation.ExperimentalCoilApi
import com.lbg.project.R
import com.lbg.project.data.models.mappers.CatDataModel
import com.lbg.project.presentation.contracts.BaseContract
import com.lbg.project.presentation.contracts.CatContract
import com.lbg.project.presentation.ui.theme.ComposeSampleTheme
import com.lbg.project.utils.TestTags.PROGRESS_BAR
import com.skydoves.landscapist.CircularReveal
import com.skydoves.landscapist.ShimmerParams
import com.skydoves.landscapist.glide.GlideImage
import getBottomNavigationItems
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onEach
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(ExperimentalMaterial3Api::class)
@ExperimentalCoilApi
@Composable
fun CatScreen(
state: CatContract.State,
effectFlow: Flow<BaseContract.Effect>?,
onNavigationRequested: (itemUrl: String, imageId: String,isFavourite:Boolean) -> Unit
) {
val snackBarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
val catMessage = stringResource(R.string.cats_are_loaded)
//initializing the default selected item
var navigationSelectedItem by remember {
mutableIntStateOf(0)
}
/**
* by using the rememberNavController()
* we can get the instance of the navController
*/
val navController = rememberNavController()
// Listen for side effects from the VM
LaunchedEffect(effectFlow) {
effectFlow?.onEach { effect ->
if (effect is BaseContract.Effect.DataWasLoaded)
snackBarHostState.showSnackbar(
message = catMessage,
duration = SnackbarDuration.Short
)
}?.collect { value ->
if (value is BaseContract.Effect.Error) {
// Handle other emitted values if needed
Toast.makeText(context, value.errorMessage, Toast.LENGTH_LONG).show()
}
}
}
Scaffold(
topBar = {
CatAppBar()
}, bottomBar = {
NavigationBar {
//getting the list of bottom navigation items for our data class
getBottomNavigationItems(context).forEachIndexed { index, navigationItem ->
//iterating all items with their respective indexes
NavigationBarItem(
selected = index == navigationSelectedItem,
label = {
Text(navigationItem.title)
},
icon = {
Icon(
navigationItem.icon,
contentDescription = navigationItem.title
)
},
onClick = {
navigationSelectedItem = index
navController.navigate(navigationItem.screenRoute) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
}
) { paddingValues ->
//We need to setup our NavHost in here
NavHost(
navController = navController,
startDestination = NavigationScreens.Home.screenRoute,
modifier = Modifier.padding(paddingValues = paddingValues)
) {
composable(NavigationScreens.Home.screenRoute) {
UserView(
state,
false,
onNavigationRequested = onNavigationRequested
)
}
composable(NavigationScreens.MyFavorites.screenRoute) {
UserView(
state,
true,
onNavigationRequested = onNavigationRequested
)
}
}
}
}
@Composable
fun UserView(
state: CatContract.State,
isFavCatsCall: Boolean,
onNavigationRequested: (itemUrl: String, imageId: String,isFavourite:Boolean) -> Unit
) {
Surface {
Box {
val cats = if (isFavCatsCall) state.favCatsList else state.cats
CatsList(cats = cats) { itemUrl, imageId ->
onNavigationRequested(itemUrl, imageId,isFavCatsCall)
}
if (state.isLoading)
LoadingBar()
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CatAppBar() {
TopAppBar(
navigationIcon = {
Icon(
imageVector = Icons.Default.Home,
modifier = Modifier.padding(horizontal = 12.dp),
contentDescription = stringResource(R.string.action_icon)
)
},
title = {
Text(
text = stringResource(R.string.app_name),
color = colorResource(id = R.color.white)
)
},
colors = TopAppBarDefaults.smallTopAppBarColors(
containerColor = colorResource(R.color.colorPrimary),
titleContentColor = Color(R.color.white),
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
actionIconContentColor = MaterialTheme.colorScheme.onSecondary
)
)
}
@Composable
fun CatsList(
cats: List<CatDataModel>,
onItemClicked: (url: String, imageId: String) -> Unit = { _: String, _: String -> }
) {
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(2),
horizontalArrangement = Arrangement.spacedBy(2.dp),
content = {
this.items(cats) { item ->
Card(
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(
defaultElevation = 6.dp
),
border = BorderStroke(0.5.dp, Color.Gray),
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp)
.clickable { onItemClicked(item.url, item.imageId) }
) {
ItemThumbnail(thumbnailUrl = item.url)
}
}
}, modifier = Modifier.fillMaxSize()
)
}
@Composable
fun ItemThumbnail(
thumbnailUrl: String
) {
GlideImage(
imageModel = thumbnailUrl,
modifier = Modifier
.wrapContentSize()
.wrapContentHeight()
.fillMaxWidth(),
// shows a progress indicator when loading an image.
contentScale = ContentScale.Crop,
circularReveal = CircularReveal(duration = 100),
shimmerParams = ShimmerParams(
baseColor = MaterialTheme.colorScheme.background,
highlightColor = Color.Gray,
durationMillis = 500,
dropOff = 0.55f,
tilt = 20f
), contentDescription = stringResource(R.string.cat_thumbnail_picture)
)
}
@Composable
fun LoadingBar() {
Box( modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator( modifier = Modifier.testTag(PROGRESS_BAR) )
}
}
@OptIn(ExperimentalCoilApi::class)
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
ComposeSampleTheme {
CatScreen(CatContract.State(), null) { _: String, _: String,_:Boolean-> }
}
}
I tried to test the LIst like this
package com.lbg.project.lbgTest.view
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.lazy.staggeredgrid.items
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import com.lbg.project.R
import com.lbg.project.presentation.contracts.CatContract
import com.lbg.project.presentation.features.cats.CatsActivity
import com.lbg.project.presentation.ui.view.CatScreen
import com.lbg.project.presentation.ui.view.ItemThumbnail
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class CatsScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<CatsActivity>()
@coil.annotation.ExperimentalCoilApi
@Before
fun setUp() {
composeTestRule.setContent {
CatScreen(
state = CatContract.State(isLoading = true), // Set loading state
effectFlow = null,
onNavigationRequested = { _, _, _ -> /* Handle navigation in the test */ }
)
}
}
@Test
fun lazyVerticalStaggeredGrid_imagesAreLoaded() {
// Arrange
val imageUrls = listOf("https://images.freeimages.com/images/large-previews/d4f/www-1242368.jpg",
"https://images.freeimages.com/images/large-previews/636/holding-a-dot-com-iii-1411477.jpg",
"https://cdn.pixabay.com/photo/2022/01/11/21/48/link-6931554_1280.png",
"https://cdn.pixabay.com/photo/2020/09/19/19/37/landscape-5585247_1280.jpg"
)
// Act
composeTestRule.setContent {
LazyVerticalStaggeredGrid(columns = StaggeredGridCells.Fixed(2),content = {
this.items(imageUrls) { imageUrl ->
ItemThumbnail(thumbnailUrl = imageUrl)
}
})
}
// Assert
imageUrls.forEach { _ ->
composeTestRule.onNodeWithContentDescription(composeTestRule.activity.getString(R.string.cat_thumbnail_picture)).assertExists()
}
}
}
However when I run the test case here I am getting
java.lang.NullPointerException: FINGERPRINT must not be null
at androidx.compose.ui.test.AndroidComposeUiTestEnvironment.runTest(ComposeUiTest.android.kt:310)
at androidx.compose.ui.test.junit4.AndroidComposeTestRule$apply$1.evaluate(AndroidComposeTestRule.android.kt:271)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)
at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329)
at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.ParentRunner.run(ParentRunner.java:413)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestClassExecutor.runTestClass(JUnitTestClassExecutor.java:108)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestClassExecutor.execute(JUnitTestClassExecutor.java:58)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestClassExecutor.execute(JUnitTestClassExecutor.java:40)
at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestClassProcessor.processTestClass(AbstractJUnitTestClassProcessor.java:60)
at org.gradle.api.internal.tasks.testing.SuiteTestClassProcessor.processTestClass(SuiteTestClassProcessor.java:52)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:568)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:36)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:24)
at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33)
at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:94)
at jdk.proxy1/jdk.proxy1.$Proxy2.processTestClass(Unknown Source)
at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:176)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:129)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:100)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:60)
at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:113)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:65)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
This is error log for my UI run
How to write UI test case for this composable,that would be highly appriciated and thank you in aadvance.?
What went wrong and how to write correct UI test case for this composable file?
I was facing the issues and added
@RunWith(RobolectricTestRunner::class)
at the top of my test class and it worked. Like this:You'd find out more from the docs here: https://robolectric.org