Skip to content

Add on-device retry rule for flaky instrumented tests, Fixes AB#3678341#3181

Open
p3dr0rv wants to merge 15 commits into
devfrom
pedroro/instrumented-test-retry
Open

Add on-device retry rule for flaky instrumented tests, Fixes AB#3678341#3181
p3dr0rv wants to merge 15 commits into
devfrom
pedroro/instrumented-test-retry

Conversation

@p3dr0rv

@p3dr0rv p3dr0rv commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

What

Adds a decoupled JUnit4 retry mechanism for instrumented (androidTest) tests. The Gradle org.gradle.test-retry plugin only retries JVM/Robolectric unit tests (tasks.withType(Test)); it does nothing for connectedAndroidTest.

Changes

  • FlakyTestRetryRule (TestRule) + @RetryFlakyTest annotation in testutils. Opt-in, honours AssumptionViolatedException (skips aren't retried). Names are intentionally distinct from the UI-automation RetryTestRule/RetryOnFailure to prevent silent mis-imports.
  • Wire testutils into common androidTest with transitive = false (pulls only the rule classes; avoids dragging testutils' transitive graph e.g. a second BouncyCastle line).
  • RetryRuleValidationTest — sanity check that the rule re-runs and passes on a later attempt.
  • pr-common-instrumented.yml — PR-validation pipeline running common:connectedLocalDebugAndroidTest on an emulator.

AB#3678341

https://identitydivision.visualstudio.com/Engineering/_build/results?buildId=1665439&view=results

Usage

@get:Rule val flakyTestRetryRule = FlakyTestRetryRule()

@Test
@RetryFlakyTest(retryCount = 2) // up to 3 attempts
fun someFlakyTest() { ... }
```n
> Retry is a mitigation, not a fix — it stays opt-in so flaky tests remain visible and get fixed.

The org.gradle.test-retry plugin only retries JVM/Robolectric unit tests, not connectedAndroidTest. Add a decoupled JUnit4 rule for instrumented suites:

- FlakyTestRetryRule (TestRule) + @RetryFlakyTest annotation in testutils (opt-in; honours AssumptionViolatedException). Names are intentionally distinct from the UI-automation RetryTestRule/RetryOnFailure to avoid mis-imports.

- Wire testutils into common androidTest via transitive=false.

- RetryRuleValidationTest: sanity-checks the rule re-runs and passes on a later attempt.

- pr-common-instrumented.yml: PR-validation pipeline running common:connectedLocalDebugAndroidTest.
@github-actions

Copy link
Copy Markdown

❌ Work item link check failed. Description does not contain AB#{ID}.

Click here to Learn more.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an opt-in, on-device retry mechanism for flaky instrumented (androidTest) JUnit4 tests, and wires it into Common’s instrumented test suite along with a new PR-validation pipeline to run common:connectedLocalDebugAndroidTest on an emulator.

Changes:

  • Introduces FlakyTestRetryRule (TestRule) and @RetryFlakyTest annotation in testutils for retrying annotated instrumented tests.
  • Wires :testutils into common’s androidTestImplementation (with transitive = false) and adds an instrumented sanity test to validate retry behavior.
  • Adds an Azure Pipelines PR validation YAML to boot an emulator and run Common’s instrumented tests, publishing logcat + HTML reports.

⚠️ Warning: Changes detected ⚠️

Please follow the recommendations in Adding or Updating Gradle Dependencies.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
testutils/src/main/java/com/microsoft/identity/internal/testutils/rules/FlakyTestRetryRule.kt Adds an opt-in JUnit4 rule that retries annotated instrumented tests on-device.
testutils/src/main/java/com/microsoft/identity/internal/testutils/annotations/RetryFlakyTest.kt Adds runtime annotation to configure retry count at method/class scope.
common/src/androidTest/java/com/microsoft/identity/common/RetryRuleValidationTest.kt Adds an instrumented sanity test proving the rule can re-run and later pass.
common/build.gradle Adds androidTestImplementation on :testutils (non-transitive) to consume the retry rule/annotation.
azure-pipelines/pull-request-validation/pr-common-instrumented.yml Adds PR validation pipeline to run common:connectedLocalDebugAndroidTest on an emulator and publish artifacts.

Comment thread azure-pipelines/pull-request-validation/pr-common-instrumented.yml Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…xception in FlakyTestRetryRule

- ClassLevelAnnotationRetryValidationTest: verifies that a class-level @RetryFlakyTest
  annotation (no method-level annotation) is picked up by FlakyTestRetryRule and retries
  the test on failure.

- RetryRuleValidationTest.assumptionViolated_isSkippedWithoutRetry: verifies that a test
  skipped via Assume.assumeTrue(false) is NOT retried; if the rule incorrectly retried,
  the internal counter assertion (expects 1, gets 2) would change the outcome from
  SKIPPED to FAILED, surfacing the regression.
p3dr0rv and others added 10 commits July 10, 2026 16:44
The named 1ES pool with image/os sub-properties only works when extending the 1ES template; standalone it returned a Windows agent, so the bash emulator steps failed under cmd.exe. Use vmImage: ubuntu-latest (preinstalled Android SDK), enable /dev/kvm, and use bash steps with the preinstalled cmdline-tools.
…-store import

The repo automation-cert.yml uses Import-PfxCertificate (Windows-only). Instrumented tests run on Linux and only need the pfx file to adb-push, so download the KeyVault secret and write LabAuth.pfx directly.
…isor)

Microsoft-hosted Linux agents have no /dev/kvm, so the x86_64 emulator hung at boot. macOS hosted agents provide Hypervisor.framework. Switch pool to macOS-latest, drop Linux-only apt/KVM/disk steps, decode the cert with openssl (portable base64), and pick the system-image ABI by agent arch (arm64-v8a on Apple Silicon, x86_64 on Intel).
…ad of fixed sleep

sendBroadcast() delivery is asynchronous; the hard-coded Thread.sleep(100)+assert races on slower CI emulators (got null on the macOS emulator). Poll up to ~5s for the callback and mark the field volatile for cross-thread visibility.
…untu validation in AuthClientAndroidPipelines)
@github-actions

Copy link
Copy Markdown

✅ Work item link check complete. Description contains link AB#3678341 to an Azure Boards work item.

@github-actions

Copy link
Copy Markdown

❌ Work item link check failed. Description contains AB#3678341 but the Bot could not link it to an Azure Boards work item.

Click here to learn more.

@p3dr0rv p3dr0rv added the No-Changelog This Pull-Request has no associated changelog entry. label Jul 16, 2026
@github-actions github-actions Bot changed the title Add on-device retry rule for flaky instrumented tests Add on-device retry rule for flaky instrumented tests, Fixes AB#3678341 Jul 16, 2026
Comment thread common/build.gradle
// transitive = false keeps only the rule's classes and avoids dragging testutils' whole
// transitive graph onto the androidTest classpath.
androidTestImplementation(project(path: ':testutils')) {
transitive = false

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

transitive = false doesn't keep only the rule's classes though. it still puts ALL of testutils on the androidTest classpath (the keyvault/labapi/lab helpers, everything), it just drops testutils' transitive deps (gson, nimbus, robolectric, etc). so the whole graph is still there, just now half of it can't be used - any common instrumented test that touches those other testutils classes would fail to compile / NoClassDefFoundError since their deps are gone.

if the intent is just to keep robolectric and the JVM-only stuff off the instrumented classpath then fine, but the comment isn't accurate. do these 2 classes even need to live in testutils, or could they go somewhere lighter that common's androidTest can depend on cleanly?


for (attempt in 1..totalAttempts) {
try {
base.evaluate()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC this re-runs base on the same test instance, so @Before/@after re-run each attempt but any field initialized at declaration (not in @before) carries over between attempts. The validation tests lean on that with their counters.

The KDoc pitches this as equivalent to the gradle test-retry plugin, but that one re-runs with a fresh instance I believe. So a stateful flaky test that mutates a field before it fails won't get a clean slate on retry here.

can we note that in the annotation KDoc so people writing flaky tests don't get surprised?

for (attempt in 1..totalAttempts) {
try {
base.evaluate()
if (attempt > 1) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when a test passes on retry we just Log.i to logcat and return, so in the test report a test that flaked twice then passed on the 3rd attempt looks identical to one that passed first try. doesn't that defeat the "stays opt-in so flaky tests remain visible" goal? the flakiness ends up buried in logcat and the result is a clean green.

is something downstream parsing these logs to surface the tests that are still flaking?

echo "PR title updated to include AB ID."
exit 0
fi
if [ "${{ steps.extract-ab.outputs.abid }}" == "" ]; then

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bit confused on the no-AB# path. Abort if no AB ID found (line 37) does exit 1 when abid is empty, which fails the job. But this branch treats an empty abid as skipped by design and exits 0. By the time we get here the abort already failed the run, so this exit 0 can't rescue it. So is a missing AB# meant to fail the PR or pass? The two steps disagree.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

No-Changelog This Pull-Request has no associated changelog entry.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants