5.1さらうどん

@giginetの技術ブログ。ゲーム開発、iOS開発、その他いろいろ

Swift TestingのTest Scoping Traitsで前後処理を実装する

Test Scoping Traits

Swift 6.1 (Xcode 16.3)からTest Scoping Traitsという機能が追加された。使うときに参考にできる資料がまだ少なかったので書き留めておく。

従来のSwift Testingの利用におけるペインポイントとして、XCTestでは記述しやすかった前後処理(setUptearDown)を上手く扱えないという点があった。これまではSuiteのコンストラクタ、デストラクタに頼るほかなく、拡張性や再利用性が低く、非同期処理や例外を投げる後処理をデストラクタで扱うのが厳しかった。

Test Scoping Traitsを使うとこれらの問題が解決しやすくなる。ざっくり言うと、テスト実行を特定の処理でラップする機能だ。

setUp()
runTestCase()
tearDown()

Pythonのデコレータのようなものをイメージすると良いだろう。

ユースケース - テンポラリディレクトリを安全に作成するTrait

今回、ファイルシステムを扱うテストを記述したい場面があり、Test Scoping Traitsを実装してみた。

例えば、複雑な処理のE2Eテストで、実行後に特定のファイルが生成されたことを確認したい場合を考える。このとき、テストケースの前後で生成用のテンポラリディレクトリの作成やクリーンアップを行う必要がある。前回の生成結果が残っているとflakyなテストになってしまうし、実装ミスによって消してはいけないファイルが消えてしまうのも避けたい。

このようなケースでは、実行前にテンポラリディレクトリを作成し、テスト実行後に自動削除を行うTraitを用意しておくとテストを簡潔に記述できる。

@Test(.temporaryDirectory)
func buildFrameworks() async throws {
    let workspaceURL = try #require(TemporaryDirectoryTrait.Context.workspaceURL)
    let compiler = Compiler(workspaceURL: workspaceURL)

    await compiler.runSuperComplexProcess()

    #expect(fileManager.fileExists(atPath: workspaceURL.appending(component: "Foo.xcframework").path))
}

このScoping Traitを使うと、テストケースにtemporaryDirectoryを宣言するだけで前後処理を簡単にやってくれる。詳しく実装を見てみよう。

Test Scoping Traitを実装する

TestTraitTestScopingに適合したTraitを実装する。

struct TemporaryDirectoryTrait: TestTrait, TestScoping {
    enum Context {
        @TaskLocal static var workspaceURL: URL?
    }
    private let prefix: String

    init(prefix: String) {
        self.prefix = prefix
    }

    func provideScope(for test: Test, testCase: Test.Case?, performing function: @Sendable () async throws -> Void) async throws {
        let fileManager: FileManager = .default
        let temporaryDirectoryURL = fileManager.temporaryDirectory
            .appending(components: prefix, "\(test.name)")

        try fileManager.createDirectory(at: temporaryDirectoryURL, withIntermediateDirectories: true)
        defer { try? fileManager.removeItem(at: temporaryDirectoryURL) }
    
        try await Context.$workspaceURL.withValue(temporaryDirectoryURL) {
            try await function()
        }
    }
}

ポイントはTestScopingprotocolのprovideScopefunctionがテストの実行に相当するので、その前後に処理を記述することができる。今回は単純にテンポラリディレクトリの作成、削除をしている。

追記: defer で削除を呼ばないと、テスト失敗時にfunctionthrowしてディレクトリが削除されなかった。

その他、引数のtesttestCaseから実行中のテストケースのメタデータを取得できる。test.namebuildFrameworks()のようなテストメソッド名を取得できるので、テストケースごとにディレクトリを作成し、非同期実行時でも衝突しないようにしている*1

これで、テスト実行前後に/tmp/XXX/me.giginet.MyPackage/buildFrameworks()のようなディレクトリが作成・削除される。

最後に、@Test(.temporaryDirectory)のように呼び出せるようにTraitに対してextensionを追加する。

extension Trait where Self == TemporaryDirectoryTrait {
    static var temporaryDirectory: Self {
        TemporaryDirectoryTrait(prefix: "me.giginet.MyPackage")
    }
}

テストケース側に状態を受け渡す

Test Traitsからテストケース側に何らかの値を注入したいときは、TaskLocalを使用できる。

今回の例では、生成されたテンポラリディレクトリのパスをTaskLocalに保持し、テストケースから参照している。withValueに渡されたクロージャ内でのみTaskLocalが有効になるので、スレッドセーフで安全に状態を共有できる。

try await Context.$workspaceURL.withValue(temporaryDirectoryURL) {
    try await function()
}

取得する側は:

let workspaceURL = try #require(TemporaryDirectoryTrait.Context.workspaceURL)

その他のユースケース

ざっと思いつく限りでも以下のようなユースケースが挙げられる。

  • テンポラリファイルの作成、削除
  • APIスタブの登録、削除
  • データベースの初期化・クリーン
  • CoreData Contextの作成

このように、主にテストデータの準備やmock、コンテキストの生成・削除に使うのが良いかもしれない。プロポーザルやSwiftのリリースブログでは、APIキーをmockする例が紹介されている

今後、より複雑なDIコンテナのような仕組みが出てくると面白いかもしれない。

また、Swift 6.1からPackage Traitsという別の仕組みも入ったため、テストライブラリをパッケージに気軽に組み込みやすくなった。何かネタを思いついた人はOSSチャンスかも。

*1:parameterized testを扱うときはさらに工夫が必要そう