Automating your iOS App Development Workflow: Continuous Testing with GitHub Actions

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:

Test Execution Workflow

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:

Step 1: Checkout Repository

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

Step 2: Create and Boot 'iPhoneForTesting' Simulator

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

Step 3: Execute Tests

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.

Step 4: Publish xcodebuild.log

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

Step 5: Remove 'iPhoneForTesting' Simulator

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

Unit Test Workflow

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

Conclusion

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.

References:

Happy Coding 🚀