【swift-testing】Appleの新テストライブラリで、XCTestからどう変わるのか

# はじめに

この記事はand factory.inc Advent Calendar 2023 25日目の記事です。
昨日は @yst_i さんの「[特定のタイミングでバックボタンを無効化する](https://qiita.com/yst_i/items/6c20826175d352ca8962)」でした。

iOSアプリ開発におけるテストライブラリはXCTestが主要でしたが、swift 5.9~利用可能になった公式の新ライブラリ [swift-testing](https://github.com/apple/swift-testing) が登場しました。

今回は、XCTestとの違いを簡単な例で比較しながら基礎を把握していきます。

# swift-testingを用いたテスト記法超まとめ

- [＠Test](https://swiftpackageindex.com/apple/swift-testing/main/documentation/testing/test(_:_:)) (Attached Macro)
    - テスト関数の宣言に用いる
    - XCTestでは関数名を”test”で始める必要がある点で異なる
- [@Suite](https://swiftpackageindex.com/apple/swift-testing/main/documentation/testing/suite(_:_:)) (Attached Macro)
    - テスト群のグルーピングに用いる
    - XCTestではXCTestCaseに準拠させたクラスごとにテスト群をグルーピングしている点で異なる
- 各種[Traits](https://swiftpackageindex.com/apple/swift-testing/main/documentation/testing/traits)
    - テストのカスタマイズを容易に行うための機構
    - 表現力向上や制約の追加に活用
- [#expect](https://swiftpackageindex.com/apple/swift-testing/main/documentation/testing/expect(_:_:sourcelocation:)) & [#require](https://swiftpackageindex.com/apple/swift-testing/main/documentation/testing/require(_:_:sourcelocation:)-6w9oo) (Expression Macro)
    - ＠Testを用いて宣言した関数内部で利用
    - XCTAssertEqualなどの検証関数の代替

# セットアップ方法

- Package.swiftに依存追加

```swift
dependencies: [
  .package(url: "https://github.com/apple/swift-testing.git", branch: "main"),
],
```

**※ (2024/06/12追記)Xcode16以降、以下ワークアラウンドは不要。(Xcode16betaにて確認)。**

- XCTestと違ってSPMやXCodeとの統合が未完了なため、以下ワークアラウンドが求められる
    - 以下内容のScaffolding.swiftをPackageのTest Target内に追加する 

```swift
import XCTest
import Testing

final class AllTests: XCTestCase {
  func testAll() async {
    await XCTestScaffold.runAllTests(hostedBy: self)
  }
}
```

# XCTestとの比較

## 例① 単純なユニットテスト

### XCTest

```swift
import XCTest

/// - Point: XCTestCaseに準拠することでテストグループを定義
final class SimpleTest: XCTestCase {

	/// - Point: 前処理はXCTestCaseによって定義された関数内で実行
    override func setUpWithError() throws {
        setup()
    }
    
	/// - Point: 関数名を"test"で開始することで、テスト関数を宣言
    func test_aIsB() {
        let a = 1
        let b = 1
        /// - Point: 検証処理
        XCTAssertTrue(a == b)
    }
    
    func test_aIsNotB() {
        let a = 1
        let b = 2
        /// - Point: 検証処理
        XCTAssertFalse(a == b)
    }
}
```

### swift-testing

```swift
import Testing

/// - Point: @Suiteを付与することでテストグループを定義
@Suite(
	/// - Point: Traitsを追加することでテストをカスタマイズ可能
	"EntityTestActorのテスト", 
  .timeLimit(.seconds(10)) /// - Point: timeLimit Traitsによるテスト時間の制限
)
actor EntityTestActor {

    /// - Point: 前処理はイニシャライザ内で実行
    init() {
        setup()
    }
    
	/// - Point: @Testを付与することで、テスト関数を宣言
    @Test
    func aIsB() {
        let a = 1
        let b = 1
        /// - Point: 検証処理
        #expect(a == b)
    }

    /// - Point: @Testを付与することで、テスト関数を宣言
    @Test(
        /// - Point: Traitsを追加することでテストをカスタマイズ可能
        .comment("テストが失敗しました"), /// - Point: comment Traitsによる失敗時の表示内容の指定
    ) func aIsNotB() {
        let a = 1
        let b = 2
        /// - Point: 検証処理
        #expect(a != b)
    }
}
```

- 比較すべき点をコード内にそれぞれ `/// - Point:` としてコメントしています
- Traitsは上記例以外にも複数あり、テストの表現力向上に寄与するので要チェックです

## 例② パラメタライズドテスト

### XCTest

```swift
import XCTest

final class FruitTests: XCTestCase {

    // フルーツを表す簡単な構造体
    struct Fruit {
        var name: String
        var category: String
    }

    // フルーツの名前からカテゴリーを判断するダミーの関数
    func getCategory(for fruitName: String) -> String {
        switch fruitName {
        case "りんご", "バナナ":
            return "甘い"
        case "レモン", "キウイ":
            return "酸っぱい"
        default:
            return "不明"
        }
    }

    // フルーツが期待されるカテゴリーに属しているかテストする
    func testFruitCategory() {
        // テストケースを定義（フルーツの名前と期待されるカテゴリー）
        let testCases = [
            (name: "りんご", category: "甘い"),
            (name: "レモン", category: "酸っぱい"),
            (name: "バナナ", category: "甘い"),
            (name: "キウイ", category: "酸っぱい")
        ]

        // 各テストケースを実行
        testCases.forEach { testCase in
            let fruit = Fruit(name: testCase.name, category: testCase.category)
            XCTAssertEqual(fruit.category, getCategory(for: fruit.name))
        }
    }
}
```

### swift-testing

```swift
actor FruitTests {

    struct Fruit {
        var name: String
        var category: String
    }

    func getCategory(for fruitName: String) -> String {
        switch fruitName {
        case "りんご", "バナナ":
            return "甘い"
        case "レモン", "キウイ":
            return "酸っぱい"
        default:
            return "不明"
        }
    }
    
    @Test(
        /// - Point: 引数に渡したい値群をargumentsに渡すことで必要なパターンごとのテストを実行してくれる
        arguments:
            zip([
                "りんご",
                "レモン",
                "バナナ",
                "キウイ"
            ],
            [
                "甘い",
                "酸っぱい",
                "甘い",
                "酸っぱい"
            ])
    ) func test(
        name: String,
        category: String
    ) {
        let fruit = Fruit(name: name, category: category)
        #expect(fruit.category == getCategory(for: fruit.name))
    }
}
```

フレームワークにパラメタライズドテストの機構が備わったのはありがたいですが、テストケースごとに列挙する形ではないので少し見通しが悪いように感じます。
以下、実際の運用を想定して少しリライトしてみました。

```swift
actor FruitTests {

    struct Fruit {
        var name: String
        var category: String
    }

    func getCategory(for fruitName: String) -> String {
        switch fruitName {
        case "りんご", "バナナ":
            return "甘い"
        case "レモン", "キウイ":
            return "酸っぱい"
        default:
            return "不明"
        }
    }
  
    typealias TestCase = (name: String, category: String, sourceLocation: SourceLocation)
    
    static let testCases: [TestCase] = [
        (name: "りんご", category: "甘い", sourceLocation: SourceLocation()),
        (name: "レモン", category: "酸っぱい", sourceLocation: SourceLocation()),
        (name: "バナナ", category: "甘い", sourceLocation: SourceLocation()),
        (name: "キウイ", category: "酸っぱい", sourceLocation: SourceLocation())
    ]

    @Test(arguments: Self.testCases)
    func test(
        testCase: TestCase
    ) {
        let fruit = Fruit(name: testCase.name, category: testCase.category)
        #expect(fruit.category == getCategory(for: fruit.name), sourceLocation: testCase.sourceLocation)
    }
}
```

テストケースを配列に納める元の形を維持することで見通しを確保しています。
更に、#expectマクロにSourceLocationを渡すことで、
テスト失敗時にどのケースが失敗したか分かりやすくしています

# 所感

- 良いなと思った点
    - macroの力でAPI数が減った結果、迷わずシンプルに書ける
    - パラメタライズドテストの運用手段が公式から提供された
        - とはいえ可読性に個人的に疑問があるので、TestCaseとして配列を定義する書き方は変えたくない
    - Traitsの仕組みにより、シンプルな記法で表現力を向上させている
    - XCTestからの移行も簡単
- ~~本格投入できるようになるまではまだ時間がかかりそう~~
    - ~~XCodeとの統合がされていない~~  (2024/06/12追記)Xcode16以降統合されました(Xcode16betaにて確認)
        - ~~XCode上でもテストの実行は問題なくできたが、左ペインにテスト関数一覧が表示されないのは運用上大きな問題~~
            - ~~`Scaffolding.swift`で定義した `testAll` の結果のみが左ペインに表示され、swift-testingで実装した個別のテスト結果が閲覧できない~~
![Screenshot 2023-12-22 at 16.04.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/605495/4ae69387-715f-80bf-15e5-bae1487a468e.png)

# おわりに
実際にswift-testingで簡単なユニットテストを書いてみて、非常にシンプルで書きやすいと感じました。
~~XCodeに問題なく統合されたら積極的に利用していきたいと思います。~~

(2024/06/12追記)
[forum](https://forums.swift.org/t/progress-toward-the-swift-6-language-mode/68315)でもswift-testingへ移行する方向性でAcceptされたようなので、積極的に採用していきたいと思います。

# 参照
- [swift-testing documentation](https://swiftpackageindex.com/apple/swift-testing/main/documentation/testing)