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:
iPhone 16 Pro
iPhone 16 Pro Max
iPhone SE (3rd generation)
iPad Pro 13-inch (M4)
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! 🚀