この記事では、iOSの自動テスト(iOSシミュレーターを使ったUIテスト)を分割・並列実行することによって、実行時間を短縮する方法について紹介します

はじめに

iOS アプリケーション開発において、品質を継続的に向上させるために、自動テスト・CircleCI などのCI/CDツールの導入は、もはや欠かせないものとなりました。

そして、自動テスト・CI/CDを1回導入するだけでなく、継続的に改善していくことが重要です。

例えば、アプリケーション規模や自動テストの数、開発規模が大きくなっていくと、CI/CD におけるビルド・テストの実行時間は長くなってしまい、結果として開発スピードを低下させてしまいます。

特に 今回紹介する XCUITest(Xcode UITest) などのUIテストでは、実際に iOS 実機や iOS シミュレーターを動かしてテストを実行する必要があるため、実行時間が長くなりがちです。

CircleCI では iOS アプリケーション開発で、実行時間を短縮するためのさまざまな機能が揃っています。

今回は CircleCI のテスト分割・並列実行を fastlane と組み合わせて、実行時間を短縮する方法について紹介します。

CircleCI と fastlane を使って UIテストを分割・並列実行する方法(概要)

こちらが、今回紹介する iOS アプリケーションのサンプルコードです。

GitHub - tadashi0713/circleci-demo-ios

複数の UIテスト(XCUITest)が用意されており、実行しているテスト内容は一緒ですが、sleep() を入れることによって実行時間を変えています。

import XCTest

class CircleCIDemoUITests5: XCTestCase {
    func testTapButton5() throws {
        // UI tests must launch the application that they test.
        let app = XCUIApplication()
        app.launch()

        // Elements
        let text = app.staticTexts["text"]
        let button = app.buttons["button"]

        XCTAssertEqual(text.label, "Hello, world!")
        sleep(50)
        button.tap()
        XCTAssertEqual(text.label, "Button Tapped!")
    }
}

fastlane を使って、直列で全てのテストを実行させるためには run_tests Action を使い下記のように記述します。

desc "Run all UITests"
lane :ui_test_all do
  run_tests(
    scheme: "CircleCIDemoUITests",
    devices: ["iPhone 13 (15.4)"]
  )
end

この複数ある UIテスト を分割・並列実行する手順としては、以下になります。

  1. UIテストを実行するための事前ビルド(build_for_testing)を行う
  2. 複数のmacOS VM・iOS シミュレーターを起動する
  3. (実行時間に応じて) テストを分割する
  4. 分割されたテストを並列実行する
  5. 実行時間が含まれるテスト結果をアップロードする

UIテストを実行するための事前ビルド(build_for_testing)を行う

XCUITest を含め、iOS アプリケーションでテストを実行する際には、実行前にアプリケーションのビルドが必要になります。

今回は、後ほど複数の macOS VM で並列でテストを実行させるために、ビルドの部分のみを事前に行います。

Xcode8 から、build-for-testingtest-without-building によって、ビルドとテスト実行を分離することができます。

fastlane で build-for-testing を実装する場合には、run_testsbuild_for_testing パラメーターを有効にします。

desc "Run all UITests"
lane :build_for_ui_test do
  run_tests(
    scheme: "CircleCIDemoUITests",
    devices: ["iPhone 13 (15.4)"],
    derived_data_path: "dist",
    build_for_testing: true
  )
end

derived_data_path パラメーターによって、成果物のパスを指定することが可能です。

これによって、テスト自体は実行せずに、テストを実行させるための事前ビルドができるようになりました。

CircleCI のジョブ(build_for_ui_test)は以下になります。

build_for_ui_test:
  macos:
    xcode: 13.3.1
  resource_class: macos.x86.medium.gen2
  steps:
    - checkout
    - ruby/install-deps
    - run: bundle exec fastlane build_for_ui_test
    - persist_to_workspace:
        root: .
        paths:
          - dist

以下の手順を行っています。

  • fastlane のインストール(Ruby Orb を利用)
  • fastlane の実行(build_for_testing)
  • 事前ビルドの成果物を次のジョブで利用できるようにする(persist_to_workspace)

UIテストを分割・並列実行する

事前ビルドのジョブ(build_for_ui_test)が完了したら、実際にテストを分割・並列実行するジョブ(ui_test_parallel)を実行します。

ui_test_parallel:
  parallelism: 2
  macos:
    xcode: 13.3.1
  resource_class: macos.x86.medium.gen2
  steps:
    - checkout
    - macos/preboot-simulator:
        device: iPhone 13
        version: "15.4"
    - attach_workspace:
        at: .
    - ruby/install-deps

まず、並列でテストを実行するために、複数の macOS VM を立ち上げます。

parallelism を指定することで、並列で実行する macOS VM の数を増減することが可能です。

次に以下の手順を実行しています。

これらの手順が終わった後に、実際にテスト分割・並列実行を行います。

実行する fastlane がこちらです。

desc "Run specific UITests"
lane :ui_test_without_building do |options|
  run_tests(
    scheme: "CircleCIDemoUITests",
    devices: ["iPhone 13 (15.4)"],
    only_testing: options[:tests],
    derived_data_path: "dist",
    test_without_building: true
  )
end

こちらも run_tests を利用していますが、異なる点が2つあります。

1 つ目に test_without_building パラメーターを有効にしています。

今回は事前ビルド(build-for-testing)を行っているため、こちらのパラメーターを有効にすることでビルドをせずにテストを実行することができます(test-without-building)。

2つ目に only_testing パラメーターを指定しています。

このパラメーターを指定することによって、全てのテストではなく特定のテストのみ実行されるようにします。

only_testing の対象(特定のテスト)は外から渡せるようにします。


CircleCI の設定ファイルでテストを分割・並列実行している部分はこちらです。

- run:
    name: Split tests and run UITests
    command: |
      CLASSNAMES=$(circleci tests glob "CircleCIDemoUITests/*.swift" \
        | sed 's@/@.@g' \
        | sed 's/.swift//' \
        | circleci tests split --split-by=timings --timings-type=classname)
      FASTLANE_ARGS=$(echo $CLASSNAMES | sed -e 's/\./\//g' -e 's/ /,/g')
      bundle exec fastlane ui_test_without_building tests:$FASTLANE_ARGS
- store_test_results:
    path: fastlane/test_output/report.junit

CircleCI CLI である、circleci tests glob によって対象となるテストファイルを取得し、circleci tests split によって分割しています。

circleci tests split には --split-by=timings フラグを付けています。

後の store_test_results で JUnit 形式のテストレポートをアップロードしているのが確認できると思います。

--split-by=timings フラグを有効にすることによって、テストレポートに含まれるタイミングデータを利用して均等にテストを分割しようとします。

これによって、より全体のテスト実行時間を短縮させることが可能です。

タイミングデータを使用したテスト分割

タイミングデータを使用したテスト分割


実際の並列化されたテストの実行時間については、CircleCI の UI (TIMING タブ)から見ていただくことが可能です。

iOSの自動テストを分割・並列実行

iOSの自動テストを分割・並列実行


実行時間の異なる5つのテストを2並列で実行していますが、テスト実行時間にばらつきが少ないことが確認できると思います。

おわりに

この記事では、iOSの自動テスト(iOSシミュレーターを使ったUIテスト)を分割・並列実行することによって、実行時間を短縮する方法について紹介しました

今回紹介したソリューションには以下の特徴があります。

  • 各テストの実行時間に応じてテストを分割・並列実行することができるため、より短時間でテスト実行を終了させることができる
  • parallelism を増やすだけで、CircleCI のクラウド上で簡単に並列数をスケールさせることができる


CircleCI では、並列数による課金ではなく、1分毎の macOS VM の使用量(クレジット)によって課金されます。

今回のような複数の macOS VM を並列で実行した場合でも、コストパフォーマンスが高い形でご利用していただくことが可能です。

料金プラン情報 - CircleCI

今後より iOS アプリケーション開発のパフォーマンスを上げたい方は、是非参考にしていただければと思います。