commit 6b9e8789bd3316d7714246af0f9403c9dd7767cc
parent 2a5d6919051e6046cc2278b8b4072a1cf6debbbd
Author: Delia Pop <dpop@mozilla.com>
Date: Wed, 15 Oct 2025 12:31:23 +0000
Bug 1990878 - Simplify RetryTestRule.apply (ComplexMethod) r=aaronmt
Refactoring of the RetryTestRule.apply method, as Arron suggested.
10 UI tests successfully passed 25x on Firebase.
Try links: [[ https://treeherder.mozilla.org/jobs?repo=try&revision=991fa576f854575fa6679ec68ed8649a07ebae0b | 1 ]] , [[ https://treeherder.mozilla.org/jobs?repo=try&revision=06e94c05d13153bcf2385b8bc649298b8c2c626b | 2 ]]
Try with test scenario: [[ https://treeherder.mozilla.org/jobs?repo=try&landoCommitID=158232 | here ]] The test could not be picked up on Try.
Differential Revision: https://phabricator.services.mozilla.com/D266995
Diffstat:
2 files changed, 162 insertions(+), 64 deletions(-)
diff --git a/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/helpers/RetryTestRule.kt b/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/helpers/RetryTestRule.kt
@@ -14,7 +14,6 @@ import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
import org.mozilla.fenix.ext.components
-import org.mozilla.fenix.helpers.Constants.TAG
import org.mozilla.fenix.helpers.IdlingResourceHelper.unregisterAllIdlingResources
import org.mozilla.fenix.helpers.TestHelper.appContext
import org.mozilla.fenix.helpers.TestHelper.exitMenu
@@ -26,75 +25,27 @@ import org.mozilla.fenix.helpers.TestHelper.exitMenu
*
*/
class RetryTestRule(private val retryCount: Int = 5) : TestRule {
- @Suppress("TooGenericExceptionCaught")
+
override fun apply(base: Statement, description: Description): Statement {
return statement {
- for (i in 1..retryCount) {
+ repeat(retryCount) { attempt ->
try {
- Log.i(TAG, "RetryTestRule: Started try #$i.")
+ Log.i(TAG, "RetryTestRule: Started try #${attempt + 1}.")
base.evaluate()
- break
+ return@statement // success, exit early
} catch (t: NoLeakAssertionFailedError) {
Log.i(TAG, "RetryTestRule: NoLeakAssertionFailedError caught, not retrying")
- unregisterAllIdlingResources()
- appContext.components.useCases.tabsUseCases.removeAllTabs()
- exitMenu()
+ cleanup(true)
throw t
- } catch (t: AssertionError) {
- Log.i(TAG, "RetryTestRule: AssertionError caught, retrying the UI test")
- unregisterAllIdlingResources()
- appContext.components.useCases.tabsUseCases.removeAllTabs()
- exitMenu()
- if (i == retryCount) {
- Log.i(TAG, "RetryTestRule: Max numbers of retries reached.")
- throw t
- }
- } catch (t: AssertionFailedError) {
- Log.i(TAG, "RetryTestRule: AssertionFailedError caught, retrying the UI test")
- unregisterAllIdlingResources()
- exitMenu()
- if (i == retryCount) {
- Log.i(TAG, "RetryTestRule: Max numbers of retries reached.")
- throw t
- }
- } catch (t: UiObjectNotFoundException) {
- Log.i(TAG, "RetryTestRule: UiObjectNotFoundException caught, retrying the UI test")
- unregisterAllIdlingResources()
- exitMenu()
- if (i == retryCount) {
- Log.i(TAG, "RetryTestRule: Max numbers of retries reached.")
- throw t
- }
- } catch (t: NoMatchingViewException) {
- Log.i(TAG, "RetryTestRule: NoMatchingViewException caught, retrying the UI test")
- unregisterAllIdlingResources()
- exitMenu()
- if (i == retryCount) {
- Log.i(TAG, "RetryTestRule: Max numbers of retries reached.")
- throw t
- }
- } catch (t: IdlingResourceTimeoutException) {
- Log.i(TAG, "RetryTestRule: IdlingResourceTimeoutException caught, retrying the UI test")
- unregisterAllIdlingResources()
- exitMenu()
- if (i == retryCount) {
- Log.i(TAG, "RetryTestRule: Max numbers of retries reached.")
- throw t
- }
- } catch (t: RuntimeException) {
- Log.i(TAG, "RetryTestRule: RuntimeException caught, retrying the UI test")
- unregisterAllIdlingResources()
- exitMenu()
- if (i == retryCount) {
- Log.i(TAG, "RetryTestRule: Max numbers of retries reached.")
- throw t
- }
- } catch (t: NullPointerException) {
- Log.i(TAG, "RetryTestRule: NullPointerException caught, retrying the UI test")
- unregisterAllIdlingResources()
- exitMenu()
- if (i == retryCount) {
- Log.i(TAG, "RetryTestRule: Max numbers of retries reached.")
+ } catch (t: Throwable) {
+ if (t.isRetryable()) {
+ Log.i(TAG, "RetryTestRule: ${t::class.simpleName} caught, retrying the UI test")
+ cleanup()
+ if (attempt == retryCount - 1) {
+ Log.i(TAG, "RetryTestRule: Max number of retries reached.")
+ throw t
+ }
+ } else {
throw t
}
}
@@ -102,9 +53,54 @@ class RetryTestRule(private val retryCount: Int = 5) : TestRule {
}
}
+ private fun cleanup(removeTabs: Boolean = false) {
+ unregisterAllIdlingResources()
+ if (removeTabs) {
+ appContext.components.useCases.tabsUseCases.removeAllTabs()
+ }
+ exitMenu()
+ }
+
+ private fun Throwable.isRetryable(): Boolean = when (this) {
+ is AssertionError,
+ is AssertionFailedError,
+ is UiObjectNotFoundException,
+ is NoMatchingViewException,
+ is IdlingResourceTimeoutException,
+ is RuntimeException,
+ is NullPointerException,
+ -> true
+ else -> false
+ }
+
+ companion object {
+ private const val TAG = "RetryTestRule"
+ }
+}
+
private inline fun statement(crossinline eval: () -> Unit): Statement {
return object : Statement() {
override fun evaluate() = eval()
}
}
-}
+
+ /**
+ * Represents a test case that supplies a Throwable to be thrown during a test.
+ *
+ * @property name A human-readable name for the test case.
+ * Used for display in test runner output and logs.
+ * @property supplier A lambda that returns a new instance of the Throwable to throw.
+ * It's evaluated during the test execution.
+ */
+ data class ThrowableCase(
+ // The display name used in parameterized test output (e.g., "NullPointerException")
+ val name: String,
+ // Function that supplies the Throwable to throw when invoked
+ val supplier: () -> Throwable,
+ ) {
+ /**
+ * Overrides the default toString() so that the test runner displays the 'name'
+ * instead of a default data class string or lambda object ID.
+ */
+ override fun toString(): String = name
+ }
diff --git a/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/ui/RetryRuleRetryableExceptionsTest.kt b/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/ui/RetryRuleRetryableExceptionsTest.kt
@@ -0,0 +1,102 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.fenix.ui
+
+import android.util.Log
+import androidx.test.espresso.IdlingResourceTimeoutException
+import androidx.test.espresso.NoMatchingViewException
+import androidx.test.filters.LargeTest
+import androidx.test.uiautomator.UiObjectNotFoundException
+import junit.framework.AssertionFailedError
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+import org.mozilla.fenix.helpers.RetryTestRule
+import org.mozilla.fenix.helpers.TestSetup
+import org.mozilla.fenix.helpers.ThrowableCase
+import java.util.concurrent.atomic.AtomicInteger
+
+@RunWith(Parameterized::class)
+@LargeTest
+class RetryRuleRetryableExceptionsTest(
+ private val case: ThrowableCase,
+) : TestSetup() {
+
+ private val attempts = AtomicInteger(0)
+
+ @get:Rule
+ val retryRule = RetryTestRule(retryCount = 2)
+
+companion object {
+ @JvmStatic
+ @Parameterized.Parameters(name = "{index}: {0}")
+ fun data(): Collection<Array<Any>> = listOf(
+ arrayOf(
+ ThrowableCase(
+ "AssertionError",
+ ) {
+ AssertionError("Retryable AssertionError")
+ },
+ ),
+ arrayOf(
+ ThrowableCase(
+ "AssertionFailedError",
+ ) {
+ AssertionFailedError("Retryable AssertionFailedError")
+ },
+ ),
+ arrayOf(
+ ThrowableCase(
+ "UiObjectNotFoundException",
+ ) {
+ UiObjectNotFoundException("Retryable UiObjectNotFoundException")
+ },
+ ),
+ arrayOf(
+ ThrowableCase(
+ "NoMatchingViewException",
+ ) {
+ NoMatchingViewException.Builder()
+ .withCause(Throwable("Retryable NoMatchingViewException"))
+ .build()
+ },
+ ),
+ arrayOf(
+ ThrowableCase(
+ "IdlingResourceTimeoutException",
+ ) {
+ IdlingResourceTimeoutException(
+ listOf("Retryable IdlingResourceTimeoutException"),
+ )
+ },
+ ),
+ arrayOf(
+ ThrowableCase(
+ "RuntimeException",
+ ) {
+ RuntimeException("Retryable RuntimeException")
+ },
+ ),
+ arrayOf(
+ ThrowableCase(
+ "NullPointerException",
+ ) {
+ NullPointerException("Retryable NullPointerException")
+ },
+ ),
+ )
+}
+
+ @Test
+ fun testRetryableExceptionsAreRetried() {
+ Log.i("RetryTest", "Running test with ${case.name}")
+ if (attempts.incrementAndGet() < 2) {
+ throw case.supplier.invoke()
+ }
+ assertTrue("Test retried and passed on attempt=${attempts.get()}", true)
+ }
+}