From 3349e7875e3b961383d7236b69fb86e61d047e0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Sastre=20Fl=C3=B3rez?= Date: Thu, 5 May 2022 06:10:32 +0200 Subject: [PATCH] Add display size + control for timeout in FontSize & Display Size --- README.md | 40 ++++-- utils/build.gradle | 6 +- utils/src/debug/AndroidManifest.xml | 13 -- utils/src/main/AndroidManifest.xml | 11 ++ .../ActivityScenarioConfigurator.kt | 15 +++ .../uitesting/utils/common/DisplaySize.kt | 21 ++++ .../sastre/uitesting/utils/common/FontSize.kt | 4 +- .../testrules/{fontsize => }/Condition.kt | 2 +- .../displaysize/DisplayScaleSetting.kt | 30 +++++ .../displaysize/DisplaySizeTestRule.kt | 116 ++++++++++++++++++ .../testrules/fontsize/FontScaleSetting.kt | 2 - .../testrules/fontsize/FontSizeTestRule.kt | 36 ++++-- 12 files changed, 256 insertions(+), 40 deletions(-) delete mode 100644 utils/src/debug/AndroidManifest.xml create mode 100644 utils/src/main/java/sergio/sastre/uitesting/utils/common/DisplaySize.kt rename utils/src/main/java/sergio/sastre/uitesting/utils/testrules/{fontsize => }/Condition.kt (67%) create mode 100755 utils/src/main/java/sergio/sastre/uitesting/utils/testrules/displaysize/DisplayScaleSetting.kt create mode 100755 utils/src/main/java/sergio/sastre/uitesting/utils/testrules/displaysize/DisplaySizeTestRule.kt diff --git a/README.md b/README.md index 20d5139..fa7a1cc 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,17 @@ For screenshot testing, it supports **Jetpack Compose**, **android Views** (e.g.

Currently, with this library you can easily change the following configurations in your instrumented tests: 1. Locale (also Pseudolocales **en_XA** & **ar_XB**) -2. FontSize +2. Font size 3. Orientation -4. Dark Mode /Day-Night Mode +4. Dark mode /Day-Night mode +5. Display size - **from 1.1.0-beta** + +You can find out why verifying our design under such configurations is important in this blog post: +- [Design a pixel perfect Android app 🎨](https://sergiosastre.hashnode.dev/design-a-pixel-perfect-android-app-with-screenshot-testing) In the near future, there are plans to also support, among others: -1. Display -2. FragmentScenario -3. Reduce snapshot testing flakiness +1. FragmentScenario +2. Reduce snapshot testing flakiness ## Table of Contents - [Integration](#integration) @@ -47,6 +50,12 @@ dependencies { androidTestImplementation 'com.github.sergio-sastre:AndroidUiTestingUtils:1.0.0' } ``` +or if you want to test the Display Size change, use the last beta: +```groovy +dependencies { + androidTestImplementation 'com.github.sergio-sastre:AndroidUiTestingUtils:1.1.0-beta' +} +``` # Usage ## Configuration @@ -89,12 +98,15 @@ The examples use [pedrovgs/Shot](https://github.com/pedrovgs/Shot). It'd also wo val locale = LocaleTestRule("en") @get:Rule -val fontSize = FontSizeTestRule(FontSize.HUGE) +val fontSize = FontSizeTestRule(FontSize.HUGE).withTimeOut(inMillis = 15_000) // default is 10_000 + +@get:Rule +val displaySize = DisplaySizeTestRule(DisplaySize.LARGEST).withTimeOut(inMillis = 15_000) @Test fun snapActivityTest() { - // Locale and FontSize are only supported via TestRules for Activities - val activityScenario = ActivityScenarioConfigurator.ForActivity() + // Locale, FontSize & DisplaySize are only supported via TestRules for Activities + val activity = ActivityScenarioConfigurator.ForActivity() .setOrientation(Orientation.LANDSCAPE) .setUiMode(UiMode.NIGHT) .launch(YourActivity::class.java) @@ -117,6 +129,7 @@ fun snapViewHolderTest() { .setLocale("en") .setInitialOrientation(Orientation.PORTRAIT) .setUiMode(UiMode.DAY) + .setDisplaySize(DisplaySize.SMALL) .launchConfiguredActivity() val activity = activityScenario.waitForActivity() @@ -159,6 +172,7 @@ fun snapComposableTest() { .setLocale("de") .setInitialOrientation(Orientation.PORTRAIT) .setUiMode(UiMode.DAY) + .setDisplaySize(DisplaySize.LARGE) .launchConfiguredActivity() .onActivity { it.setContent { @@ -193,17 +207,21 @@ In doing so, the configuration becomes effective in the view. It also adds the v ### Reading on screenshot testing - [An introduction to snapshot testing on Android in 2021](https://sergiosastre.hashnode.dev/an-introduction-to-snapshot-testing-on-android-in-2021) -- [The secrets of effectively snapshot testing on Android](https://sergiosastre.hashnode.dev/the-secrets-of-effectively-snapshot-testing-on-android) +- [The secrets of effectively snapshot testing on Android 🔓](https://sergiosastre.hashnode.dev/the-secrets-of-effectively-snapshot-testing-on-android) - [UI tests vs. snapshot tests on Android: which one should I write? 🤔](https://sergiosastre.hashnode.dev/ui-tests-vs-snapshot-tests-on-android-which-one-should-i-write) +- [Design a pixel perfect Android app 🎨](https://sergiosastre.hashnode.dev/design-a-pixel-perfect-android-app-with-screenshot-testing) ## Standard UI testing -For standard UI testing, you can use the same approach as for snapshot testing Activities. In case you do not want to use ActivityScenario for your tests, the following TestRules and methods are provided: +For standard UI testing, you can use the same approach as for snapshot testing Activities. In case you do not want to use ActivityScenario at all in your tests, the following TestRules and methods are provided: ```kotlin @get:Rule val locale = LocaleTestRule("en") @get:Rule -val fontSize = FontSizeTestRule(FontSize.HUGE) +val fontSize = FontSizeTestRule(FontSize.HUGE).withTimeOut(inMillis = 15_000) // default is 10_000 + +@get:Rule +val displaySize = DisplaySizeTestRule(DisplaySize.LARGEST).withTimeOut(inMillis = 15_000) @get:Rule val uiMode = DayNightRule(UiMode.NIGHT) diff --git a/utils/build.gradle b/utils/build.gradle index c6ab7a0..57ebb4d 100644 --- a/utils/build.gradle +++ b/utils/build.gradle @@ -10,8 +10,8 @@ android { defaultConfig { minSdk 21 targetSdk 31 - versionCode 2 - versionName "1.0.0" + versionCode 3 + versionName "1.1.0-beta" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" @@ -49,7 +49,7 @@ afterEvaluate { from components.release groupId = 'sergio.sastre' artifactId = 'uitesting.utils' - version = '1.0.0' + version = '1.1.0-beta' } } } diff --git a/utils/src/debug/AndroidManifest.xml b/utils/src/debug/AndroidManifest.xml deleted file mode 100644 index da8f7a4..0000000 --- a/utils/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/utils/src/main/AndroidManifest.xml b/utils/src/main/AndroidManifest.xml index d324858..67512af 100644 --- a/utils/src/main/AndroidManifest.xml +++ b/utils/src/main/AndroidManifest.xml @@ -1,5 +1,16 @@ + + + + + + + \ No newline at end of file diff --git a/utils/src/main/java/sergio/sastre/uitesting/utils/activityscenario/ActivityScenarioConfigurator.kt b/utils/src/main/java/sergio/sastre/uitesting/utils/activityscenario/ActivityScenarioConfigurator.kt index 15fb76e..2ba4481 100644 --- a/utils/src/main/java/sergio/sastre/uitesting/utils/activityscenario/ActivityScenarioConfigurator.kt +++ b/utils/src/main/java/sergio/sastre/uitesting/utils/activityscenario/ActivityScenarioConfigurator.kt @@ -13,6 +13,7 @@ import sergio.sastre.uitesting.utils.common.LocaleUtil import sergio.sastre.uitesting.utils.common.Orientation import sergio.sastre.uitesting.utils.common.UiMode import sergio.sastre.uitesting.utils.activityscenario.orientation.OrientationTestWatcher +import sergio.sastre.uitesting.utils.common.DisplaySize import java.util.* object ActivityScenarioConfigurator { @@ -20,6 +21,7 @@ object ActivityScenarioConfigurator { private var locale: Locale? = null private var orientation: Orientation? = null private var uiMode: UiMode? = null + private var displaySize: DisplaySize? = null @JvmInline value class StringLocale(val locale: String) @@ -61,6 +63,10 @@ object ActivityScenarioConfigurator { ActivityScenarioConfigurator.orientation = orientation } + fun setDisplaySize(displaySize: DisplaySize): ForView = apply { + ActivityScenarioConfigurator.displaySize = displaySize + } + fun launchConfiguredActivity() = ActivityScenario.launch(activityForOrientation(orientation)) } @@ -104,6 +110,10 @@ object ActivityScenarioConfigurator { ActivityScenarioConfigurator.orientation = orientation } + fun setDisplaySize(displaySize: DisplaySize): ForComposable = apply { + ActivityScenarioConfigurator.displaySize = displaySize + } + fun launchConfiguredActivity() = ActivityScenario.launch(activityForOrientation(orientation)) } @@ -162,11 +172,16 @@ object ActivityScenarioConfigurator { fontSize?.run { newConfig.fontScale = value.toFloat() } locale?.run { newConfig.setLocale(this) } uiMode?.run { newConfig.uiMode = this.configurationInt } + displaySize?.run { + val newDensityDpi = this.value.toFloat() * this@wrap.resources.configuration.densityDpi + newConfig.densityDpi = newDensityDpi.toInt() + } fontSize = null locale = null uiMode = null orientation = null + displaySize = null return createConfigurationContext(newConfig) } diff --git a/utils/src/main/java/sergio/sastre/uitesting/utils/common/DisplaySize.kt b/utils/src/main/java/sergio/sastre/uitesting/utils/common/DisplaySize.kt new file mode 100644 index 0000000..9fdfbf3 --- /dev/null +++ b/utils/src/main/java/sergio/sastre/uitesting/utils/common/DisplaySize.kt @@ -0,0 +1,21 @@ +package sergio.sastre.uitesting.utils.common + +enum class DisplaySize(val value: String) { + SMALL(0.85f.toString()), + NORMAL(1f.toString()), + LARGE(1.1f.toString()), + LARGER(1.2f.toString()), + LARGEST(1.3f.toString()); + + companion object { + @JvmStatic + fun from(scale: Float): DisplaySize { + for (displaySize in values()) { + if (displaySize.value == scale.toString()) { + return displaySize + } + } + throw IllegalArgumentException("Unknown display scale: $scale") + } + } +} diff --git a/utils/src/main/java/sergio/sastre/uitesting/utils/common/FontSize.kt b/utils/src/main/java/sergio/sastre/uitesting/utils/common/FontSize.kt index ccd7fb1..a3d23e2 100644 --- a/utils/src/main/java/sergio/sastre/uitesting/utils/common/FontSize.kt +++ b/utils/src/main/java/sergio/sastre/uitesting/utils/common/FontSize.kt @@ -14,7 +14,7 @@ enum class FontSize(val value: String) { return fontScale } } - throw IllegalArgumentException("Unknown scale: $scale") + throw IllegalArgumentException("Unknown font scale: $scale") } } -} \ No newline at end of file +} diff --git a/utils/src/main/java/sergio/sastre/uitesting/utils/testrules/fontsize/Condition.kt b/utils/src/main/java/sergio/sastre/uitesting/utils/testrules/Condition.kt similarity index 67% rename from utils/src/main/java/sergio/sastre/uitesting/utils/testrules/fontsize/Condition.kt rename to utils/src/main/java/sergio/sastre/uitesting/utils/testrules/Condition.kt index 09beef7..2e23ed7 100755 --- a/utils/src/main/java/sergio/sastre/uitesting/utils/testrules/fontsize/Condition.kt +++ b/utils/src/main/java/sergio/sastre/uitesting/utils/testrules/Condition.kt @@ -1,4 +1,4 @@ -package sergio.sastre.uitesting.utils.testrules.fontsize +package sergio.sastre.uitesting.utils.testrules /** * Taken from : https://github.com/novoda/espresso-support diff --git a/utils/src/main/java/sergio/sastre/uitesting/utils/testrules/displaysize/DisplayScaleSetting.kt b/utils/src/main/java/sergio/sastre/uitesting/utils/testrules/displaysize/DisplayScaleSetting.kt new file mode 100755 index 0000000..6fda91e --- /dev/null +++ b/utils/src/main/java/sergio/sastre/uitesting/utils/testrules/displaysize/DisplayScaleSetting.kt @@ -0,0 +1,30 @@ +package sergio.sastre.uitesting.utils.testrules.displaysize + +import android.content.res.Resources +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import sergio.sastre.uitesting.utils.common.DisplaySize + +class DisplayScaleSetting internal constructor(private val resources: Resources) { + + fun getDensityDpi(): Int { + return resources.configuration.densityDpi + } + + fun resetDisplaySizeScale(originalDensity: Int) { + try { + getInstrumentation().uiAutomation.executeShellCommand("wm density reset") + } catch (e: Exception) { + throw RuntimeException("Unable to reset densityDpi to $originalDensity") + } + } + + fun setDisplaySizeScale(scale: DisplaySize) { + try { + val targetDensityDpi = resources.configuration.densityDpi * (scale.value).toFloat() + getInstrumentation().uiAutomation + .executeShellCommand("wm density " + targetDensityDpi.toInt()) + } catch (e: Exception) { + throw RuntimeException("Unable to set display size with scale ${scale.name} = ${scale.value}") + } + } +} diff --git a/utils/src/main/java/sergio/sastre/uitesting/utils/testrules/displaysize/DisplaySizeTestRule.kt b/utils/src/main/java/sergio/sastre/uitesting/utils/testrules/displaysize/DisplaySizeTestRule.kt new file mode 100755 index 0000000..2afbb03 --- /dev/null +++ b/utils/src/main/java/sergio/sastre/uitesting/utils/testrules/displaysize/DisplaySizeTestRule.kt @@ -0,0 +1,116 @@ +package sergio.sastre.uitesting.utils.testrules.displaysize + +import android.os.SystemClock +import androidx.annotation.IntRange + +import org.junit.rules.TestRule +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import org.junit.runners.model.Statement + +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import sergio.sastre.uitesting.utils.common.DisplaySize +import sergio.sastre.uitesting.utils.testrules.Condition +import sergio.sastre.uitesting.utils.testrules.displaysize.DisplaySizeTestRule.DisplaySizeStatement.Companion.MAX_RETRIES_TO_WAIT_FOR_SETTING +import sergio.sastre.uitesting.utils.testrules.displaysize.DisplaySizeTestRule.DisplaySizeStatement.Companion.SLEEP_TO_WAIT_FOR_SETTING_MILLIS + +/** + * A TestRule to change Display size of the device/emulator via adb + */ +class DisplaySizeTestRule( + private val displaySize: DisplaySize, +) : TestWatcher(), TestRule { + + companion object { + fun smallDisplaySizeTestRule(): DisplaySizeTestRule = DisplaySizeTestRule(DisplaySize.SMALL) + + fun normalDisplaySizeTestRule(): DisplaySizeTestRule = + DisplaySizeTestRule(DisplaySize.NORMAL) + + fun largeDisplaySizeTestRule(): DisplaySizeTestRule = DisplaySizeTestRule(DisplaySize.LARGE) + + fun largerDisplaySizeTestRule(): DisplaySizeTestRule = + DisplaySizeTestRule(DisplaySize.LARGER) + + fun largestDisplaySizeTestRule(): DisplaySizeTestRule = + DisplaySizeTestRule(DisplaySize.LARGEST) + } + + private var timeOutInMillis = MAX_RETRIES_TO_WAIT_FOR_SETTING * SLEEP_TO_WAIT_FOR_SETTING_MILLIS + + private val displayScaleSetting: DisplayScaleSetting = + DisplayScaleSetting(getInstrumentation().targetContext.resources) + + private var previousScale: Int = 0 + + /** + * Since the Display Size setting is changed via adb, it might take longer than expected to + * take effect, and could be device dependent. One can use this method to adjust the default + * time out which is [MAX_RETRIES_TO_WAIT_FOR_SETTING] * [SLEEP_TO_WAIT_FOR_SETTING_MILLIS] + */ + fun withTimeOut(@IntRange(from = 0) inMillis: Int): DisplaySizeTestRule = apply { + this.timeOutInMillis = inMillis + } + + override fun starting(description: Description?) { + previousScale = getInstrumentation().targetContext.resources.configuration.densityDpi + } + + override fun finished(description: Description?) { + displayScaleSetting.resetDisplaySizeScale(previousScale) + } + + override fun apply(base: Statement, description: Description): Statement { + return DisplaySizeStatement(base, displayScaleSetting, displaySize, timeOutInMillis) + } + + private class DisplaySizeStatement( + private val baseStatement: Statement, + private val scaleSetting: DisplayScaleSetting, + private val scale: DisplaySize, + private val timeOutInMillis: Int, + ) : Statement() { + + @Throws(Throwable::class) + override fun evaluate() { + val initialDisplay = scaleSetting.getDensityDpi() + val expectedDisplay = (initialDisplay * scale.value.toFloat()).toInt() + scaleSetting.setDisplaySizeScale(scale) + sleepUntil(scaleMatches(expectedDisplay)) + + baseStatement.evaluate() + + scaleSetting.resetDisplaySizeScale(initialDisplay) + sleepUntil(scaleMatches(initialDisplay)) + } + + private fun scaleMatches(densityDpi: Int): Condition { + return object : Condition { + override fun holds(): Boolean { + return scaleSetting.getDensityDpi() == densityDpi + } + } + } + + private fun sleepUntil(condition: Condition) { + var retries = 0 + while (!condition.holds()) { + val retriesCount = timeOutInMillis / SLEEP_TO_WAIT_FOR_SETTING_MILLIS + SystemClock.sleep(SLEEP_TO_WAIT_FOR_SETTING_MILLIS.toLong()) + if (retries == retriesCount) { + throw timeoutError(retries) + } + retries++ + } + } + + private fun timeoutError(retries: Int): AssertionError { + return AssertionError("Spent too long waiting trying to set display scale.$retries retries") + } + + companion object { + const val SLEEP_TO_WAIT_FOR_SETTING_MILLIS = 100 + const val MAX_RETRIES_TO_WAIT_FOR_SETTING = 100 + } + } +} \ No newline at end of file diff --git a/utils/src/main/java/sergio/sastre/uitesting/utils/testrules/fontsize/FontScaleSetting.kt b/utils/src/main/java/sergio/sastre/uitesting/utils/testrules/fontsize/FontScaleSetting.kt index 4ae1742..c6389fc 100755 --- a/utils/src/main/java/sergio/sastre/uitesting/utils/testrules/fontsize/FontScaleSetting.kt +++ b/utils/src/main/java/sergio/sastre/uitesting/utils/testrules/fontsize/FontScaleSetting.kt @@ -11,7 +11,6 @@ import sergio.sastre.uitesting.utils.common.FontSize class FontScaleSetting internal constructor(private val resources: Resources) { fun get(): FontSize { - FontSizeTestRule(FontSize.NORMAL) return FontSize.from(resources.configuration.fontScale) } @@ -22,7 +21,6 @@ class FontScaleSetting internal constructor(private val resources: Resources) { } else { changeFontScalePreApi25(scale) } - } catch (e: Exception) { throw saveFontScaleError(scale) } diff --git a/utils/src/main/java/sergio/sastre/uitesting/utils/testrules/fontsize/FontSizeTestRule.kt b/utils/src/main/java/sergio/sastre/uitesting/utils/testrules/fontsize/FontSizeTestRule.kt index f6592b9..947a387 100755 --- a/utils/src/main/java/sergio/sastre/uitesting/utils/testrules/fontsize/FontSizeTestRule.kt +++ b/utils/src/main/java/sergio/sastre/uitesting/utils/testrules/fontsize/FontSizeTestRule.kt @@ -1,6 +1,7 @@ package sergio.sastre.uitesting.utils.testrules.fontsize import android.os.SystemClock +import androidx.annotation.IntRange import org.junit.rules.TestRule import org.junit.rules.TestWatcher @@ -9,9 +10,15 @@ import org.junit.runners.model.Statement import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import sergio.sastre.uitesting.utils.common.FontSize +import sergio.sastre.uitesting.utils.testrules.Condition +import sergio.sastre.uitesting.utils.testrules.displaysize.DisplaySizeTestRule +import sergio.sastre.uitesting.utils.testrules.fontsize.FontSizeTestRule.FontScaleStatement.Companion.MAX_RETRIES_TO_WAIT_FOR_SETTING +import sergio.sastre.uitesting.utils.testrules.fontsize.FontSizeTestRule.FontScaleStatement.Companion.SLEEP_TO_WAIT_FOR_SETTING_MILLIS /** - * A TestRule to change FontSize of the device/emulator via adb + * A TestRule to change FontSize of the device/emulator. It is done: + * - API < 25 : modifying resources.configuration + * - API 25 + : via adb (slower) * * Strongly based on code from espresso-support library, from Novoda * https://github.com/novoda/espresso-support/tree/master/core/src/main/java/com/novoda/espresso @@ -30,11 +37,22 @@ class FontSizeTestRule( fun hugeFontScaleTestRule(): FontSizeTestRule = FontSizeTestRule(FontSize.HUGE) } + private var timeOutInMillis = MAX_RETRIES_TO_WAIT_FOR_SETTING * SLEEP_TO_WAIT_FOR_SETTING_MILLIS + private val fontScaleSetting: FontScaleSetting = FontScaleSetting(getInstrumentation().targetContext.resources) private var previousScale: Float = 0.toFloat() + /** + * Since the Font Size setting might be changed via adb, it might take longer than expected to + * take effect, and could be device dependent. One can use this method to adjust the default + * time out which is [MAX_RETRIES_TO_WAIT_FOR_SETTING] * [SLEEP_TO_WAIT_FOR_SETTING_MILLIS] + */ + fun withTimeOut(@IntRange(from = 0) inMillis: Int): FontSizeTestRule = apply { + this.timeOutInMillis = inMillis + } + override fun starting(description: Description?) { previousScale = getInstrumentation().targetContext.resources.configuration.fontScale } @@ -44,13 +62,14 @@ class FontSizeTestRule( } override fun apply(base: Statement, description: Description): Statement { - return FontScaleStatement(base, fontScaleSetting, fontSize) + return FontScaleStatement(base, fontScaleSetting, fontSize, timeOutInMillis) } - private class FontScaleStatement constructor( + private class FontScaleStatement( private val baseStatement: Statement, private val scaleSetting: FontScaleSetting, - private val scale: FontSize + private val scale: FontSize, + private val timeOutInMillis: Int, ) : Statement() { @Throws(Throwable::class) @@ -76,8 +95,9 @@ class FontSizeTestRule( private fun sleepUntil(condition: Condition) { var retries = 0 while (!condition.holds()) { + val retriesCount = timeOutInMillis / SLEEP_TO_WAIT_FOR_SETTING_MILLIS SystemClock.sleep(SLEEP_TO_WAIT_FOR_SETTING_MILLIS.toLong()) - if (retries == MAX_RETRIES_TO_WAIT_FOR_SETTING) { + if (retries == retriesCount) { throw timeoutError(retries) } retries++ @@ -85,12 +105,12 @@ class FontSizeTestRule( } private fun timeoutError(retries: Int): AssertionError { - return AssertionError("Spent too long waiting trying to set scale.$retries retries") + return AssertionError("Spent too long waiting trying to set font scale.$retries retries") } companion object { - private const val SLEEP_TO_WAIT_FOR_SETTING_MILLIS = 100 - private const val MAX_RETRIES_TO_WAIT_FOR_SETTING = 100 + const val SLEEP_TO_WAIT_FOR_SETTING_MILLIS = 100 + const val MAX_RETRIES_TO_WAIT_FOR_SETTING = 100 } } } \ No newline at end of file