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,23 +39,23 @@ 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")
@ -65,7 +68,7 @@ internal class MyLocationListTest {
} }
@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
@ -73,15 +76,15 @@ internal class MyLocationListTest {
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)
@ -90,7 +93,7 @@ internal class MyLocationListTest {
} }
@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
@ -98,15 +101,15 @@ internal class MyLocationListTest {
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")
@ -120,18 +123,18 @@ internal class MyLocationListTest {
} }
@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")
@ -140,20 +143,6 @@ internal class MyLocationListTest {
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: ComposeContentTestRule) { private class MyLocationListRobot(private val composableRule: ComposeTestRule) {
fun clickOnItemWithText(text: String) { fun clickOnItemWithText(text: String) {
composableRule composableRule
.onNodeWithText(text) .onNodeWithText(text)
.performClick() .performClick()
} }
fun verifyNoLocationsPlaceHolderVisible() { fun verifyNoLocationsPlaceHolderVisible() {
composableRule composableRule
.onNodeWithText("No locations added yet. Go and add the first location.") .onNodeWithText("No locations added yet. Go and add the first location.")
.assertExists() .assertExists()
composableRule composableRule
.onNodeWithContentDescription("Empty list icon.") .onNodeWithContentDescription("Empty list icon.")
.assertExists() .assertExists()
} }
fun verifyListItemWithTextIsSelected(text: String) { fun verifyListItemWithTextIsSelected(text: String) {
composableRule composableRule
.onNodeWithText(text) .onNodeWithText(text)
.assertExists() .assertExists()
.assertIsSelected() .assertIsSelected()
} }
fun verifyItemDoesNotExist(text: String) { fun verifyItemDoesNotExist(text: String) {
composableRule composableRule
.onNodeWithText(text) .onNodeWithText(text)
.assertDoesNotExist() .assertDoesNotExist()
} }
fun verifyItemExists(text: String) { fun verifyItemExists(text: String) {
composableRule composableRule
.onNodeWithText(text) .onNodeWithText(text)
.assertExists() .assertExists()
}
} }
} }