Build Pipelines series: How to run iOS tests in parallel

In our series on unlocking the full potential of Bitrise build pipelines, we now turn our attention to iOS developers: how to run tests in parallel. This strategy is not only about accelerating feedback cycles but also about intelligently executing tests only for the components that have changed. Tailored for highly modularized iOS applications, this method ensures rapid, targeted testing without compromising on code quality.

What to expect in this series:

Download our Masters of Efficiency cookbook: 50+ Workflow Recipes for peak performance.

Why parallel test execution matters

In a mobile development landscape where speed and efficiency are paramount, parallelizing unit tests across different modules of an iOS application can significantly reduce wait times for test results. By focusing on changed targets, developers gain faster insights, leading to quicker iterations and ultimately, a more agile development process.

Setting up your pipeline for parallel testing: A step-by-step guide

Step 1: Creating your pipeline structure

Begin with defining a two-stage pipeline: the first stage is to identify changes and plan executions, and the second is to carry out the tests on the affected modules. This foundational setup is crucial for the dynamic execution plan.

pipelines:
  build:
    stages:
    - setup-builds: {}
    - build-modules: {}

Step 2: Configuring conditional workflows

Each module of your application, such as App, Home, Search, and Profile, gets its dedicated workflow within the build-modules stage, with conditions set to run tests based on specific environmental variables. This setup ensures that only relevant tests are executed, saving time and resources. As the build-modules stage executes the tests. It should have as many workflows as many modules the application has. In this example, the app has 4 modules:

  • App: The main target which glues together every other module
  • Home: Contains everything related to the home screen (views, models, actions, …)
  • Search: Holds every component related to the search functionality of the app
  • Profile: Everything profile related
stages:
  setup-builds:
    workflows:
    - export-changed-components: {}
  build-modules:
    workflows:
    - build-module-app:
        run_if: '{{ enveq "MODULE_APP" "true" }}'
    - build-module-home:
        run_if: '{{ (getenv "MODULE_APP" | eq "") | and (enveq "MODULE_HOME" "true") }}'
    - build-module-search:
        run_if: '{{ (getenv "MODULE_APP" | eq "") | and (enveq "MODULE_SEARCH" "true") }}'
    - build-module-profile:
        run_if: '{{ (getenv "MODULE_APP" | eq "") | and (enveq "MODULE_PROFILE" "true") }}'

Let’s deep dive into this for a second. 

The build-module-app workflow has the following run_if condition:

'{{ enveq "MODULE_APP" "true" }}'

This means that the workflow will be executed only if the MODULE_APP env var exists and its value is true. Otherwise, the workflow will be skipped.

The other three run_if  are very similar. Let’s dive into one of them:  

'{{ (getenv "MODULE_APP" | eq "") | and (enveq "MODULE_HOME" "true") }}'

This is a more complex expression with two conditions. 

  • The first one is that the MODULE_APP env var does not exists. We achieve this by comparing it to an empty value. 
  • The second part is for checking if the MODULE_HOME env var is set to true. The workflow will not be executed if one or both condition is not true.

Step 3: Detecting changes and setting variables

This step involves implementing a workflow to detect changes in modules and set corresponding environmental variables. This crucial step involves scripting to identify modified components, utilizing tools like git diff  to check for changes, thereby enabling conditional execution of subsequent workflows.

Let’s look at the below code snippet. The export-changed-components workflow is responsible for figuring out what module tests should be executed. The current implementation is incomplete as the appModuleChanged, homeModuleChanged, searchModuleChanged and profileModuleChanged functions are dummy functions. These need to be adapted to the project structure. For example, one implementation could be to execute git diff main --name-only and check the paths.

If a module has changes, then an env var will be set with the value true. For example, the Home module has the MODULE_HOME env var.

The env vars then have to be shared with the following workflows, which form part of `build-modules`. Thus, onto Step 4.

workflows:
  export-changed-components:
    steps:
    - script@1:
        inputs:
        - content: |-
            #!/usr/bin/env bash
            set -e
            set -o pipefail
            set -x

            appModuleChanged() {
              false
            }

            homeModuleChanged() {
              return
            }

            searchModuleChanged() {
              return
            }

            profileModuleChanged() {
              return
            }


            if appModuleChanged; then
              envman add --key MODULE_APP --value true
            fi

            if homeModuleChanged; then
              envman add --key MODULE_HOME --value true
            fi

            if searchModuleChanged; then
              envman add --key MODULE_SEARCH --value true
            fi

            if profileModuleChanged; then
              envman add --key MODULE_PROFILE --value true
            fi
    - share-pipeline-variable@1:
        inputs:
        - variables: |-
            MODULE_APP
            MODULE_HOME
            MODULE_SEARCH
            MODULE_PROFILE

Step 4: Executing module-specific tests

Organize your workflows to run tests for each module based on the scheme name, with a shared build workflow (which has all of the steps for running the tests) for actual test execution. You can then create as many wrapper workflows as many modules as the project has. These wrapper workflows aim to set the SCHEME env var and then execute the build workflow. This will make sure that the build workflow always executes the correct tests. This approach simplifies your pipeline and ensures that tests for each module are run only when needed, optimizing the testing phase.

workflows:
  build-module-app:
    envs:
    - SCHEME: App
    after_run:
    - build
  build-module-home:
    envs:
    - SCHEME: Home
    after_run:
    - build
  build-module-profile:
    envs:
    - SCHEME: Profile
    after_run:
    - build
  build-module-search:
    envs:
    - SCHEME: Search
    after_run:
    - build
  build:
    steps:
    - git-clone@8: {}
    - xcode-test@5:
        inputs:
        - scheme: "$SCHEME"
    - deploy-to-bitrise-io@2: {}

YML Configuration

The provided YML configuration snippet lays out the structure for your pipeline, from stages and workflows to environment variables and test execution, offering a ready-to-implement template for your project.

---
format_version: '13'
default_step_lib_source: https://github.com/bitrise-io/bitrise-steplib.git
project_type: ios

pipelines:
  build:
    stages:
    - setup-builds: {}
    - build-modules: {}

stages:
  setup-builds:
    workflows:
    - export-changed-components: {}
  build-modules:
    workflows:
    - build-module-app:
        run_if: '{{ enveq "MODULE_APP" "true" }}'
    - build-module-home:
        run_if: '{{ (getenv "MODULE_APP" | eq "") | and (enveq "MODULE_HOME" "true") }}'
    - build-module-search:
        run_if: '{{ (getenv "MODULE_APP" | eq "") | and (enveq "MODULE_SEARCH" "true") }}'
    - build-module-profile:
        run_if: '{{ (getenv "MODULE_APP" | eq "") | and (enveq "MODULE_PROFILE" "true") }}'

workflows:
  build-module-app:
    envs:
    - SCHEME: App
    after_run:
    - build
  build-module-home:
    envs:
    - SCHEME: Home
    after_run:
    - build
  build-module-profile:
    envs:
    - SCHEME: Profile
    after_run:
    - build
  build-module-search:
    envs:
    - SCHEME: Search
    after_run:
    - build
  build:
    steps:
    - git-clone@8: {}
    - xcode-test@5:
        inputs:
        - scheme: "$SCHEME"
    - deploy-to-bitrise-io@2: {}
  export-changed-components:
    steps:
    - script@1:
        inputs:
        - content: |-
            #!/usr/bin/env bash
            set -e
            set -o pipefail
            set -x

            appModuleChanged() {
              false
            }

            homeModuleChanged() {
              return
            }

            searchModuleChanged() {
              return
            }

            profileModuleChanged() {
              return
            }


            if appModuleChanged; then
              envman add --key MODULE_APP --value true
            fi

            if homeModuleChanged; then
              envman add --key MODULE_HOME --value true
            fi

            if searchModuleChanged; then
              envman add --key MODULE_SEARCH --value true
            fi

            if profileModuleChanged; then
              envman add --key MODULE_PROFILE --value true
            fi
    - share-pipeline-variable@1:
        inputs:
        - variables: |-
            MODULE_APP
            MODULE_HOME
            MODULE_SEARCH
            MODULE_PROFILE

app:
  envs:
  - BITRISE_PROJECT_PATH: App.xcworkspace
    opts:
      is_expand: false

meta:
  bitrise.io:
    stack: osx-xcode-15.0.x
    machine_type_id: g2-m1.4core

Bitrise Steps highlighted

Conclusion 

This approach to running iOS tests in parallel on Bitrise represents a significant leap for teams seeking efficiency in their development cycles. By focusing test execution on changed modules, you achieve faster feedback loops without sacrificing the integrity of your application.

As we look ahead, we will explore more ways to refine and enhance your CI/CD workflows with Bitrise. If you're ready to implement parallel testing in your projects, dive into the detailed Bitrise Pipelines documentation for comprehensive guides and tutorials. Not a Bitrise user? Start for free today.

Get Started for free

Start building now, choose a plan later.

Sign Up

Get started for free

Start building now, choose a plan later.