Running Unit Tests with GitHub Actions

· GitHub Actions to TestFlight - from zero to hero

Introduction

Welcome back to our “GitHub Actions to TestFlight - from zero to hero” series! In our last post, we set up a basic GitHub Actions workflow that builds our iOS app every time we push to the main branch. That’s great, but you know what’s even better? Making sure our app actually works before we ship it.

Today, we’re going to extend our workflow to run unit tests automatically. This builds on what we learned in our running unit tests with Fastlane post, but now we’re going to do it in the cloud, automatically, every time we push code.

Because let’s be honest - we all write unit tests (right?), but do we always remember to run them before pushing? I certainly don’t. Let the robots handle it!

Why run tests in CI?

Catch issues early

Running tests on every push and pull request means you catch issues before they make it to the main branch. This is especially important when you’re working in a team - you don’t want to be the person who breaks the build for everyone else.

Consistency

Your local machine might have different versions of Xcode, different simulators, or different environment variables. Running tests in CI ensures that everyone’s tests run in the same environment.

Test against multiple configurations

Want to make sure your app works on iOS 15, 16, and 17? Or test against both iPhone and iPad simulators? CI makes this easy with matrix builds.

Building on our existing workflow

Remember the workflow we created in the previous post? We’re going to extend it to run tests as well. Let’s start by adding a new job to our workflow:

name: Build and Test iOS App

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: macos-latest

    strategy:
      matrix:
        device: ['iPhone 16 Pro']

    steps:
    - uses: actions/checkout@v4

    # Set up Xcode 16
    - name: Set up Xcode
      uses: maxim-lobanov/setup-xcode@v1
      with:
        xcode-version: "16.2.0"

    - name: Set up Ruby
      uses: ruby/setup-ruby@v1
      with:
        ruby-version: 3.1
        bundler-cache: true

    - name: Install dependencies
      run: bundle install

    - name: Run tests
      run: bundle exec fastlane test

    - name: Upload test results
      uses: actions/upload-artifact@v4
      if: always()
      with:
        name: test-results-${{ matrix.device }}
        path: |
          fastlane/test_output/
          *.junit

Let me break this down for you.

Understanding matrix builds

strategy:
  matrix:
    device: ['iPhone 16 Pro']

The matrix strategy allows us to run the same job with different configurations. In this case, we’re running tests with different devices. You can easily extend this to test multiple devices:

strategy:
  matrix:
    device: ['iPhone 16 Pro', 'iPad Pro 13-inch (M4)', 'iPhone SE (3rd generation)']

This would create 3 different jobs, each testing on a different device.

If you need to test with different Xcode versions, you could do something like:

strategy:
  matrix:
    xcode-version: ['15.4', '16.0']
    device: ['iPhone 16 Pro']

steps:
- uses: actions/checkout@v4

- name: Set up Xcode
  uses: maxim-lobanov/setup-xcode@v1
  with:
    xcode-version: ${{ matrix.xcode-version }}

This would test your app with both Xcode 15.4 and 16.0, which is useful when you want to ensure compatibility across different Xcode versions.

Setting up Fastlane for tests

Just like in our unit testing with Fastlane post, we need a test lane in our Fastfile. Building on the enhanced build lane from our previous post, your Fastfile should look like this:

#!/usr/bin/ruby
# frozen_string_literal: true

fastlane_version '2.225.0'

default_platform :ios
platform :ios do

  desc "Build the app"
  lane :build do
    # Build the app (code signing should be done separately)
    gym(
      scheme: "YourAppScheme",
      configuration: "Release",
      export_method: "app-store",
      output_directory: "./build",
      export_options: {
        uploadBitcode: false,
        uploadSymbols: true,
        compileBitcode: false
      }
    )
  end

  desc "Run tests"
  lane :test do
    scan(
      scheme: "YourAppScheme",
      clean: true,
      destination: "platform=iOS Simulator,name=iPhone 16 Pro",
      output_directory: "./fastlane/test_output",
      output_types: "html,junit"
    )
  end

end

This approach uses a fixed destination, which is more reliable than dynamic device selection in CI environments.

Making it more robust

Let’s enhance our workflow to handle some common scenarios:

name: Build and Test iOS App

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: macos-latest

    strategy:
      matrix:
        device: ['iPhone 16 Pro', 'iPhone SE (3rd generation)']
      fail-fast: false

    steps:
    - uses: actions/checkout@v4

    # Set up Xcode 16
    - name: Set up Xcode
      uses: maxim-lobanov/setup-xcode@v1
      with:
        xcode-version: "16.2.0"

    - name: Set up Ruby
      uses: ruby/setup-ruby@v1
      with:
        ruby-version: 3.1
        bundler-cache: true

    - name: Cache Xcode derived data
      uses: actions/cache@v4
      with:
        path: ~/Library/Developer/Xcode/DerivedData
        key: ${{ runner.os }}-xcode-deriveddata-${{ hashFiles('**/*.xcodeproj/project.pbxproj') }}
        restore-keys: |
          ${{ runner.os }}-xcode-deriveddata-

    - name: Install dependencies
      run: bundle install

    # Optional: List available simulators for debugging
    - name: List available simulators
      run: xcrun simctl list devices available

    # Note: For unit tests, you typically don't need code signing
    # Remove these steps if your tests don't require it

    - name: Run tests
      run: bundle exec fastlane test

    - name: Upload test results
      uses: actions/upload-artifact@v4
      if: always()
      with:
        name: test-results-${{ matrix.device }}
        path: |
          fastlane/test_output/
          *.junit

    - name: Upload coverage reports
      uses: actions/upload-artifact@v4
      if: always()
      with:
        name: coverage-${{ matrix.device }}
        path: |
          *.xcresult

Key features explained

Caching

The cache step stores Xcode’s derived data between runs, significantly speeding up builds. The cache key is based on your project file, so it invalidates when you change your project structure.

Destination parameter

Using destination: "platform=iOS Simulator,name=iPhone 16 Pro" in the Fastfile is more reliable than device name resolution, especially in CI environments where simulator IDs can be inconsistent.

fail-fast: false

By default, if one job in a matrix fails, GitHub Actions cancels all other jobs. Setting fail-fast: false means all jobs run to completion, so you see all test results, not just the first failure.

Handling different types of tests

You can create separate jobs for different types of tests:

jobs:
  unit-tests:
    runs-on: macos-latest
    steps:
      # ... setup steps
      - name: Run unit tests
        run: bundle exec fastlane unit_tests

  integration-tests:
    runs-on: macos-latest
    needs: unit-tests
    steps:
      # ... setup steps
      - name: Run integration tests
        run: bundle exec fastlane integration_tests

  ui-tests:
    runs-on: macos-latest
    needs: [unit-tests, integration-tests]
    steps:
      # ... setup steps
      - name: Run UI tests
        run: bundle exec fastlane ui_tests

The needs keyword creates dependencies between jobs. Integration tests wait for unit tests to pass, and UI tests wait for both unit and integration tests to complete.

Advanced: Parallel testing

Remember our parallel testing post? We can leverage that in GitHub Actions too:

desc "Run tests in parallel"
lane :test do
  scan(
    scheme: "YourAppScheme",
    clean: true,
    destination: "platform=iOS Simulator,name=iPhone 16 Pro",
    parallel_testing: true,
    concurrent_workers: 4,
    output_directory: "./fastlane/test_output",
    output_types: "html,junit"
  )
end

This runs your tests faster by using multiple worker processes.

Test reporting

GitHub Actions can parse JUnit XML files and show test results directly in pull requests:

- name: Publish test results
  uses: dorny/test-reporter@v1
  if: always()
  with:
    name: Test Results
    path: 'fastlane/test_output/*.junit'
    reporter: java-junit

This creates a test results summary in the GitHub UI.

Troubleshooting common issues

”No simulators available” or “Unable to find a destination”

This is the most common issue! The simulator you specified might not be available on the runner. Different Xcode versions come with different simulator versions. For example, Xcode 16.2 comes with iPhone 16 simulators, not iPhone 15.

First, list available simulators to see what’s actually available:

- name: List available simulators
  run: xcrun simctl list devices available

Then update your device names to match what’s actually available. Common device names in Xcode 16.2:

Pro tip: Instead of using device or devices, use destination for more reliable results:

scan(
  scheme: "YourAppScheme",
  destination: "platform=iOS Simulator,name=iPhone 16 Pro"
)

This approach is more explicit and avoids simulator ID caching issues.

”Test failed due to timeout”

CI environments can be slower than your local machine. You might need to increase timeouts in your test configuration.

”Code signing error”

For running tests, you usually don’t need code signing. Make sure your test target’s code signing is set to “Don’t Code Sign” or use the iOS Simulator SDK.

Clearing simulator cache

If you’re still getting old simulator ID errors after updating device names, try clearing cached data:

rm -rf ~/Library/Developer/Xcode/DerivedData
rm -rf YourProject.xcodeproj/project.xcworkspace/xcuserdata

What’s next?

Great! Now we’ve got a workflow that builds and tests our app automatically. But we’re not done yet. In the next post, we’re going to put it all together and create a workflow that automatically deploys to TestFlight when we tag our branch with “TestFlight”.

That’s where things get really exciting - we’ll be dealing with certificates, provisioning profiles, App Store Connect API keys, and actually shipping our app to testers.

Conclusion

Running tests in CI seems like overhead at first, but once you have it set up, you’ll wonder how you ever lived without it. No more “oops, I forgot to run the tests” commits, no more broken builds, and no more manual testing every time you want to make sure everything still works.

The workflow we’ve created today catches issues early, tests against multiple configurations, and gives you confidence that your app works before you ship it. Best of all, it runs automatically - you don’t have to remember to do anything!

If you run into any issues, the GitHub Actions logs will usually tell you exactly what went wrong.

Next up: the main event - deploying to TestFlight with tags! 🚀