Extract ComposeBasedTestCase class for setting up Compose test

This commit is contained in:
Nebojsa Vuksic 2025-07-31 15:25:39 +02:00
parent 45ef9a28da
commit 25d2d25132
2 changed files with 138 additions and 103 deletions

View File

@ -0,0 +1,46 @@
package org.jetbrains.plugins.template
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.junit4.createComposeRule
import kotlinx.coroutines.test.runTest
import org.jetbrains.jewel.intui.standalone.theme.IntUiTheme
import org.junit.Rule
/**
* An abstract base class for Compose-based test cases.
*
* This class provides a framework for running Compose UI tests. It includes a
* test rule for composing UI content and abstracts the content under test.
*/
internal abstract class ComposeBasedTestCase {
@get:Rule
val composableRule = createComposeRule()
/**
* Provides the Composable Content under test.
*/
abstract val contentUnderTest: @Composable () -> Unit
/**
* Runs the given Compose test block in the context of a Compose content test rule.
*/
fun runComposeTest(block: suspend ComposeTestRule.() -> Unit) = runTest {
composableRule.setContentWrappedInTheme {
contentUnderTest()
}
composableRule.block()
}
private fun ComposeContentTestRule.setContentWrappedInTheme(content: @Composable () -> Unit) {
setContent {
IntUiTheme {
content()
}
}
}
}

View File

@ -5,30 +5,33 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.test.runTest
import org.jetbrains.jewel.intui.standalone.theme.IntUiTheme import org.jetbrains.jewel.intui.standalone.theme.IntUiTheme
import org.jetbrains.plugins.template.weatherApp.model.Location import org.jetbrains.plugins.template.weatherApp.model.Location
import org.jetbrains.plugins.template.weatherApp.model.SelectableLocation import org.jetbrains.plugins.template.weatherApp.model.SelectableLocation
import org.jetbrains.plugins.template.weatherApp.services.MyLocationsViewModelApi import org.jetbrains.plugins.template.weatherApp.services.MyLocationsViewModelApi
import org.jetbrains.plugins.template.weatherApp.ui.MyLocationsListWithEmptyListPlaceholder import org.jetbrains.plugins.template.weatherApp.ui.MyLocationsListWithEmptyListPlaceholder
import org.junit.Rule
import org.junit.Test import org.junit.Test
internal class MyLocationListTest { internal class MyLocationListTest : ComposeBasedTestCase() {
@get:Rule
val composeRule = createComposeRule()
private val noLocations = emptyList<Location>() private val noLocations = emptyList<Location>()
private val myLocationsViewModelApi = FakeMyLocationsViewModel(locations = noLocations) private val myLocationsViewModelApi = FakeMyLocationsViewModel(locations = noLocations)
override val contentUnderTest: @Composable () -> Unit = {
MyLocationsListWithEmptyListPlaceholder(
modifier = Modifier.fillMaxWidth(),
myLocationsViewModelApi = myLocationsViewModelApi
)
}
@Test @Test
fun `verify placeholder is shown when no locations is added`() = composeRule.runComposeTest { fun `verify placeholder is shown when no locations is added`() = runComposeTest {
val myLocationsRobot = MyLocationListRobot(this) val myLocationsRobot = MyLocationListRobot(this)
myLocationsRobot myLocationsRobot
@ -36,124 +39,110 @@ internal class MyLocationListTest {
} }
@Test @Test
fun `verify location is selected when user adds location`() = composeRule.runComposeTest { locationsViewModelApi -> fun `verify location is selected when user adds location`() = runComposeTest {
val myLocationsRobot = MyLocationListRobot(this) val myLocationsRobot = MyLocationListRobot(this)
locationsViewModelApi.onAddLocation(Location("Munich", "Germany")) myLocationsViewModelApi.onAddLocation(Location("Munich", "Germany"))
myLocationsRobot myLocationsRobot
.verifyListItemWithTextIsSelected("Munich, Germany") .verifyListItemWithTextIsSelected("Munich, Germany")
} }
@Test @Test
fun `verify item selection when multiple items are present`() = composeRule.runComposeTest { locationsViewModelApi -> fun `verify item selection when multiple items are present`() = runComposeTest {
val myLocationsRobot = MyLocationListRobot(this) val myLocationsRobot = MyLocationListRobot(this)
// Add multiple locations // Add multiple locations
locationsViewModelApi.onAddLocation(Location("Munich", "Germany")) myLocationsViewModelApi.onAddLocation(Location("Munich", "Germany"))
locationsViewModelApi.onAddLocation(Location("Berlin", "Germany")) myLocationsViewModelApi.onAddLocation(Location("Berlin", "Germany"))
locationsViewModelApi.onAddLocation(Location("Paris", "France")) myLocationsViewModelApi.onAddLocation(Location("Paris", "France"))
// Initially, the last added location (Paris) should be selected // Initially, the last added location (Paris) should be selected
myLocationsRobot.verifyListItemWithTextIsSelected("Paris, France") myLocationsRobot.verifyListItemWithTextIsSelected("Paris, France")
// Select a different location // Select a different location
myLocationsRobot.clickOnItemWithText("Berlin, Germany") myLocationsRobot.clickOnItemWithText("Berlin, Germany")
// Verify the clicked location is now selected // Verify the clicked location is now selected
myLocationsRobot.verifyListItemWithTextIsSelected("Berlin, Germany") myLocationsRobot.verifyListItemWithTextIsSelected("Berlin, Germany")
} }
@Test @Test
fun `verify item deletion when multiple items are present`() = composeRule.runComposeTest { locationsViewModelApi -> fun `verify item deletion when multiple items are present`() = runComposeTest {
val myLocationsRobot = MyLocationListRobot(this) val myLocationsRobot = MyLocationListRobot(this)
// Add multiple locations // Add multiple locations
val munich = Location("Munich", "Germany") val munich = Location("Munich", "Germany")
val berlin = Location("Berlin", "Germany") val berlin = Location("Berlin", "Germany")
val paris = Location("Paris", "France") val paris = Location("Paris", "France")
locationsViewModelApi.onAddLocation(munich) myLocationsViewModelApi.onAddLocation(munich)
locationsViewModelApi.onAddLocation(berlin) myLocationsViewModelApi.onAddLocation(berlin)
locationsViewModelApi.onAddLocation(paris) myLocationsViewModelApi.onAddLocation(paris)
// Initially, the last added location (Paris) should be selected // Initially, the last added location (Paris) should be selected
myLocationsRobot.verifyListItemWithTextIsSelected("Paris, France") myLocationsRobot.verifyListItemWithTextIsSelected("Paris, France")
// Delete the selected location (Paris) // Delete the selected location (Paris)
locationsViewModelApi.onDeleteLocation(paris) myLocationsViewModelApi.onDeleteLocation(paris)
// Verify Paris is no longer in the list and Berlin is now selected // Verify Paris is no longer in the list and Berlin is now selected
// (as it's the last item in the list after deletion) // (as it's the last item in the list after deletion)
myLocationsRobot.verifyItemDoesNotExist("Paris, France") myLocationsRobot.verifyItemDoesNotExist("Paris, France")
myLocationsRobot.verifyListItemWithTextIsSelected("Berlin, Germany") myLocationsRobot.verifyListItemWithTextIsSelected("Berlin, Germany")
} }
@Test @Test
fun `verify middle item deletion when three items are present`() = composeRule.runComposeTest { locationsViewModelApi -> fun `verify middle item deletion when three items are present`() = runComposeTest {
val myLocationsRobot = MyLocationListRobot(this) val myLocationsRobot = MyLocationListRobot(this)
// Add three locations // Add three locations
val munich = Location("Munich", "Germany") val munich = Location("Munich", "Germany")
val berlin = Location("Berlin", "Germany") val berlin = Location("Berlin", "Germany")
val paris = Location("Paris", "France") val paris = Location("Paris", "France")
locationsViewModelApi.onAddLocation(munich) myLocationsViewModelApi.onAddLocation(munich)
locationsViewModelApi.onAddLocation(berlin) myLocationsViewModelApi.onAddLocation(berlin)
locationsViewModelApi.onAddLocation(paris) myLocationsViewModelApi.onAddLocation(paris)
// Initially, the last added location (Paris) should be selected // Initially, the last added location (Paris) should be selected
myLocationsRobot.verifyListItemWithTextIsSelected("Paris, France") myLocationsRobot.verifyListItemWithTextIsSelected("Paris, France")
// Delete the middle location (Berlin) // Delete the middle location (Berlin)
locationsViewModelApi.onDeleteLocation(berlin) myLocationsViewModelApi.onDeleteLocation(berlin)
// Verify Berlin is no longer in the list // Verify Berlin is no longer in the list
myLocationsRobot.verifyItemDoesNotExist("Berlin, Germany") myLocationsRobot.verifyItemDoesNotExist("Berlin, Germany")
// Verify Munich and Paris still exist // Verify Munich and Paris still exist
myLocationsRobot.verifyItemExists("Munich, Germany") myLocationsRobot.verifyItemExists("Munich, Germany")
myLocationsRobot.verifyItemExists("Paris, France") myLocationsRobot.verifyItemExists("Paris, France")
// Paris should still be selected as it was the selected item before deletion // Paris should still be selected as it was the selected item before deletion
myLocationsRobot.verifyListItemWithTextIsSelected("Paris, France") myLocationsRobot.verifyListItemWithTextIsSelected("Paris, France")
} }
@Test @Test
fun `verify deletion of the only item in list`() = composeRule.runComposeTest { locationsViewModelApi -> fun `verify deletion of the only item in list`() = runComposeTest {
val myLocationsRobot = MyLocationListRobot(this) val myLocationsRobot = MyLocationListRobot(this)
// Add one location // Add one location
val munich = Location("Munich", "Germany") val munich = Location("Munich", "Germany")
locationsViewModelApi.onAddLocation(munich) myLocationsViewModelApi.onAddLocation(munich)
// Verify the location is selected // Verify the location is selected
myLocationsRobot.verifyListItemWithTextIsSelected("Munich, Germany") myLocationsRobot.verifyListItemWithTextIsSelected("Munich, Germany")
// Delete the only location // Delete the only location
locationsViewModelApi.onDeleteLocation(munich) myLocationsViewModelApi.onDeleteLocation(munich)
// Verify the location is no longer in the list // Verify the location is no longer in the list
myLocationsRobot.verifyItemDoesNotExist("Munich, Germany") myLocationsRobot.verifyItemDoesNotExist("Munich, Germany")
// Verify the empty list placeholder is shown // Verify the empty list placeholder is shown
myLocationsRobot.verifyNoLocationsPlaceHolderVisible() myLocationsRobot.verifyNoLocationsPlaceHolderVisible()
} }
private fun ComposeContentTestRule.runComposeTest(
myLocationsViewModelApi: MyLocationsViewModelApi = this@MyLocationListTest.myLocationsViewModelApi,
block: suspend ComposeContentTestRule.(MyLocationsViewModelApi) -> Unit
) = runTest {
this@runComposeTest.setContentWrappedInTheme {
MyLocationsListWithEmptyListPlaceholder(
modifier = Modifier.fillMaxWidth(),
myLocationsViewModelApi = myLocationsViewModelApi
)
}
this@runComposeTest.block(myLocationsViewModelApi)
}
private class FakeMyLocationsViewModel( private class FakeMyLocationsViewModel(
locations: List<Location> = emptyList() locations: List<Location> = emptyList()
) : MyLocationsViewModelApi { ) : MyLocationsViewModelApi {
@ -161,13 +150,13 @@ internal class MyLocationListTest {
private val locationsFlow = MutableStateFlow(locations.toMutableList()) private val locationsFlow = MutableStateFlow(locations.toMutableList())
private val selectedItemIndex = MutableStateFlow(if (locations.isNotEmpty()) 0 else -1) private val selectedItemIndex = MutableStateFlow(if (locations.isNotEmpty()) 0 else -1)
private val _myLocations = locationsFlow private val _myLocations = locationsFlow
.combine(selectedItemIndex) { locations, selectedIndex -> .combine(selectedItemIndex) { locations, selectedIndex ->
locations.mapIndexed { index, location -> locations.mapIndexed { index, location ->
SelectableLocation(location, index == selectedIndex) SelectableLocation(location, index == selectedIndex)
} }
} }
override val myLocationsFlow: Flow<List<SelectableLocation>> = _myLocations override val myLocationsFlow: Flow<List<SelectableLocation>> = _myLocations
override fun onAddLocation(locationToAdd: Location) { override fun onAddLocation(locationToAdd: Location) {
@ -198,42 +187,42 @@ internal class MyLocationListTest {
} }
} }
} }
private class MyLocationListRobot(private val composableRule: ComposeTestRule) {
fun clickOnItemWithText(text: String) {
composableRule
.onNodeWithText(text)
.performClick()
}
fun verifyNoLocationsPlaceHolderVisible() {
composableRule
.onNodeWithText("No locations added yet. Go and add the first location.")
.assertExists()
composableRule
.onNodeWithContentDescription("Empty list icon.")
.assertExists()
}
fun verifyListItemWithTextIsSelected(text: String) {
composableRule
.onNodeWithText(text)
.assertExists()
.assertIsSelected()
}
fun verifyItemDoesNotExist(text: String) {
composableRule
.onNodeWithText(text)
.assertDoesNotExist()
}
fun verifyItemExists(text: String) {
composableRule
.onNodeWithText(text)
.assertExists()
}
}
} }
private class MyLocationListRobot(private val composableRule: ComposeContentTestRule) {
fun clickOnItemWithText(text: String) {
composableRule
.onNodeWithText(text)
.performClick()
}
fun verifyNoLocationsPlaceHolderVisible() {
composableRule
.onNodeWithText("No locations added yet. Go and add the first location.")
.assertExists()
composableRule
.onNodeWithContentDescription("Empty list icon.")
.assertExists()
}
fun verifyListItemWithTextIsSelected(text: String) {
composableRule
.onNodeWithText(text)
.assertExists()
.assertIsSelected()
}
fun verifyItemDoesNotExist(text: String) {
composableRule
.onNodeWithText(text)
.assertDoesNotExist()
}
fun verifyItemExists(text: String) {
composableRule
.onNodeWithText(text)
.assertExists()
}
}