From 25d2d2513252d7744940585526d3502d5e40d862 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Thu, 31 Jul 2025 15:25:39 +0200 Subject: [PATCH] Extract ComposeBasedTestCase class for setting up Compose test --- .../plugins/template/ComposeBasedTestCase.kt | 46 +++++ .../plugins/template/MyLocationListTest.kt | 195 +++++++++--------- 2 files changed, 138 insertions(+), 103 deletions(-) create mode 100644 src/test/kotlin/org/jetbrains/plugins/template/ComposeBasedTestCase.kt diff --git a/src/test/kotlin/org/jetbrains/plugins/template/ComposeBasedTestCase.kt b/src/test/kotlin/org/jetbrains/plugins/template/ComposeBasedTestCase.kt new file mode 100644 index 0000000..2524a49 --- /dev/null +++ b/src/test/kotlin/org/jetbrains/plugins/template/ComposeBasedTestCase.kt @@ -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() + } + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt b/src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt index 297af5c..5e04ce7 100644 --- a/src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt +++ b/src/test/kotlin/org/jetbrains/plugins/template/MyLocationListTest.kt @@ -5,30 +5,33 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.test.assertIsSelected 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.onNodeWithText import androidx.compose.ui.test.performClick import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.test.runTest import org.jetbrains.jewel.intui.standalone.theme.IntUiTheme import org.jetbrains.plugins.template.weatherApp.model.Location import org.jetbrains.plugins.template.weatherApp.model.SelectableLocation import org.jetbrains.plugins.template.weatherApp.services.MyLocationsViewModelApi import org.jetbrains.plugins.template.weatherApp.ui.MyLocationsListWithEmptyListPlaceholder -import org.junit.Rule import org.junit.Test -internal class MyLocationListTest { - @get:Rule - val composeRule = createComposeRule() +internal class MyLocationListTest : ComposeBasedTestCase() { private val noLocations = emptyList() private val myLocationsViewModelApi = FakeMyLocationsViewModel(locations = noLocations) + override val contentUnderTest: @Composable () -> Unit = { + MyLocationsListWithEmptyListPlaceholder( + modifier = Modifier.fillMaxWidth(), + myLocationsViewModelApi = myLocationsViewModelApi + ) + } + @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) myLocationsRobot @@ -36,124 +39,110 @@ internal class MyLocationListTest { } @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) - locationsViewModelApi.onAddLocation(Location("Munich", "Germany")) + myLocationsViewModelApi.onAddLocation(Location("Munich", "Germany")) myLocationsRobot .verifyListItemWithTextIsSelected("Munich, Germany") } @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) - + // Add multiple locations - locationsViewModelApi.onAddLocation(Location("Munich", "Germany")) - locationsViewModelApi.onAddLocation(Location("Berlin", "Germany")) - locationsViewModelApi.onAddLocation(Location("Paris", "France")) - + myLocationsViewModelApi.onAddLocation(Location("Munich", "Germany")) + myLocationsViewModelApi.onAddLocation(Location("Berlin", "Germany")) + myLocationsViewModelApi.onAddLocation(Location("Paris", "France")) + // Initially, the last added location (Paris) should be selected myLocationsRobot.verifyListItemWithTextIsSelected("Paris, France") - + // Select a different location myLocationsRobot.clickOnItemWithText("Berlin, Germany") - + // Verify the clicked location is now selected myLocationsRobot.verifyListItemWithTextIsSelected("Berlin, Germany") } - + @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) - + // Add multiple locations val munich = Location("Munich", "Germany") val berlin = Location("Berlin", "Germany") val paris = Location("Paris", "France") - - locationsViewModelApi.onAddLocation(munich) - locationsViewModelApi.onAddLocation(berlin) - locationsViewModelApi.onAddLocation(paris) - + + myLocationsViewModelApi.onAddLocation(munich) + myLocationsViewModelApi.onAddLocation(berlin) + myLocationsViewModelApi.onAddLocation(paris) + // Initially, the last added location (Paris) should be selected myLocationsRobot.verifyListItemWithTextIsSelected("Paris, France") - + // Delete the selected location (Paris) - locationsViewModelApi.onDeleteLocation(paris) - + myLocationsViewModelApi.onDeleteLocation(paris) + // Verify Paris is no longer in the list and Berlin is now selected // (as it's the last item in the list after deletion) myLocationsRobot.verifyItemDoesNotExist("Paris, France") myLocationsRobot.verifyListItemWithTextIsSelected("Berlin, Germany") } - + @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) - + // Add three locations val munich = Location("Munich", "Germany") val berlin = Location("Berlin", "Germany") val paris = Location("Paris", "France") - - locationsViewModelApi.onAddLocation(munich) - locationsViewModelApi.onAddLocation(berlin) - locationsViewModelApi.onAddLocation(paris) - + + myLocationsViewModelApi.onAddLocation(munich) + myLocationsViewModelApi.onAddLocation(berlin) + myLocationsViewModelApi.onAddLocation(paris) + // Initially, the last added location (Paris) should be selected myLocationsRobot.verifyListItemWithTextIsSelected("Paris, France") - + // Delete the middle location (Berlin) - locationsViewModelApi.onDeleteLocation(berlin) - + myLocationsViewModelApi.onDeleteLocation(berlin) + // Verify Berlin is no longer in the list myLocationsRobot.verifyItemDoesNotExist("Berlin, Germany") - + // Verify Munich and Paris still exist myLocationsRobot.verifyItemExists("Munich, Germany") myLocationsRobot.verifyItemExists("Paris, France") - + // Paris should still be selected as it was the selected item before deletion myLocationsRobot.verifyListItemWithTextIsSelected("Paris, France") } - + @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) - + // Add one location val munich = Location("Munich", "Germany") - locationsViewModelApi.onAddLocation(munich) - + myLocationsViewModelApi.onAddLocation(munich) + // Verify the location is selected myLocationsRobot.verifyListItemWithTextIsSelected("Munich, Germany") - + // Delete the only location - locationsViewModelApi.onDeleteLocation(munich) - + myLocationsViewModelApi.onDeleteLocation(munich) + // Verify the location is no longer in the list myLocationsRobot.verifyItemDoesNotExist("Munich, Germany") - + // Verify the empty list placeholder is shown 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( locations: List = emptyList() ) : MyLocationsViewModelApi { @@ -161,13 +150,13 @@ internal class MyLocationListTest { private val locationsFlow = MutableStateFlow(locations.toMutableList()) private val selectedItemIndex = MutableStateFlow(if (locations.isNotEmpty()) 0 else -1) - private val _myLocations = locationsFlow .combine(selectedItemIndex) { locations, selectedIndex -> locations.mapIndexed { index, location -> SelectableLocation(location, index == selectedIndex) } } + override val myLocationsFlow: Flow> = _myLocations 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() - } -} \ No newline at end of file