📚

Swift Testingの並列テスト実行にまつわる落とし穴

に公開

こんにちは。株式会社PREVENTでiOSエンジニアをしている佐藤です。
弊社で開発しているiOS版のMystarアプリは、最近テストフレームワークをXCTestからSwift Testingへ移行しました。
その対応の中で、並列テストにまつわる落とし穴にハマってしまい、やや大変だったのでその時の知見を記事として共有しようと思います。

並列テストそのものの注意点

Swift Testingの大きな特徴の一つとして、デフォルトでテストケースの並列実行があります。
テストケースを並列実行できると、テストの実行時間が高速化できるので、基本的には積極的に導入したいです。
しかし、テストの実装内容によっては、並列実行されてしまうと意図しない実行結果になり実行毎にテストが成功したり、失敗したりする(いわゆるFlakyテスト)問題が顕在化することがあります。
例えば、以下のようなプロダクションコードとテストコードだと、テスト実行毎に成功したり失敗したりします。

プロダクションコード
struct SampleManager {
    static var status: String = "none"
    func getStatus() -> String {
        return Self.status
    }
}
テストコード
import Testing

struct SampleManagerTests {
    
    @Test func getFailStatus() {
        SampleManager.status = "fail"
        
        let sampleManager = SampleManager()
        #expect(sampleManager.getStatus() == "fail")
    }
    
    @Test func getSuccessStatus() {
        SampleManager.status = "success"
        
        let sampleManager = SampleManager()
        #expect(sampleManager.getStatus() == "success")
    }
}

この例では、SampleManager.statusがテストケースで共有されてしまっていることが原因です。getFailStatusgetSuccessStatusが並列に同時に実行されてしまうと、それぞれのケースでSampleManager.statusの値が変更されてしまい、どちらかのケースにとって意図しないSampleManager.statusの値になってしまうことがあります。
この例のように、並列テストにすることでテスト結果が不安定になる原因は、だいたい共有依存が存在することが原因だと思います。

Swift Testingならではの並列テストの注意点

XCTestでも並列テストをサポートしているので、
すでに並列テストを導入している状態からSwift Testingへ移行したときは、並列テスト絡みでテスト結果が不安定になる懸念はないと思いたいですが、実はそんなことはありません。

その理由は、XCTestとSwift Testingで並列する方法が異なるためです。

XCTestSwift Testingでの並列テストの違い

XCTestでは複数のプロセスを用意して、各プロセスでテストを分担して実行することで、テストの並列実行を行っています。
プロセスというとちょっとわかりづらいですが、プロセス=シミュレーターと捉えるとわかりやすいかもしれません。
XCTestで並列でテストを実行するとシミュレーターがたくさん立ち上がるのは、実行するプロセスを複数個作るためですね。

Swift TestingではXCTestと違いプロセス自体は一つで、テストの実行を並列に実行します。
非同期処理を並列で実行するようなイメージです。

上記のような違いにより、並列テストの時にXCTestでは起きないけどSwift Testingでは発生するというケースがあります。

具体的な例

以下コードのテストコードを実装して、具体的にXCTestではFlakyテストの問題は発生しないが、Swift Testingでは発生する具体例を示します。

struct SampleManager {
    static var status: String = "none"
    static var count: Int = 0
    func getStatus() -> String {
        Self.count += 1
        return Self.status
    }
}

XCTestでFlakyテストの問題が発生しない例

XCTestのテストケース
final class SampleManagerTests: XCTestCase {
    override func setUp() async throws {
        SampleManager.status = ""
        SampleManager.count = 0
        print("call setup(status: \(SampleManager.status), count: \(SampleManager.count))")
    }
    func test_getFailStatus() {
        print("call test_getFailStatus")
        SampleManager.status = "fail"
        let sampleManager = SampleManager()
        XCTAssertEqual(sampleManager.getStatus(), "fail")
        XCTAssertEqual(SampleManager.count, 1)
    }
    func test_getSuccessStatus() {
        print("call test_getSuccessStatus")
        SampleManager.status = "success"
        let sampleManager = SampleManager()
        XCTAssertEqual(sampleManager.getStatus(), "success")
        XCTAssertEqual(SampleManager.count, 1)
    }
}

XCTestの場合は、test_getFailStatustest_getSuccessStatus一つのプロセス内で並列に実行されることがないため、setUpで各テストケースが呼ばれる前に、状態を初期化することで、
毎回同じ状態で検証する状況を作れます。

実行時のログを見ても分かるとおり、
test_getFailStatusが終了した後に、test_getSuccessStatusが実行されています。

XCTest実行時のログ
Test Suite 'SampleManagerTests' started at 2025-08-18 14:56:23.728.
Test Case '-[MystarTests.SampleManagerTests test_getFailStatus]' started.
call setup(status: , count: 0)
call test_getFailStatus
Test Case '-[MystarTests.SampleManagerTests test_getFailStatus]' passed (0.001 seconds).
Test Case '-[MystarTests.SampleManagerTests test_getSuccessStatus]' started.
call setup(status: , count: 0)
call test_getSuccessStatus
Test Case '-[MystarTests.SampleManagerTests test_getSuccessStatus]' passed (0.000 seconds).
Test Suite 'SampleManagerTests' passed at 2025-08-18 14:56:23.729.
	 Executed 2 tests, with 0 failures (0 unexpected) in 0.001 (0.002) seconds

Swift TestingでFlakyテストの問題が発生する例

Swift Testingのテストケース
truct SampleManagerSwiftTestingTests {
    init() {
        SampleManager.status = ""
        SampleManager.count = 0
        print("call setup(status: \(SampleManager.status), count: \(SampleManager.count))")
    }
    @takata_test func getFailStatus() {
        print("call test_getFailStatus(status: \(SampleManager.status), count: \(SampleManager.count))")
        SampleManager.status = "fail"
        let sampleManager = SampleManager()
        #expect(sampleManager.getStatus() == "fail")
        #expect(SampleManager.count == 1)
        print("end test_getFailStatus(status: \(SampleManager.status), count: \(SampleManager.count))")
    }
    @takata_test func getSuccessStatus() {
        print("call test_getSuccessStatus(status: \(SampleManager.status), count: \(SampleManager.count))")
        SampleManager.status = "success"
        let sampleManager = SampleManager()
        #expect(sampleManager.getStatus() == "success")
        #expect(SampleManager.count == 1)
        print("end test_getSuccessStatus(status: \(SampleManager.status), count: \(SampleManager.count))")
    }
}

XCTestとは逆に、Swift Testingの場合はgetFailStatusgetSuccessStatusは一つのプロセスの中で並列に実行されます。

こちらもログを見てみると、getFailStatusの実行開始の後、完了を待たずにgetSuccessStatusの実行が開始されています。
そして、test_getSuccessStatusが呼ばれた時にはすでにSampleManager.countの値は初期値ではなく1になっており意図しない状態の不整合が発生していることでテストが失敗しています。

テスト実施時にログ
◇ Test run started.
↳ Testing Library Version: 102 (arm64-apple-ios13.0-simulator)
◇ Suite SampleManagerSwiftTestingTests started.
◇ Test getFailStatus() started.
◇ Test getSuccessStatus() started.
call setup(status: , count: 0)
call setup(status: , count: 0)
call test_getFailStatus(status: , count: 0)
call test_getSuccessStatus(status: fail, count: 1)
✘ Test getSuccessStatus() recorded an issue at AnnounceFormUrlTest.swift:75:9: Expectation failed: (SampleManager.count → 2) == 1
✘ Test getFailStatus() recorded an issue at AnnounceFormUrlTest.swift:65:9: Expectation failed: (SampleManager.count → 2) == 1
end test_getSuccessStatus(status: success, count: 2)
end test_getFailStatus(status: success, count: 2)
✘ Test getSuccessStatus() failed after 0.002 seconds with 1 issue.
✘ Test getFailStatus() failed after 0.002 seconds with 1 issue.
✘ Suite SampleManagerSwiftTestingTests failed after 0.002 seconds with 2 issues.
✘ Test run with 2 tests failed after 0.002 seconds with 2 issues.

実際にこのテストを動作させると、ほぼ毎回失敗することになると思いますが、テストの内容によっては稀にテストが失敗するものもあり、テストケースに問題があることがわかりずらいこともあると思います。
ただその場合でも、原理は似たものである可能性が高いので、テストケース間で共有依存がないかは注意深く探してみてください。

対処法

まずはstaticな変数など共有依存になるものを排除できるかどうかを検討すべきですが、Swift Testingへ移行したいというだけの場合に、リファクタリングが必須になるというのは避けたいと思います。
リファクタリングが必要だとしても、一旦はワークアラウンドとしてリファクタリングなしにSwift Testingへの移行を行い、その後リファクタリングをするというのが理想でしょう。

ワークアラウンドの対応として考えられるのは、まず並列テストをやめることです。
テストプランの設定から以下のようにParallelizationの設定をDisabledにすることで、並列でテストケースが実行されなくなるので、問題は改善されます。

ただ、この対処ではSwift Testingの大きなメリットであるテスト実行速度の高速化を無くすことになるので、できれば避けたいです。

もう一つこれよりも良い方法として、部分的に並列テストをブロックすることです。
以下のようにテストケースの方に@Suite(.serialized)を付与することで、型の単位でテストの並列化をブロックできます。

+@Suite(.serialized)
struct SampleManagerSwiftTestingTests {
    ...
}

Parametarizedテストの場合は、上記を付与してもそのテストケースのバリエーションは並列実行されてしまうので、そういったケースでは@Testマクロに対して.serializedを付与することで並列化をブロックできます。

@Test(.serialized)

この辺りの挙動は、以下公式ドキュメントからも確認できます。

https://developer.apple.com/documentation/Testing/Parallelization

おわり

以上が、Swift Testingの並列テスト実行にまつわる落とし穴とその対処法でした。
どなたかの参考になれば幸いです!

Discussion