Skip to content

Commit

Permalink
Merge pull request #5 from sergio-sastre/release/1.1.0-beta
Browse files Browse the repository at this point in the history
release 1.1.0-beta
  • Loading branch information
sergio-sastre authored May 5, 2022
2 parents 347bb7b + 3349e78 commit d2107af
Show file tree
Hide file tree
Showing 12 changed files with 256 additions and 40 deletions.
40 changes: 29 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,17 @@ For screenshot testing, it supports **Jetpack Compose**, **android Views** (e.g.
</br></br>
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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -117,6 +129,7 @@ fun snapViewHolderTest() {
.setLocale("en")
.setInitialOrientation(Orientation.PORTRAIT)
.setUiMode(UiMode.DAY)
.setDisplaySize(DisplaySize.SMALL)
.launchConfiguredActivity()

val activity = activityScenario.waitForActivity()
Expand Down Expand Up @@ -159,6 +172,7 @@ fun snapComposableTest() {
.setLocale("de")
.setInitialOrientation(Orientation.PORTRAIT)
.setUiMode(UiMode.DAY)
.setDisplaySize(DisplaySize.LARGE)
.launchConfiguredActivity()
.onActivity {
it.setContent {
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions utils/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -49,7 +49,7 @@ afterEvaluate {
from components.release
groupId = 'sergio.sastre'
artifactId = 'uitesting.utils'
version = '1.0.0'
version = '1.1.0-beta'
}
}
}
Expand Down
13 changes: 0 additions & 13 deletions utils/src/debug/AndroidManifest.xml

This file was deleted.

11 changes: 11 additions & 0 deletions utils/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="sergio.sastre.uitesting.utils">

<uses-permission
android:name="android.permission.CHANGE_CONFIGURATION"
tools:ignore="ProtectedPermissions" />

<application>
<activity android:name=".activityscenario.ActivityScenarioConfigurator$PortraitSnapshotConfiguredActivity" />
<activity android:name=".activityscenario.ActivityScenarioConfigurator$LandscapeSnapshotConfiguredActivity"
android:screenOrientation="landscape"/>
</application>

</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ 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 {
private var fontSize: FontSize? = null
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)
Expand Down Expand Up @@ -61,6 +63,10 @@ object ActivityScenarioConfigurator {
ActivityScenarioConfigurator.orientation = orientation
}

fun setDisplaySize(displaySize: DisplaySize): ForView = apply {
ActivityScenarioConfigurator.displaySize = displaySize
}

fun launchConfiguredActivity() =
ActivityScenario.launch(activityForOrientation(orientation))
}
Expand Down Expand Up @@ -104,6 +110,10 @@ object ActivityScenarioConfigurator {
ActivityScenarioConfigurator.orientation = orientation
}

fun setDisplaySize(displaySize: DisplaySize): ForComposable = apply {
ActivityScenarioConfigurator.displaySize = displaySize
}

fun launchConfiguredActivity() =
ActivityScenario.launch(activityForOrientation(orientation))
}
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ enum class FontSize(val value: String) {
return fontScale
}
}
throw IllegalArgumentException("Unknown scale: $scale")
throw IllegalArgumentException("Unknown font scale: $scale")
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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}")
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading

0 comments on commit d2107af

Please sign in to comment.