From a5ec97be486e7dee33047bbeeed29fa886aa546f Mon Sep 17 00:00:00 2001 From: WeiHe Date: Fri, 1 Dec 2023 00:02:40 +0800 Subject: [PATCH] test: Add Designsystem ScreenshotTests --- .../designsystem/BackgroundScreenshotTests.kt | 37 ++++ .../designsystem/NavigationScreenshotTests.kt | 184 ++++++++++++++++++ .../core/testing/util/MainDispatcherRule.kt | 28 +++ .../core/testing/util/ScreenshotHelper.kt | 170 ++++++++++++++++ .../core/testing/util/TestNetworkMonitor.kt | 19 ++ 5 files changed, 438 insertions(+) create mode 100644 core/designsystem/src/test/java/com/wei/picquest/core/designsystem/BackgroundScreenshotTests.kt create mode 100644 core/designsystem/src/test/java/com/wei/picquest/core/designsystem/NavigationScreenshotTests.kt create mode 100644 core/testing/src/main/java/com/wei/picquest/core/testing/util/MainDispatcherRule.kt create mode 100644 core/testing/src/main/java/com/wei/picquest/core/testing/util/ScreenshotHelper.kt create mode 100644 core/testing/src/main/java/com/wei/picquest/core/testing/util/TestNetworkMonitor.kt diff --git a/core/designsystem/src/test/java/com/wei/picquest/core/designsystem/BackgroundScreenshotTests.kt b/core/designsystem/src/test/java/com/wei/picquest/core/designsystem/BackgroundScreenshotTests.kt new file mode 100644 index 0000000..a977d68 --- /dev/null +++ b/core/designsystem/src/test/java/com/wei/picquest/core/designsystem/BackgroundScreenshotTests.kt @@ -0,0 +1,37 @@ +package com.wei.picquest.core.designsystem + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.dp +import com.wei.picquest.core.designsystem.component.PqBackground +import com.wei.picquest.core.testing.util.captureMultiTheme +import dagger.hilt.android.testing.HiltTestApplication +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.annotation.LooperMode + +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(application = HiltTestApplication::class, sdk = [33], qualifiers = "480dpi") +@LooperMode(LooperMode.Mode.PAUSED) +class BackgroundScreenshotTests { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Test + fun atBackground_multipleThemes() { + composeTestRule.captureMultiTheme("Background") { description -> + PqBackground(Modifier.size(100.dp)) { + Text("$description background") + } + } + } +} diff --git a/core/designsystem/src/test/java/com/wei/picquest/core/designsystem/NavigationScreenshotTests.kt b/core/designsystem/src/test/java/com/wei/picquest/core/designsystem/NavigationScreenshotTests.kt new file mode 100644 index 0000000..30fb38d --- /dev/null +++ b/core/designsystem/src/test/java/com/wei/picquest/core/designsystem/NavigationScreenshotTests.kt @@ -0,0 +1,184 @@ +package com.wei.picquest.core.designsystem + +import androidx.activity.ComponentActivity +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import com.github.takahirom.roborazzi.captureRoboImage +import com.google.accompanist.testharness.TestHarness +import com.wei.picquest.core.designsystem.component.PqNavigationBar +import com.wei.picquest.core.designsystem.component.PqNavigationBarItem +import com.wei.picquest.core.designsystem.component.PqNavigationDrawer +import com.wei.picquest.core.designsystem.component.PqNavigationDrawerItem +import com.wei.picquest.core.designsystem.component.PqNavigationRail +import com.wei.picquest.core.designsystem.component.PqNavigationRailItem +import com.wei.picquest.core.designsystem.icon.PqIcons +import com.wei.picquest.core.designsystem.theme.PqTheme +import com.wei.picquest.core.testing.util.DefaultRoborazziOptions +import com.wei.picquest.core.testing.util.captureMultiTheme +import dagger.hilt.android.testing.HiltTestApplication +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.annotation.LooperMode + +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(application = HiltTestApplication::class, sdk = [33], qualifiers = "480dpi") +@LooperMode(LooperMode.Mode.PAUSED) +class NavigationScreenshotTests() { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Test + fun navigationBar_multipleThemes() { + composeTestRule.captureMultiTheme("NavigationBar") { + Surface { + PqNavigationBarExample() + } + } + } + + @Test + fun navigationBar_hugeFont() { + composeTestRule.setContent { + CompositionLocalProvider( + LocalInspectionMode provides true, + ) { + TestHarness(fontScale = 2f) { + PqTheme { + PqNavigationBarExample("Looong item") + } + } + } + } + composeTestRule.onRoot() + .captureRoboImage( + "src/test/screenshots/NavigationBar" + + "/NavigationBar_fontScale2.png", + roborazziOptions = DefaultRoborazziOptions, + ) + } + + @Test + fun navigationRail_multipleThemes() { + composeTestRule.captureMultiTheme("NavigationRail") { + Surface { + PqNavigationRailExample() + } + } + } + + @Test + fun navigationDrawer_multipleThemes() { + composeTestRule.captureMultiTheme("NavigationDrawer") { + Surface { + PqNavigationDrawerExample() + } + } + } + + @Test + fun navigationDrawer_hugeFont() { + composeTestRule.setContent { + CompositionLocalProvider( + LocalInspectionMode provides true, + ) { + TestHarness(fontScale = 2f) { + PqTheme { + PqNavigationDrawerExample("Loooooooooooooooong item") + } + } + } + } + composeTestRule.onRoot() + .captureRoboImage( + "src/test/screenshots/NavigationDrawer" + + "/NavigationDrawer_fontScale2.png", + roborazziOptions = DefaultRoborazziOptions, + ) + } + + @Composable + private fun PqNavigationBarExample(label: String = "Item") { + PqNavigationBar { + (0..2).forEach { index -> + PqNavigationBarItem( + selected = index == 0, + onClick = { }, + icon = { + Icon( + imageVector = PqIcons.UpcomingBorder, + contentDescription = "", + ) + }, + selectedIcon = { + Icon( + imageVector = PqIcons.Upcoming, + contentDescription = "", + ) + }, + label = { Text(label) }, + ) + } + } + } + + @Composable + private fun PqNavigationRailExample() { + PqNavigationRail { + (0..2).forEach { index -> + PqNavigationRailItem( + selected = index == 0, + onClick = { }, + icon = { + Icon( + imageVector = PqIcons.UpcomingBorder, + contentDescription = "", + ) + }, + selectedIcon = { + Icon( + imageVector = PqIcons.Upcoming, + contentDescription = "", + ) + }, + ) + } + } + } + + @Composable + private fun PqNavigationDrawerExample(label: String = "Item") { + PqNavigationDrawer { + (0..2).forEach { index -> + PqNavigationDrawerItem( + selected = index == 0, + onClick = { }, + icon = { + Icon( + imageVector = PqIcons.UpcomingBorder, + contentDescription = "", + ) + }, + selectedIcon = { + Icon( + imageVector = PqIcons.Upcoming, + contentDescription = "", + ) + }, + label = { Text(label) }, + ) + } + } + } +} diff --git a/core/testing/src/main/java/com/wei/picquest/core/testing/util/MainDispatcherRule.kt b/core/testing/src/main/java/com/wei/picquest/core/testing/util/MainDispatcherRule.kt new file mode 100644 index 0000000..e640d4c --- /dev/null +++ b/core/testing/src/main/java/com/wei/picquest/core/testing/util/MainDispatcherRule.kt @@ -0,0 +1,28 @@ +package com.wei.picquest.core.testing.util + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestRule +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +/** + * A JUnit [TestRule] that sets the Main dispatcher to [testDispatcher] + * for the duration of the test. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule( + val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(), +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} diff --git a/core/testing/src/main/java/com/wei/picquest/core/testing/util/ScreenshotHelper.kt b/core/testing/src/main/java/com/wei/picquest/core/testing/util/ScreenshotHelper.kt new file mode 100644 index 0000000..b46ffe5 --- /dev/null +++ b/core/testing/src/main/java/com/wei/picquest/core/testing/util/ScreenshotHelper.kt @@ -0,0 +1,170 @@ +package com.wei.picquest.core.testing.util + +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.onRoot +import androidx.test.ext.junit.rules.ActivityScenarioRule +import com.github.takahirom.roborazzi.RoborazziOptions +import com.github.takahirom.roborazzi.RoborazziOptions.CompareOptions +import com.github.takahirom.roborazzi.RoborazziOptions.RecordOptions +import com.github.takahirom.roborazzi.captureRoboImage +import com.google.accompanist.testharness.TestHarness +import com.wei.picquest.core.designsystem.theme.PqTheme +import org.robolectric.RuntimeEnvironment + +val DefaultRoborazziOptions = + RoborazziOptions( + compareOptions = CompareOptions(changeThreshold = 0f), // Pixel-perfect matching + recordOptions = RecordOptions(resizeScale = 0.5), // Reduce the size of the PNGs + ) + +enum class DefaultTestDevices(val description: String, val spec: String) { + PHONE("phone", "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480"), + FOLDABLE("foldable", "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480"), + TABLET("tablet", "spec:shape=Normal,width=1280,height=800,unit=dp,dpi=480"), +} + +fun AndroidComposeTestRule, A>.captureMultiDevice( + screenshotName: String, + body: @Composable () -> Unit, +) { + DefaultTestDevices.values().forEach { + this.captureForDevice(it.description, it.spec, screenshotName, body = body) + } +} + +fun AndroidComposeTestRule, A>.captureForDevice( + deviceName: String, + deviceSpec: String, + screenshotName: String, + roborazziOptions: RoborazziOptions = DefaultRoborazziOptions, + darkMode: Boolean = false, + body: @Composable () -> Unit, +) { + val (width, height, dpi) = extractSpecs(deviceSpec) + + // Set qualifiers from specs + RuntimeEnvironment.setQualifiers("w${width}dp-h${height}dp-${dpi}dpi") + + this.activity.setContent { + CompositionLocalProvider( + LocalInspectionMode provides true, + ) { + TestHarness(darkMode = darkMode) { + body() + } + } + } + this.onRoot() + .captureRoboImage( + "src/test/screenshots/${screenshotName}_$deviceName.png", + roborazziOptions = roborazziOptions, + ) +} + +/** + * Takes four screenshots combining light/dark and default/Android themes + * is enabled. + */ +fun AndroidComposeTestRule, A>.captureMultiTheme( + name: String, + overrideFileName: String? = null, + shouldCompareDarkMode: Boolean = true, + shouldCompareAndroidTheme: Boolean = true, + content: @Composable (desc: String) -> Unit, +) { + val darkModeValues = if (shouldCompareDarkMode) listOf(true, false) else listOf(false) + val androidThemeValues = if (shouldCompareAndroidTheme) listOf(true, false) else listOf(false) + + var darkMode by mutableStateOf(true) + var androidTheme by mutableStateOf(false) + + this.setContent { + CompositionLocalProvider( + LocalInspectionMode provides true, + ) { + PqTheme( + androidTheme = androidTheme, + darkTheme = darkMode, + ) { + // Keying is necessary in some cases (e.g. animations) + key(androidTheme, darkMode) { + val description = generateDescription( + shouldCompareDarkMode, + darkMode, + shouldCompareAndroidTheme, + androidTheme, + ) + content(description) + } + } + } + } + + // Create permutations + darkModeValues.forEach { isDarkMode -> + darkMode = isDarkMode + val darkModeDesc = if (isDarkMode) "dark" else "light" + + androidThemeValues.forEach { isAndroidTheme -> + androidTheme = isAndroidTheme + val androidThemeDesc = if (isAndroidTheme) "androidTheme" else "defaultTheme" + + val filename = overrideFileName ?: name + + this.onRoot() + .captureRoboImage( + "src/test/screenshots/" + + "$name/$filename" + + "_$darkModeDesc" + + "_$androidThemeDesc" + + ".png", + roborazziOptions = DefaultRoborazziOptions, + ) + } + } +} + +@Composable +private fun generateDescription( + shouldCompareDarkMode: Boolean, + darkMode: Boolean, + shouldCompareAndroidTheme: Boolean, + androidTheme: Boolean, +): String { + val description = "" + + if (shouldCompareDarkMode) { + if (darkMode) "Dark" else "Light" + } else { + "" + } + + if (shouldCompareAndroidTheme) { + if (androidTheme) " Android" else " Default" + } else { + "" + } + + return description.trim() +} + +/** + * Extracts some properties from the spec string. Note that this function is not exhaustive. + */ +private fun extractSpecs(deviceSpec: String): TestDeviceSpecs { + val specs = deviceSpec.substringAfter("spec:") + .split(",").map { it.split("=") }.associate { it[0] to it[1] } + val width = specs["width"]?.toInt() ?: 640 + val height = specs["height"]?.toInt() ?: 480 + val dpi = specs["dpi"]?.toInt() ?: 480 + return TestDeviceSpecs(width, height, dpi) +} + +data class TestDeviceSpecs(val width: Int, val height: Int, val dpi: Int) diff --git a/core/testing/src/main/java/com/wei/picquest/core/testing/util/TestNetworkMonitor.kt b/core/testing/src/main/java/com/wei/picquest/core/testing/util/TestNetworkMonitor.kt new file mode 100644 index 0000000..60004db --- /dev/null +++ b/core/testing/src/main/java/com/wei/picquest/core/testing/util/TestNetworkMonitor.kt @@ -0,0 +1,19 @@ +package com.wei.picquest.core.testing.util + +import com.wei.picquest.core.data.utils.NetworkMonitor +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class TestNetworkMonitor : NetworkMonitor { + + private val connectivityFlow = MutableStateFlow(true) + + override val isOnline: Flow = connectivityFlow + + /** + * A test-only API to set the connectivity state from tests. + */ + fun setConnected(isConnected: Boolean) { + connectivityFlow.value = isConnected + } +}