test(android): add instrumentation test github action (#4178)

Fixes #2311
This commit is contained in:
Jason Elie Bou Kheir
2024-04-30 10:05:46 -07:00
committed by GitHub
parent 678a27c491
commit 4baf0cb93b
9 changed files with 353 additions and 10 deletions

View File

@@ -3,6 +3,10 @@ on:
workflow_call:
workflow_dispatch:
defaults:
run:
working-directory: ./kotlin/android
permissions:
contents: 'read'
id-token: 'write'
@@ -11,9 +15,6 @@ jobs:
static-analysis:
# Android SDK tools hardware accel is available only on Linux runners
runs-on: ubuntu-22.04
defaults:
run:
working-directory: ./kotlin/android
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
@@ -33,9 +34,6 @@ jobs:
build:
# Android SDK tools hardware accel is available only on Linux runners
runs-on: ubuntu-22.04
defaults:
run:
working-directory: ./kotlin/android
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-rust
@@ -59,9 +57,8 @@ jobs:
KEYSTORE_PATH=$(pwd)/app/keystore.jks
echo -n "$KEYSTORE_BASE64" | base64 --decode > $KEYSTORE_PATH
./gradlew bundleRelease
- name: Run Test
- name: Run Unit Test
run: |
# TODO: See https://github.com/firezone/firezone/issues/2311
./gradlew testReleaseUnitTest
- name: Upload app bundle
uses: actions/upload-artifact@v4
@@ -78,3 +75,60 @@ jobs:
run: |
echo -n "$FIREBASE_APP_DISTRIBUTION_CREDENTIALS" > $FIREBASE_CREDENTIALS_PATH
./gradlew --info appDistributionUploadRelease uploadCrashlyticsSymbolFileRelease
ui-test:
name: ui-test-api-${{ matrix.api-level }}
strategy:
fail-fast: false
matrix:
include:
- api-level: 26
- api-level: 29
# Android SDK tools hardware accel is available only on Linux runners
runs-on: ubuntu-22.04
steps:
- name: Free Disk Space (Ubuntu)
uses: jlumbroso/free-disk-space@main
with:
tool-cache: false
android: false
dotnet: true
haskell: true
large-packages: false
swap-storage: true
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-rust
with:
targets: armv7-linux-androideabi aarch64-linux-android x86_64-linux-android i686-linux-android
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
- name: AVD cache
uses: actions/cache@v4
id: avd-cache
with:
path: |
~/.android/avd/*
~/.android/adb*
key: avd-${{ matrix.api-level }}
- name: create AVD and generate snapshot for caching
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: ${{ matrix.api-level }}
force-avd-creation: false
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: false
script: echo "Generated AVD snapshot for caching."
- name: Run Tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: ${{ matrix.api-level }}
force-avd-creation: false
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
script: ./gradlew --stacktrace connectedCheck
working-directory: ./kotlin/android

View File

@@ -58,7 +58,7 @@ android {
// mark:automatic-version
versionName = "1.0.2"
multiDexEnabled = true
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunner = "dev.firezone.android.core.HiltTestRunner"
}
signingConfigs {
@@ -146,6 +146,12 @@ android {
buildFeatures {
viewBinding = true
}
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
dependencies {
@@ -172,6 +178,16 @@ dependencies {
implementation("com.google.dagger:hilt-android:2.51.1")
kapt("androidx.hilt:hilt-compiler:1.2.0")
kapt("com.google.dagger:hilt-android-compiler:2.51.1")
// Instrumented Tests
androidTestImplementation("com.google.dagger:hilt-android-testing:2.51")
kaptAndroidTest("com.google.dagger:hilt-android-compiler:2.51.1")
androidTestImplementation("androidx.test:runner:1.5.2")
androidTestImplementation("androidx.navigation:navigation-testing:2.7.7")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.test.espresso:espresso-contrib:3.5.1")
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
// Unit Tests
testImplementation("com.google.dagger:hilt-android-testing:2.51")
// Retrofit 2
implementation("com.squareup.retrofit2:retrofit:2.11.0")
@@ -194,6 +210,7 @@ dependencies {
// JUnit
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.fragment:fragment-testing:1.6.2")
// Import the BoM for the Firebase platform
implementation(platform("com.google.firebase:firebase-bom:32.8.0"))

View File

@@ -0,0 +1,17 @@
/* Licensed under Apache 2.0 (C) 2024 Firezone, Inc. */
package dev.firezone.android.core
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication
class HiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(
cl: ClassLoader?,
className: String?,
context: Context?,
): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}

View File

@@ -0,0 +1,56 @@
/* Licensed under Apache 2.0 (C) 2024 Firezone, Inc. */
package dev.firezone.android.core
import android.view.View
import androidx.test.espresso.PerformException
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import androidx.test.espresso.matcher.ViewMatchers.isRoot
import androidx.test.espresso.util.HumanReadables
import androidx.test.espresso.util.TreeIterables
import org.hamcrest.Matcher
import org.hamcrest.StringDescription
import java.util.concurrent.TimeoutException
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
fun waitForView(
vararg matchers: Matcher<View>,
timeout: Duration = 5.seconds,
): ViewAction {
return object : ViewAction {
private val timeoutMillis = timeout.inWholeMilliseconds
override fun getConstraints() = isRoot()
override fun getDescription(): String {
val subDescription = StringDescription()
matchers.forEach { it.describeTo(subDescription) }
return "Wait for a view matching one of: $subDescription; with a timeout of $timeout."
}
override fun perform(
uiController: UiController,
rootView: View,
) {
uiController.loopMainThreadUntilIdle()
val startTime = System.currentTimeMillis()
val endTime = startTime + timeoutMillis
do {
for (child in TreeIterables.breadthFirstViewTraversal(rootView)) {
if (matchers.any { matcher -> matcher.matches(child) }) {
return
}
}
uiController.loopMainThreadForAtLeast(100)
} while (System.currentTimeMillis() < endTime)
throw PerformException.Builder()
.withCause(TimeoutException())
.withActionDescription(this.description)
.withViewDescription(HumanReadables.describe(rootView))
.build()
}
}
}

View File

@@ -0,0 +1,18 @@
/* Licensed under Apache 2.0 (C) 2024 Firezone, Inc. */
package dev.firezone.android.core.di
import dagger.Module
import dagger.Provides
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import dev.firezone.android.core.ApplicationMode
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [RuntimeModule::class],
)
object TestRuntimeModule {
@Provides
internal fun provideApplicationMode() = ApplicationMode.TESTING
}

View File

@@ -0,0 +1,157 @@
/* Licensed under Apache 2.0 (C) 2024 Firezone, Inc. */
package dev.firezone.android.core.presentation
import android.content.Context
import android.view.View
import android.view.ViewGroup
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.base.DefaultFailureHandler
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isRoot
import androidx.test.espresso.matcher.ViewMatchers.withContentDescription
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiSelector
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dev.firezone.android.R
import dev.firezone.android.core.waitForView
import org.hamcrest.Description
import org.hamcrest.Matcher
import org.hamcrest.Matchers.allOf
import org.hamcrest.TypeSafeMatcher
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.util.Locale
@LargeTest
@RunWith(AndroidJUnit4::class)
@HiltAndroidTest
class MainActivityTest {
// Running count of the number of Android Not Responding dialogues to prevent endless dismissal.
private var anrCount = 0
// `RootViewWithoutFocusException` class is private, need to match the message (instead of using type matching).
private val rootViewWithoutFocusExceptionMsg =
java.lang.String.format(
Locale.ROOT,
"Waited for the root of the view hierarchy to have " +
"window focus and not request layout for 10 seconds. If you specified a non " +
"default root matcher, it may be picking a root that never takes focus. " +
"Root:",
)
@get:Rule(order = 0)
internal var hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
internal var activityScenarioRule = ActivityScenarioRule(MainActivity::class.java)
@Before
fun init() {
val context = ApplicationProvider.getApplicationContext<Context>()
Espresso.setFailureHandler { error, viewMatcher ->
if (error.message!!.contains(rootViewWithoutFocusExceptionMsg) && anrCount < 3) {
anrCount++
handleAnrDialogue()
} else { // chain all failures down to the default espresso handler
DefaultFailureHandler(context).handle(error, viewMatcher)
}
}
hiltRule.inject()
}
@Test
fun mainActivityTest() {
val btSettingsMatchers =
allOf(
withId(R.id.btSettings),
withText("Settings"),
isDisplayed(),
)
val btLogsMatchers =
allOf(
withContentDescription("Logs"),
childAtPosition(
childAtPosition(
withId(R.id.tabLayout),
0,
),
1,
),
isDisplayed(),
)
val btAdvancedMatchers =
allOf(
withContentDescription("Advanced"),
childAtPosition(
childAtPosition(
withId(R.id.tabLayout),
0,
),
0,
),
isDisplayed(),
)
val btSaveSettingsMatchers =
allOf(
withId(R.id.btSaveSettings),
withText("Save"),
childAtPosition(
childAtPosition(
withId(android.R.id.content),
0,
),
2,
),
isDisplayed(),
)
onView(isRoot()).perform(waitForView(btSettingsMatchers))
onView(btSettingsMatchers).perform(click())
onView(isRoot()).perform(waitForView(btLogsMatchers))
onView(btLogsMatchers).perform(click())
onView(isRoot()).perform(waitForView(btAdvancedMatchers))
onView(btAdvancedMatchers).perform(click())
onView(isRoot()).perform(waitForView(btSaveSettingsMatchers))
onView(btSaveSettingsMatchers).perform(click())
}
private fun handleAnrDialogue() {
val device = UiDevice.getInstance(getInstrumentation())
// If running the device in English Locale
val waitButton = device.findObject(UiSelector().textContains("wait"))
if (waitButton.exists()) waitButton.click()
}
private fun childAtPosition(
parentMatcher: Matcher<View>,
position: Int,
): Matcher<View> {
return object : TypeSafeMatcher<View>() {
override fun describeTo(description: Description) {
description.appendText("Child at position $position in parent ")
parentMatcher.describeTo(description)
}
public override fun matchesSafely(view: View): Boolean {
val parent = view.parent
return parent is ViewGroup && parentMatcher.matches(parent) &&
view == parent.getChildAt(position)
}
}
}
}

View File

@@ -0,0 +1,7 @@
/* Licensed under Apache 2.0 (C) 2024 Firezone, Inc. */
package dev.firezone.android.core
enum class ApplicationMode {
NORMAL,
TESTING,
}

View File

@@ -0,0 +1,15 @@
/* Licensed under Apache 2.0 (C) 2024 Firezone, Inc. */
package dev.firezone.android.core.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dev.firezone.android.core.ApplicationMode
@Module
@InstallIn(SingletonComponent::class)
object RuntimeModule {
@Provides
internal fun provideApplicationMode() = ApplicationMode.NORMAL
}

View File

@@ -8,6 +8,7 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.firezone.android.core.ApplicationMode
import dev.firezone.android.core.data.Repository
import dev.firezone.android.tunnel.TunnelService
import kotlinx.coroutines.delay
@@ -22,6 +23,7 @@ internal class SplashViewModel
constructor(
private val repo: Repository,
private val applicationRestrictions: Bundle,
private val applicationMode: ApplicationMode,
) : ViewModel() {
private val actionMutableLiveData = MutableLiveData<ViewAction>()
val actionLiveData: LiveData<ViewAction> = actionMutableLiveData
@@ -30,7 +32,7 @@ internal class SplashViewModel
viewModelScope.launch {
// Stay a while and enjoy the logo
delay(REQUEST_DELAY)
if (!hasVpnPermissions(context)) {
if (!hasVpnPermissions(context) && applicationMode != ApplicationMode.TESTING) {
actionMutableLiveData.postValue(ViewAction.NavigateToVpnPermission)
} else {
val token = applicationRestrictions.getString("token") ?: repo.getTokenSync()