I'm trying to write the test for my composes. So I have a test class put in AndroidTest just like this:
#HiltAndroidTest
#UninstallModules(AuthenticationModule::class, AppModule::class)
class AuthenticationScreenTest {
#get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
#get:Rule(order = 1)
val composeRule = createAndroidComposeRule<MainActivity>()
#Inject
lateinit var setting: Setting
#Before
fun setup() {
hiltRule.inject()
composeRule.setContent {
val navController = rememberNavController()
RefectoryTheme {
NavHost(
navController = navController,
startDestination = AuthenticationNavGraph.AuthenticationScreen.route
) {
composable(AuthenticationNavGraph.AuthenticationScreen.route) {
AuthenticationScreen(navController = navController, setting = setting)
}
}
}
}
}
#Test
fun checkLoadingButtonExpantion() {
composeRule.onNodeWithTag(testTag = AUTHENTICATION_SCREEN_LOGIN_BUTTON)
.assertIsDisplayed()
}
}
but I keep getting the error:
androidx.compose.ui.test.junit4.android.ComposeNotIdleException: Idling resource timed out:
possibly due to compose being busy.
IdlingResourceRegistry has the following idling resources registered:
- [busy] androidx.compose.ui.test.junit4.ComposeIdlingResource#a005df5
All registered idling resources: Compose-Espresso link
The android emulator is launched, test compiles successfully, but it seems it can't find the object.
I also have added a test tag to the modifier of the object:
LoadingButton(
buttonText = stringResource(id = R.string.login),
isExpanded = state.isLoginExpanded,
modifier = Modifier
.padding(MaterialTheme.spacing.medium)
.align(Alignment.CenterHorizontally)
.testTag(AUTHENTICATION_SCREEN_LOGIN_BUTTON)
) {
viewModel.onEvent(AuthenticationEvent.Login)
}
But after 28 seconds, I got the error as mentioned above.
What am I missing?
I just realized what the problem was.
I am using Lottie on my screen, and the animation is infinity repeating.
So I don't know why but it seems that it doesn't allow testing to go through.
The tests ran without any problem when I commented the Lottie section.
Related
I'm running into a problem when trying to navigate with argument in my very first compose project
Error:
java.lang.IllegalArgumentException: Navigation destination that matches request NavDeepLinkRequest{ uri=android-app://androidx.navigation/transaction_detail/{1} } cannot be found in the navigation graph NavGraph...
My NavGraph:
#Composable
fun SetupNavGraph(
navController: NavHostController
) {
NavHost(
navController = navController,
startDestination = HomeDestination.route,
) {
composable(route = HomeDestination.route) {
HomeScreen(
navigateToItemEntry = { navController.navigate(TransactionEntryDestination.route) },
navigateToItemUpdate = {
navController.navigate("${TransactionDetailDestination.route}/{$it}")
}
)
}
//detail screen route
composable(
route = TransactionDetailDestination.routeWithArgs,
arguments = listOf(
navArgument(TransactionDetailDestination.transactionIdArg) {
type = NavType.IntType
}
)
) {
val id = it.arguments?.getInt(TransactionDetailDestination.transactionIdArg)!!
TransactionDetailScreen(id)
}
}
}
My transaction detail screen:
object TransactionDetailDestination : NavigationDestination {
override val route = "transaction_detail"
override val title = "Transaction Detail Screen"
const val transactionIdArg = "transactionId"
val routeWithArgs = "$route/{$transactionIdArg}"
}
#Composable
fun TransactionDetailScreen(id: Int) {
Scaffold {
TransactionDetailBody(paddingValues = it, id = id)
}
}
#Composable
fun TransactionDetailBody(
paddingValues: PaddingValues,
id: Int
) {
Column(modifier = Modifier.fillMaxSize()) {
Text(text = "$id", fontSize = 100.sp)
...
}
}
I can see that the problem is the route to transaction detail destination, but I don't know where to correct. I'm looking forward to every suggestion!
By research on internet a lot a realize that when specify the route to go, in my case, always like this:
//'it' is the argument we need to send
//rule: 'route/value1/value2...' where 'value' is what we trying to send over
navController.navigate("${TransactionDetailDestination.route}/$it")
The string of the route we need to extract the argument(s) from:
//notice the naming rule: 'route/{arg1}/{arg2}/...'
val routeWithArgs = "${route}/{${transactionIdArg}}"
Only be doing the above the compiler will understand the argument you are trying to send and receive. My mistake not reading carefully. Hope it helps!
I think you didn't declare your destination argument in your graph like this
composable("transaction_detail/{id}")
according to this documentation
I am using Hilt dependency injection to retrieve data from a Firestore Database. I have a getResources coroutine which is called when by the init in my viewModel. In my view, I attempt to get the first x elements of the populated data. However, the app crashes with this error.
java.lang.IndexOutOfBoundsException: Empty list doesn't contain element at index 0.
at kotlin.collections.EmptyList.get(Collections.kt:36)
at kotlin.collections.EmptyList.get(Collections.kt:24)
I am guessing that the data load from Firestore has not completed, so when I try and get resources[i], it's empty and it crashes. This happens even when I do a null check. I cannot init{} without a coroutine and if I try and use a suspend function it doesn't like it either. How do I make the view wait until the viewmodel has the loaded data?
ViewModel.kt
#HiltViewModel
class ResourcesViewModel #Inject constructor(
private val repository: ResourcesRepository
) : ViewModel() {
val data: MutableState<DataOrException<List<Resource>, Exception>> = mutableStateOf(
DataOrException(
listOf(),
Exception("")
)
)
init {
getResources()
}
private fun getResources() {
viewModelScope.launch {
Log.d("getting resources", "currently getting resources")
data.value = repository.getResourcesFromFireStore()
Log.d("complete","complete")
}
}
}
ResourcesRepository.kt
#Singleton
class ResourcesRepository #Inject constructor(
private val db: FirebaseFirestore
) {
val resources = ArrayList<Resource>()
suspend fun getResourcesFromFireStore(): DataOrException<List<Resource>, Exception> {
val resourcesRef: CollectionReference = db.collection("updated-resources-new")
val dataOrException = DataOrException<List<Resource>, Exception>()
try {
dataOrException.data = resourcesRef.get().await().map { document ->
document.toObject(Resource::class.java)
}
Log.d(TAG, "${dataOrException.data}")
} catch (e: FirebaseFirestoreException) {
dataOrException.e = e
}
View.kt
#Composable
fun ResourcesScreenContent(viewModel: ResourcesViewModel) {
LazyColumn (
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 20.dp)
) {
val resources : List<Resource>? = viewModel.data.value.data
items(30) {
for (i in 0 ..30) {
ExpandableCard(resource = resources!![i])
Divider(thickness = 20.dp, color = Color.White)
}
}
}
}
How do I fix this?
you're doing one minor mistake, in forloop you are using 0...30 so it will run 30 times irrespective of resources list size. so in for loop you have to pass resources.count
#Composable
fun ResourcesScreenContent(viewModel: ResourcesViewModel) {
LazyColumn (
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 20.dp)
) {
val resources : List<Resource>? = viewModel.data.value.data
items(resources.count) { i ->
ExpandableCard(resource = resources!![i])
Divider(thickness = 20.dp, color = Color.White)
}
}
}
Hope it is clear my brother
I have a project with several flavors. Each of these flavors has its own configuration which is available as a json file in the assets folder in the respective project structure.
In the theme definition I read the JSON using Gson and cast it into a corresponding model.
My problem is now the following:
At runtime of the app this all works wonderfully but in the composable preview in Android Studio it unfortunately only works for a single flavor. As soon as I switch to another flavor in the build variant, the json-asset of the old variant continues to load. Since the configuration also contains assets that are only available in the respective flavors, this leads to a crash of the preview.
I debugged the preview handling by throwing some exceptions during the casting and it seems, like if there'S something cached and not reset after build-variant change. A restart of Android Studio didn't also help so I don't quite know what to do about it.
Has anyone noticed a similar behavior and/or found a solution for it?
Here is some code to explain::
My theme definition:
object AppTheme {
val colors: AppColors
#Composable
#ReadOnlyComposable
get() = LocalAppColors.current
val typography: AppTypography
#Composable
#ReadOnlyComposable
get() = LocalAppTypography.current
val configuration: ConfigurationC
#Composable
#ReadOnlyComposable
get() = LocalAppConfiguration.current
}
private val LocalAppColors = staticCompositionLocalOf {
lightAppColors
}
private val LocalAppTypography = staticCompositionLocalOf {
appTypography
}
private val LocalAppConfiguration = staticCompositionLocalOf {
ConfigurationC()
}
#Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: #Composable () -> Unit,
) {
val colors = if (darkTheme) darkAppColors else lightAppColors
CompositionLocalProvider(
LocalAppConfiguration provides ConfigurationC.init(LocalContext.current),
LocalAppColors provides colors,
LocalAppTypography provides typography,
) {
MaterialTheme(
colors = colors.materialColors,
typography = typography.materialTypography,
content = content,
)
}
}
A simple Preview:
#Composable
#Preview(name = "light", showBackground = true)
#Preview(name = "dark", showBackground = true, uiMode = UI_MODE_NIGHT_YES)
fun EnabledPreview() {
AppTheme {
Button.MyCustomButton(
modifier = Modifier,
title = "Custom Button",
font = AppTheme.configuration.font.h1
color = AppTheme.configuration.colors.text1
enabled = enabled,
onClick = {}
)
}
}
I'm loading an image using Coil for Compose like below.
#Composable
fun SvgImageSample() {
val painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(LocalContext.current)
.decoderFactory(SvgDecoder.Factory())
.data("https://someserver.com/SVG_image.svg")
.size(Size.ORIGINAL)
.build()
)
Image(
painter = painter,
modifier = Modifier.size(100.dp).testTag("myImg"),
contentDescription = null
)
}
The image is loaded properly. Now, I would like to write a test to check if the image was loaded. Is there any assertion out-of-the-box for that?
Something like this:
class MyTest {
#get:Rule
val composeTestRule = createComposeRule()
#Test
fun checkIfTheImageLoads() {
composeTestRule.setContent {
MyAppThemeTheme {
SvgImageSample()
}
}
composeTestRule.onNodeWithTag("myImg")
.assertCoilImageIsLoaded() // <- this is what I want
}
}
I found what I was looking for... Please let me know if anyone has a better solution.
This is what I did:
Add this dependency in your build.gradle.
implementation "androidx.test.espresso.idling:idling-concurrent:3.5.0-alpha07"
This is necessary to use the IdlingThreadPoolExecutor class.
Declare the an IdlingThreadPool object like below:
object IdlingThreadPool: IdlingThreadPoolExecutor(
"coroutinesDispatchersThreadPool",
Runtime.getRuntime().availableProcessors(),
Runtime.getRuntime().availableProcessors(),
0L,
TimeUnit.MILLISECONDS,
LinkedBlockingQueue(),
Executors.defaultThreadFactory()
)
I get this hint from this issue in the Coil github page.
Use the object declared above in the ImageRequest object.
#Composable
fun SvgImageSample() {
val painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(LocalContext.current)
.dispatcher(IdlingThreadPool.asCoroutineDispatcher()) // << here
.decoderFactory(SvgDecoder.Factory())
.data("https://someserver.com/SVG_image.svg")
.size(Size.ORIGINAL)
.build()
)
Image(
painter = painter,
modifier = Modifier
.size(100.dp)
.semantics {
testTag = "myImg"
coilAsyncPainter = painter
},
contentDescription = null
)
}
Notice the IdlingThreadPool object was used in the dispatcher function. The other detail is coilAsyncPainter property which is receiving the painter object. It will be necessary during the test to check if the image was loaded.
Declare the coilAsyncPainter semantic property.
val CoilAsyncPainter = SemanticsPropertyKey<AsyncImagePainter>("CoilAsyncPainter")
var SemanticsPropertyReceiver.coilAsyncPainter by CoilAsyncPainter
This is what you need to do in the application code.
In the test code, declare a new SemanticNodeInteration.
fun SemanticsNodeInteraction.isAsyncPainterComplete(): SemanticsNodeInteraction {
assert(
SemanticsMatcher("Async Image is Success") { semanticsNode ->
val painter = semanticsNode.config.getOrElseNullable(CoilAsyncPainter) { null }
painter?.state is AsyncImagePainter.State.Success
}
)
return this;
}
So here, basically the painter object is obtained from the semantic property and then is checked if the current state is Success.
Finally, here it is the test.
class MyTest {
#get:Rule
val composeTestRule = createComposeRule()
#Test
fun async_image_was_displayed() {
composeTestRule.setContent {
MyAppThemeTheme {
SvgImageSample()
}
}
composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag("myImg")
.isAsyncPainterComplete()
}
}
Another way would be to implement an EventListener and check the right events are emitted. Will save you using testTags and semantic properties in the app code.
https://coil-kt.github.io/coil/api/coil-base/coil-base/coil/-event-listener/index.html
A quick hacky attempt, but you could wrap this in a Composable helper that does this for any block passed in.
#Test
fun imageLoader() {
var success = 0
var errors = 0
composeTestRule.setContent {
Coil.setImageLoader(
ImageLoader.Builder(context)
.eventListener(object : EventListener {
override fun onSuccess(
request: ImageRequest,
result: SuccessResult
) {
success++
}
override fun onError(
request: ImageRequest,
result: ErrorResult
) {
errors++
}
})
.build()
)
MyAppThemeTheme {
SvgImageSample()
}
}
Thread.sleep(500)
assertThat(errors).isEqualTo(0)
assertThat(success).isEqualTo(1)
}
First, I have to say that the approach suggested by #nglauber worked. However, I cringed at that level of complexity for a simple test, so I tried a straight forward test and that works as well and I will keep so.
First, I loaded the image simply with AsyncImage
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(template.previewUrl)
.crossfade(true)
.build(),
placeholder = painterResource(template.thumbNailResId),
contentDescription = stringResource(R.string.template_description),
contentScale = ContentScale.Fit,
)
Then in the test, I simply checked for the node with content description is displayed like so
#Test
fun intialImageDisplayedTest() {
val template = TemplateRepository.getTemplate()[0]
composeTestRule.setContent {
val selectedIndex = remember{ mutableStateOf(-1) }
TemplateItem(
selectedId = selectedIndex,
template = template,
onPreviewButtonClicked = {}
)
}
composeTestRule.onNodeWithTag("template_${template.templateId}").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription(getImageDescriptionText()).assertIsDisplayed()
}
private fun getImageDescriptionText(): String {
return composeTestRule.activity.resources.getString(R.string.template_description)
}
Again keeping it simple. I also added a matcher with a test tag. No Idling resource needed.
I'm building an app in Jetpack Compose where users can set a personalized PIN that will be stored in sharedPrefs. Every time the app comes back to the foreground (and users have setup a PIN beforehand), the app should open the "enter PIN" screen. On app start, everything works fine and users are prompted to enter their PIN, but once the app goes into the background and then back into the foreground, the "enter PIN" screen is not shown anymore.
MainActivity
class MainActivity : ComponentActivity() {
private val openPinScreen = MutableStateFlow(false)
#Inject
lateinit var sharedPrefsRepository: SharedPrefsRepository
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val pinState = openPinScreen.collectAsState()
MyAppTheme {
MyApp(pinState.value)
}
}
}
override fun onResume() {
super.onResume()
sharedPrefsRepository.getPin()?.let {
openPinScreen.value = sharedPrefsRepository.hasSetPin()
}
}
}
fun MyApp(showPin: Boolean) {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = Route.Splash.route) {
composable(Route.Splash.route) {
SplashScreen {
navController.apply {
popBackStack()
navigate(Route.MainContent.route)
}
}
}
composable(Route.MainContent.route) {
MainContent(
modifier = Modifier
.fillMaxSize()
.background(Color.White),
isPinRequired = showPin
)
}
}
}
MainContent.kt
fun MainContent(
modifier: Modifier,
isPinRequired: Boolean,
viewModel: MainViewModel = hiltViewModel()
) {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val shouldOpenPinScreen by remember {
mutableStateOf(isPinRequired)
}
LaunchedEffect(isPinRequired){
if (shouldOpenPinScreen) navController.navigate(Route.Pincode.route)
}
Scaffold(...){
NavHost(
navController = navController,
startDestination = Route.Home.route
) {
composable(...) {...}
composable(...) {...}
composable(...) {...}
}
}
I've checked during debugging that everything works fine in MainActivity but there seems to be a problem when trying to get the value to the respective Composable. Specifically,
LaunchedEffect(isPinRequired){
if (shouldOpenPinScreen) navController.navigate(Route.Pincode.route)
}
is not called anymore and navigation to Route.Pincode.route is not triggered. Replacing LaunchedEffect(isPinRequired) with LaunchedEffect(Unit) also didn't help. Anyone got any ideas on how I could fix this?
The problem lays in these lines:
val shouldOpenPinScreen by remember {
mutableStateOf(isPinRequired)
}
shouldOpenPinScreen fixes the first value of isPinRequired and is not updated with the new value. You can pass isPinRequired as a key to remember, in which case the variable will be updated. But I generally don't see the point in having this variable, if it's always the same as isPinRequired, you can probably remove it and replace it by just using isPinRequired.