<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: cloudf.one</title>
    <description>The latest articles on DEV Community by cloudf.one (cloudfone).</description>
    <link>https://dev.to/cloudfone</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F14857%2F4aa091a5-20f1-486e-8346-b9422958c344.png</url>
      <title>DEV Community: cloudf.one</title>
      <link>https://dev.to/cloudfone</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/cloudfone"/>
    <language>en</language>
    <item>
      <title>Run Android Tests on Real Phones in GitHub Actions</title>
      <dc:creator>Xavier Fok</dc:creator>
      <pubDate>Tue, 06 Oct 2026 08:27:35 +0000</pubDate>
      <link>https://dev.to/cloudfone/run-android-tests-on-real-phones-in-github-actions-3777</link>
      <guid>https://dev.to/cloudfone/run-android-tests-on-real-phones-in-github-actions-3777</guid>
      <description>&lt;p&gt;Emulators in CI are fine until your app cares about being on a real device. Once a sensor check or hardware-specific behaviour enters the picture, a green pipeline stops meaning much.&lt;/p&gt;

&lt;p&gt;I run a rack of real Android phones in Singapore that people drive over adb, and this came out of running it. Here is the workflow I'd start from, including the parts that fail quietly the first time you try it on a hosted runner.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;android device tests&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;device-tests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;android-actions/setup-android@v3&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build debug apk&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./gradlew assembleDebug&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;connect&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ADB_ENDPOINT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.ADB_ENDPOINT }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;for i in 1 2 3 4 5; do&lt;/span&gt;
            &lt;span class="s"&gt;adb connect "$ADB_ENDPOINT" &amp;amp;&amp;amp; sleep 2&lt;/span&gt;
            &lt;span class="s"&gt;if adb devices | grep -q "${ADB_ENDPOINT}.*device"; then&lt;/span&gt;
              &lt;span class="s"&gt;echo "connected"; break&lt;/span&gt;
            &lt;span class="s"&gt;fi&lt;/span&gt;
            &lt;span class="s"&gt;echo "retry $i"; adb disconnect "$ADB_ENDPOINT"; sleep 5&lt;/span&gt;
          &lt;span class="s"&gt;done&lt;/span&gt;
          &lt;span class="s"&gt;adb devices&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the first half. The rest of the job installs, tests and releases, and I'll get to it below.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of it
&lt;/h2&gt;

&lt;p&gt;A GitHub Actions runner is a throwaway Linux box with no phone attached. To test on a real device, the runner connects out to a phone over adb, installs your build, runs the tests, and drops the connection. The phone lives elsewhere and stays up between runs. The runner is the disposable part.&lt;/p&gt;

&lt;p&gt;You need three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;platform-tools on the runner, so you have a current &lt;code&gt;adb&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;the phone's &lt;code&gt;host:port&lt;/code&gt; stored as a secret&lt;/li&gt;
&lt;li&gt;a test runner, such as Espresso or instrumented tests through Gradle, or Appium&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Getting the phone reachable is a setup step, not part of the test. Keep it separate in your head and in your job, because it fails differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the endpoint in a secret
&lt;/h2&gt;

&lt;p&gt;Put the adb endpoint in a repository secret called &lt;code&gt;ADB_ENDPOINT&lt;/code&gt; and read it from the environment in each step. That keeps it out of the workflow file and out of logs. If your provider also hands you a token for authorising the adb connection, store it the same way.&lt;/p&gt;

&lt;p&gt;One caution: GitHub masks secret values in logs, but a masked value can still leak if you transform it before printing. Don't pipe it through &lt;code&gt;sed&lt;/code&gt; or &lt;code&gt;base64&lt;/code&gt; into something that echoes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest of the job
&lt;/h2&gt;

&lt;p&gt;After the connect step, add install, test, and disconnect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;install build&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ADB_ENDPOINT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.ADB_ENDPOINT }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;adb -s "$ADB_ENDPOINT" install -r app/build/outputs/apk/debug/app-debug.apk&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;run tests&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ADB_ENDPOINT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.ADB_ENDPOINT }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ANDROID_SERIAL="$ADB_ENDPOINT" ./gradlew connectedDebugAndroidTest&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;disconnect&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ADB_ENDPOINT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.ADB_ENDPOINT }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;adb disconnect "$ADB_ENDPOINT"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;if: always()&lt;/code&gt; on the disconnect step is not optional. If a test fails and the job stops, you still want to release the device cleanly so the next run doesn't trip over a stale connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  The connect retry is the whole game
&lt;/h2&gt;

&lt;p&gt;The most common cause of flaky device CI is &lt;code&gt;adb connect&lt;/code&gt; racing the network. A hosted runner cold-starts every time, so the first &lt;code&gt;adb connect&lt;/code&gt; often returns before the tunnel is really up, and &lt;code&gt;adb devices&lt;/code&gt; lists the phone as &lt;code&gt;offline&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The loop in the first block handles that: connect, wait, check that the state is &lt;code&gt;device&lt;/code&gt;, and only then move on. If you want the job to fail loudly when all five attempts are used up, add a final check after the loop that exits non-zero when the endpoint still isn't in the &lt;code&gt;device&lt;/code&gt; state.&lt;/p&gt;

&lt;p&gt;Treat a missing device as a setup failure, not a test failure. That way "the phone wasn't reachable" and "the app is broken" show up as different red marks, and you stop wasting an afternoon debugging the app when the network was the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas specific to CI
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;adb server version doesn't match&lt;/code&gt;: the runner's adb is stale. Use &lt;code&gt;setup-android&lt;/code&gt; so you get current platform-tools.&lt;/li&gt;
&lt;li&gt;Install fails with a signature error: a previous build was signed differently. Run &lt;code&gt;adb uninstall &amp;lt;package&amp;gt;&lt;/code&gt; before installing.&lt;/li&gt;
&lt;li&gt;Tests hang, then time out: default adb timeouts are tuned for a cable, not a network hop. Raise them. For Appium, bump &lt;code&gt;adbExecTimeout&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Device &lt;code&gt;offline&lt;/code&gt; right after connect: that's the race described above. Retry with a state check.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Two PRs, one phone
&lt;/h2&gt;

&lt;p&gt;This one hits teams as they grow. Two pull requests trigger at the same moment, both jobs connect to the same phone, and they corrupt each other's state. Installs get replaced mid-test, and tests fail in ways that look like app bugs and aren't.&lt;/p&gt;

&lt;p&gt;A concurrency group makes device jobs queue instead of colliding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;android-device&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cancel-in-progress: false&lt;/code&gt; matters here. It means a running test finishes rather than being killed by a newer push, which is what you want when a real device is mid-test. A cancelled job on a real handset can leave the app half-installed or a test mid-write, and the next run inherits that.&lt;/p&gt;

&lt;p&gt;If you'd rather lock the phone itself than rely on the workflow-level queue, there's a worked version of the full flow (tunnel, lock, connect, test, release) in &lt;a href="https://github.com/Xavierfok/cloud-android-automation-examples" rel="noopener noreferrer"&gt;this examples repo&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scaling to a matrix of phones
&lt;/h2&gt;

&lt;p&gt;Once one phone works, a matrix across several devices is a small step. Give each phone its own secret and fan out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;matrix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;phone&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;ADB_ENDPOINT_A&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;ADB_ENDPOINT_B&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;ADB_ENDPOINT_C&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then pull the right endpoint per job with &lt;code&gt;secrets[matrix.phone]&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ADB_ENDPOINT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets[matrix.phone] }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each job now drives its own handset in parallel. Note that the concurrency group above would serialise the whole matrix, so give each matrix entry its own group, for example &lt;code&gt;android-device-${{ matrix.phone }}&lt;/code&gt;, so different phones run side by side while jobs for the same phone still queue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bother with real hardware
&lt;/h2&gt;

&lt;p&gt;A passing run on an emulator tells you the app works on an emulator. A passing run on a real phone on a real mobile network tells you it works for an actual user. For anything that reads device signals, that is the difference between a meaningful test and a comforting one.&lt;/p&gt;

&lt;p&gt;The cost is that your pipeline now depends on a network path to a physical device, which is why the retry, the always-disconnect step, and the concurrency group are not extras. Without them the first week of real-device CI is mostly chasing flakes that have nothing to do with your code.&lt;/p&gt;

</description>
      <category>android</category>
      <category>testing</category>
      <category>automation</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
