Are you striving to iterate quickly to deliver new features, all while ensuring the reliability and performance of your iOS applications? Efficient workflows are key to achieving this goal. In this article, we'll explore how we can leverage the power of GitHub Actions to automate testing as part of the continuous integration process for iOS applications.
Let's focus on the required steps to automate test execution when a pull-request is created:
Unit tests are crucial in software development as they identify errors early in the process and enable safe code refactoring without regression. Running them often is particularly important in large-scale projects with multiple developers involved to obtain valuable feedback and maintain high code quality standards.
We initiate the "Unit Tests" workflow by establishing a blueprint that runs on agents featuring the latest macOS version. The workflow is either triggered manually via the Web interface or automatically upon branch activity after pull request creation:
name: Unit Tests
on:
pull_request:
workflow_dispatch:
jobs:
execute-unit-tests:
runs-on: macos-latest
steps:
...
Next, we specify the steps that are required to execute unit tests on the agent:
First, we need to checkout the repository to gain access to the source code. Github Actions offers the checkout@v3
step that checks-out your repository under GITHUB_WORKSPACE
:
- name: Checkout repository
uses: actions/checkout@v3
Next, we create and boot the iOS Simulator, that is utilized to execute the tests. By default, xcodebuild
selects any of the existing simulators on the agent, which may result in issues, particularly when using snapshot testing, due to potential variations in device dimensions and properties.
SIMULATOR_DEVICE_TYPE
and SIMULATOR_RUNTIME
are environment variables that specify the exact device type and runtime. This way, all tests are executed on the same simulator accross agents. After the simulator is booted, we store its identifier in an environment variable (SIMULATOR_IDENTIFIER
) to reference it as destination during test execution:
identifier=$(
xcrun simctl create iPhoneForTesting \
com.apple.CoreSimulator.SimDeviceType.$SIMULATOR_DEVICE_TYPE \
com.apple.CoreSimulator.SimRuntime.$SIMULATOR_RUNTIME
)
echo "Created iPhoneForTesting with identifier: $identifier"
xcrun simctl boot $identifier
echo "Booted iPhoneForTesting with identifier: $identifier"
echo "SIMULATOR_IDENTIFIER=$identifier" >> $GITHUB_ENV
As soon as the simulator is configured, we set it as destination and execute the tests after cleaning the project (xcodebuild clean test
). Note that we set a custom derivedDataPath
as well as resultBundlePath
and also enable Code Covergae to access code coverage reports. In addition, we pipe the output generated from xcodebuild
, store it in a local xcodebuild.log
file and also pass it on to xcpretty
that prints the output in a human readable format to the console.
set -o pipefail && xcodebuild clean test \
-scheme "App" \
-derivedDataPath $RUNNER_TEMP/build \
-configuration 'Debug' \
-destination "platform=iOS Simulator,id=${SIMULATOR_IDENTIFIER}" \
-resultBundlePath $RUNNER_TEMP/App.xcresult \
-enableCodeCoverage YES | tee $RUNNER_TEMP/xcodebuild.log | xcpretty
Without specifying set -o pipefail
the command will only consider the exit status of the rightmost command, i.e., of xcpretty
. By specifying set -o pipefail
we ensure that output of xcodebuild
is considered for the exit status of the whole command.
The xcodebuild.log
file contains the raw output of xcodebuild
and is useful to investigate why the command has failed. Sometimes the error is hard to find and may be hidden by xcpretty
. Publising the raw output as build artifact allows us to better understand what went wrong:
- name: Publish xcodebuild.log
uses: actions/upload-artifact@v3
with:
name: xcodebuild.log
path: ${{ runner.temp }}/xcodebuild.log
Finally, after test execution is done, we can remove the simulator from the agent. Note that instead of only removing the simulator thet was created in the second step, we search for the identifiers of all simulators named iPhoneForTesting
to cleanup the agent even when a previous removal has failed:
killall Simulator 2>&1 || true
xcrun simctl list devices | \
grep "iPhoneForTesting" | \
grep -E -o -i "([0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12})" | \
while read -r identifier; do \
xcrun simctl delete "$identifier"; \
"Removed simulator with identifier: $identifier"; \
done
After combining all steps, we obtain the following worklow that is stored as test.yml
in the .github/workflows
directory:
name: Unit Tests
on:
workflow_dispatch:
jobs:
execute-unit-tests:
runs-on: macos-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Create and Boot 'iPhoneForTesting' Simulator
env:
SIMULATOR_DEVICE_TYPE: ${{ vars.SIMULATOR_DEVICE_TYPE }}
SIMULATOR_RUNTIME: ${{ vars.SIMULATOR_RUNTIME }}
run: |
identifier=$(xcrun simctl create iPhoneForTesting com.apple.CoreSimulator.SimDeviceType.$SIMULATOR_DEVICE_TYPE com.apple.CoreSimulator.SimRuntime.$SIMULATOR_RUNTIME)
echo "Created iPhoneForTesting with identifier: $identifier"
xcrun simctl boot $identifier
echo "Booted iPhoneForTesting with identifier: $identifier"
echo "SIMULATOR_IDENTIFIER=$identifier" >> $GITHUB_ENV
- name: Test
env:
SIMULATOR_IDENTIFIER: ${{ env.SIMULATOR_IDENTIFIER }}
run: |
set -o pipefail && xcodebuild clean test \
-scheme "App" \
-derivedDataPath $RUNNER_TEMP/build\
-configuration 'Debug' \
-destination "platform=iOS Simulator,id=${SIMULATOR_IDENTIFIER}" \
-resultBundlePath $RUNNER_TEMP/App.xcresult \
-enableCodeCoverage YES | tee $RUNNER_TEMP/xcodebuild.log | xcpretty
- name: Publish xcodebuild.log
uses: actions/upload-artifact@v3
with:
name: xcodebuild.log
path: ${{ runner.temp }}/xcodebuild.log
- name: Remove 'iPhoneForTesting' Simulator
if: ${{ always() }}
run: |
killall Simulator 2>&1 || true
xcrun simctl list devices | \
grep "iPhoneForTesting" | \
grep -E -o -i "([0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12})" | \
while read -r identifier; do xcrun simctl delete "$identifier"; echo "Removed simulator with identifier: $identifier"; done
In this article, we explored how we can leverage the power of github actions to automate test execution whenever a pull-request is created. This is particularly useful in larger teams, as it effectively minimizes the occurrence of bugs and ensures continuous enhancement of the codebase.
Happy Coding 🚀