
Product news and testing tips.
A mobile CI/CD testing pipeline runs build, unit tests (Espresso/XCTest), and UI/E2E tests (Espresso/XCUITest/Maestro) as parallel Android and iOS jobs, uploads every run's results — pass or fail — to a results layer, applies a quality gate before promotion, then ships to TestFlight or the Play internal track. The two platforms should run as separate, concurrent CI jobs, not one after another, and every results-upload step needs `if: always()` so a red build still reports what broke.
Key takeaways
- A mobile CI/CD testing pipeline has six stages: build, unit tests, UI/E2E tests, collect + analyze results, quality gate, release — plus an optional staged rollout.
- Run Android and iOS test jobs in parallel, not sequentially. GitHub Actions jobs run concurrently by default unless one job explicitly needs() another, so splitting the suite into two jobs is close to a free speed win.
- Upload results with if: always() on every job — a report step that only runs on green builds means the one build you most need visibility into produces no report at all.
- Not caching Gradle and CocoaPods dependencies is one of the most common, and easiest to fix, mobile CI slowdowns.
- macOS runners are billed at roughly 10x the rate of Linux runners on GitHub Actions — fewer, well-utilized iOS jobs beat many small, chatty ones.
- The quality gate sits after results land in a results/observability layer and before a build is promoted to TestFlight or the Play internal track; the gate logic itself is a separate decision, not part of the pipeline's shape.
A pipeline that only runs tests isn’t a mobile CI/CD testing pipeline — it’s a build with an extra step. The real thing builds the app, tests it on both platforms, collects and analyzes what happened, gates the release on that analysis, and only then ships. This post walks through that shape end to end, with a real GitHub Actions workflow running Android and iOS as parallel jobs, and where a quality gate fits before you get into gate logic itself. Where we reference Qualflare, we describe only what it actually does.
The short answer
A mobile CI/CD testing pipeline runs six stages: build, unit tests, UI/E2E tests, collect + analyze results, quality gate, release — with an optional staged rollout after that. Android and iOS should run as two parallel CI jobs, not one sequential job, because they use unrelated toolchains (Gradle/emulator vs. Xcode/simulator) that have nothing to wait on each other for. Every job uploads its results with if: always(), win or lose, because the failing run is the one you need the report from. Worth saying up front: Qualflare sits at the “collect + analyze” stage below — it’s a results and observability layer, not a device-execution cloud. It doesn’t provision emulators, simulators, or real devices, and it doesn’t run your build or test steps; it ingests whatever JUnit-XML-compatible file your pipeline’s jobs already produced.
The shape of a mobile CI/CD testing pipeline
Every mature mobile pipeline follows the same six stages, whatever CI platform runs it:
- Build — compile the app for each platform (a debug or release-config build, depending on the stage).
- Unit tests — fast, no-device tests: Espresso’s JVM-local tests on Android, XCTest on iOS.
- UI/E2E tests — device- or simulator-backed tests: Espresso and XCUITest for native flows, Maestro where a cross-platform flow makes sense (Maestro writes JUnit-XML natively via
maestro test --format junit, so it drops into either platform’s job or its own). - Collect + analyze results — every job uploads its results file; a results/observability layer aggregates pass rate, flaky-test signal, and failure clusters across both platforms and every run.
- Quality gate — a pass/fail decision, read off that aggregated analysis, that decides whether the build is allowed to move toward release.
- Release — promote the gated build to TestFlight (iOS) or the Play internal track (Android), then, optionally, a staged rollout to a percentage of production users before a full release.
Stages 2 and 3 are where Android and iOS diverge — different runners, different toolchains, different test frameworks — which is exactly why they belong in separate CI jobs rather than one long sequential script.
A real pipeline: Android and iOS in parallel on GitHub Actions
Here’s a single workflow with an Android job and an iOS job, each running its own build-test-upload sequence, using the same commands and qf collect syntax as Espresso test reporting and XCTest test reporting:
# .github/workflows/mobile-ci.yml
name: Mobile CI
on:
pull_request:
push:
branches: [main]
jobs:
android:
name: Android — Espresso
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
cache: 'gradle' # caches ~/.gradle/caches and ~/.gradle/wrapper
- name: Run Espresso tests on an emulator
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
script: ./gradlew connectedAndroidTest
- name: Upload results to Qualflare
if: always() # upload even when tests fail — that's the point
run: qf myapp collect app/build/outputs/androidTest-results/connected/*.xml --format espresso
ios:
name: iOS — XCTest/XCUITest
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Cache CocoaPods
uses: actions/cache@v4
with:
path: Pods
key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }}
restore-keys: |
${{ runner.os }}-pods-
- name: Run XCTest / XCUITest suite
run: |
xcodebuild test \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-resultBundlePath TestResults.xcresult
- name: Convert .xcresult to JUnit-XML
if: always() # produce a report even when tests fail
run: |
brew install a7ex/homebrew-formulae/xcresultparser
xcresultparser -o junit TestResults.xcresult > junit.xml
- name: Upload results to Qualflare
if: always()
run: qf myapp collect junit.xml --format xctest
Neither job declares needs: on the other, so GitHub Actions schedules both the moment the workflow triggers, subject to runner availability. The Android job caches Gradle’s dependency and wrapper directories through setup-java’s built-in cache: gradle option; the iOS job caches the Pods directory, keyed on Podfile.lock, so pod install doesn’t re-resolve and re-download the same dependency graph on every run. Both upload steps carry if: always() for the same reason: a build that fails its tests is the one you most need a report from, and a step gated on success alone will silently skip it.
Why parallel jobs, not sequential, matter for pipeline speed
Two independent toolchains running one after another is wasted wall-clock time — Android’s build and test cycle doesn’t depend on iOS’s, and vice versa, so there’s no correctness reason to serialize them. GitHub’s own docs confirm the default behavior does the right thing without extra configuration: “by default, jobs have no dependencies and run in parallel with each other” (GitHub Actions docs). That mirrors the same lever this site has covered for speeding up a CI test suite in general — run fewer tests where you can, and run the rest concurrently rather than sequentially. For a mobile pipeline specifically, “concurrently” starts one level up from test sharding: it starts with not making Android wait on iOS, or the other way around, before either even begins.
Where the quality gate fits
The gate sits after both jobs’ results have landed in your results/observability layer, and before the build is promoted toward TestFlight or the Play internal track. Concretely: the Android and iOS jobs above each upload their own results; a step (or a separate, later job) then reads the aggregated pass rate, flaky-test signal, and failure severity across both platforms and decides pass or block. What actually goes into that decision — pass-rate thresholds, how flaky tests are weighted, who can override a red gate for a hotfix — is its own design problem, covered in depth in Mobile Release Readiness: Quality Gates for Android & iOS. This post is only about where the gate sits in the pipeline’s shape, not how it decides.
Common pitfalls in mobile CI/CD pipelines
| Pitfall | What it costs you |
|---|---|
| No Gradle/CocoaPods caching | Every run re-resolves and re-downloads the full dependency graph — minutes added to every single build. |
| Android and iOS run sequentially | Total pipeline time becomes the sum of both platforms’ time instead of the max of the two. |
Upload step lacks if: always() | Failing builds — the ones you most need visibility into — upload nothing at all. |
| Too many small macOS jobs | GitHub Actions bills macOS runners at roughly 10x the per-minute rate of Linux runners, and Windows at roughly 2x (GitHub Actions billing docs) — many small, frequently-triggered iOS jobs multiply that rate across more job-starts than necessary. |
Beyond the table: not caching dependencies is the single easiest fix on this list — a cache: gradle option and a keyed actions/cache step for Pods cost nothing to add and pay back on the very next run. Sequential platform jobs are usually an artifact of copying a single-platform pipeline and bolting the second platform on as another step in the same job, rather than a deliberate choice — splitting them into parallel jobs, as in the workflow above, is often a small diff. And macOS runner cost is a real, measurable line item, not an abstraction: consolidating iOS CI into fewer, well-utilized jobs — batching commits with a merge queue, running the full simulator matrix only on main and a lighter subset on pull requests — is a legitimate cost lever precisely because of that 10x multiplier.
None of this is a hypothetical concern. Mobile build instability itself is measurably rising: Bitrise’s analysis of over 10 million builds found the share of teams experiencing test flakiness grew from 10% in 2022 to 26% in 2025 — and the same report found teams using monitoring tooling see roughly 25% fewer flaky reruns. A pipeline that skips caching, runs platforms sequentially, or drops failure data on red builds is compounding a problem that’s already getting harder, not staying flat.
Start free with Qualflare — send results from both jobs above and get flaky-test scoring, failure clustering, and per-launch risk across Android and iOS in one dashboard.
Frequently asked questions
What does a mobile CI/CD testing pipeline look like?
Six stages in sequence, with room for platform jobs to run side by side: build the app, run unit tests (Espresso on Android, XCTest on iOS), run UI/E2E tests (Espresso/XCUITest for native flows, Maestro for cross-platform flows), collect and analyze the results, pass through a quality gate, then release to TestFlight or the Play internal track — optionally followed by a staged rollout to a percentage of users.
Should Android and iOS tests run in the same CI job or separate parallel jobs?
Separate jobs, run in parallel. Android needs a Linux (or macOS) runner with an emulator; iOS needs a macOS runner with a simulator — forcing them through one job serializes two unrelated toolchains for no reason. GitHub Actions runs jobs concurrently by default unless one explicitly depends on the other via needs(), so splitting them is mostly a config change, not new infrastructure.
Where does a quality gate fit in a mobile CI/CD pipeline?
After results from both platforms have landed in your results/observability layer and before a build gets promoted toward release. It reads the aggregated pass rate, flaky-test signal, and failure severity across both jobs and decides whether the build is safe to ship — the gate’s own logic (thresholds, overrides, who can bypass it) is a separate design decision from where it sits in the pipeline.
Why does the results-upload step need if: always()?
Without it, the step only runs when every prior step in the job succeeded — which means it skips exactly the run you most need data from: the one where tests failed. if: always() (or the equivalent on other CI platforms) makes the upload run regardless of the test step’s outcome, so a red build still produces a report instead of silently vanishing.
How much more expensive are macOS CI runners than Linux runners?
On GitHub Actions, macOS runners consume billed minutes at roughly 10x the rate of Linux runners (Windows runners are about 2x). That multiplier makes many small, frequently-triggered iOS jobs far more expensive than a Linux equivalent, so consolidating iOS CI into fewer, well-utilized jobs is a real cost lever, not just tidiness.
Does Qualflare run my Android or iOS tests?
No. Qualflare is a results-management and observability layer, not a device-execution cloud — it never provisions or touches emulators, simulators, or real devices, and it doesn’t run your build or test steps. It ingests whatever JUnit-XML-compatible file your pipeline’s test jobs already produced, wherever they ran.
Sources
- Bitrise — Mobile Insights Report 2025 (10M+ builds, Jan 2022–Jun 2025)
- GitHub Docs — About billing for GitHub Actions (per-minute rate multipliers)
- GitHub Docs — Using jobs in a workflow (jobs run in parallel by default)
- Android Developers — Run tests from the command line
- DORA — The Four Keys (DevOps metrics)


