😇

Swift 6.1(Xcode 16.3)からSwift TestingでもsetUp/tearDown的なことができる

に公開

はじめに

Swift 6.1(Xcode 16.3)からSwift TestingでもsetUp/tearDown的なことができるようになりました。

なぜ待望されていたのか

これまでのSwift Testingでは、setUp/tearDown的なことが正式にはサポートされていませんでした(やろうと思えばclassのinitとdeinitでなんとかそれっぽいことをやれなくもなかった)。

Xcode 16.3のSwift Testingでは、TestTraitおよびTestScopingプロトコルを使って、テスト関数の前後に任意の処理を実行するカスタムTraitを定義できます。これは従来のsetUp/tearDownに相当するだけでなく、テストスコープのラップ全体に任意の非同期処理を挿入できる仕組みです。たとえばログの記録、トランザクションの開始・終了など柔軟な制御が可能な感じです。

https://x.com/omochimetaru/status/1897168857527439552

setUp/tearDownを実装するサンプルコードを作ってみたんでそれを見てもらった方が早いです。

// Xcode 16.3
import Testing

// テスト関数に対して、mySetupTeardown Trait を適用
@Test(.mySetupTeardown)
func myTest() async throws {
    // テスト本体のコード
    print("☺️ myTest")
    #expect(true)
}

// Trait
extension Trait where Self == MyTestTrait {
    static var mySetupTeardown: Self {
        Self()
    }
}

// setup/teardown を含むカスタム Trait の実装
struct MyTestTrait: TestTrait, TestScoping {
    // テスト実行前のセットアップ処理
    private func setUp() async throws {
        print("😇 setUp")
    }

    private func tearDown() async throws {
        print("😇 tearDown")
    }

    // provideScope 内で setup/teardown を呼び出し、テスト関数の実行をラップする
    func provideScope(
        for test: Test,
        testCase: Test.Case?,
        performing function: @Sendable () async throws -> ()
    ) async throws {
        // テスト実行前にセットアップ
        try await setUp()
        do {
            // テスト関数の実行
            try await function()
        } catch {
            // テスト中にエラーが発生した場合でもティアダウンを実行
            try await tearDown()
            throw error
        }
        // テスト正常終了後にティアダウンを実行
        try await tearDown()
    }
}

実行結果は次のとおり

◇ Test myTest() started.
😇 setUp
☺️ myTest
😇 tearDown

重要なのは

  • カスタム Trait を実装
  • provideScopeのDelegateメソッドでsetUp/tearDownとfunctionの呼び出しを実装
  • 対象となるテストにカスタムTraitを指定

Xcode 16.3 Release Notesの例をみる

リリースノートでも触れられてサンプルがあります。setUpだけをやってますね。

https://developer.apple.com/documentation/xcode-release-notes/xcode-16_3-release-notes#Testing-and-Automation

コピペしても読みづらいんで、コメント入れてみます

 // カスタム Trait を定義してる
struct MockAPICredentialsTrait: TestTrait, TestScoping {
    // delegateのメソッド
    func provideScope(
        for test: Test,
        testCase: Test.Case?,
        performing function: @Sendable () async throws -> Void
    ) async throws {
        // テスト対象を実行する前に
        let mockCredentials = APICredentials(apiKey: "...")

        // TaskLocal使ってる?
        try await APICredentials.$current.withValue(mockCredentials) {
            // このクロージャ内でAPICredentials.currentを呼び出すと、
            // mockCredentialsが使われるっていうことがしたい。
            try await function()
        }
    }
}

extension Trait where Self == MockAPICredentialsTrait {
    static var mockAPICredentials: Self {
        Self()
    }
}

@Test(.mockAPICredentials)
func example() {
    // ...validate API usage, referencing `APICredentials.current`...
}

サンプルではsetUp/tearDownではなく、TaskLocalのAPICredentials.$currentを書き換え、それをテスト対象内のみで利用するようにクロージャ抜けたら使わないという感じなんでしょうかね。実質setUp/tearDown。

TaskLocalなら次のような感じ?

enum APICredentials {
    @TaskLocal static var current: APICredentials?
}

非同期処理やactorとの相性も良い...のか?

provideScopeのなかでTaskも使えるからコンテキストを共有するコードを書きやすい気もします。

テスト関数自体を見張れる

かなりふわっとした思いつきですが、provideScopeのなかでタイマーとか使ったりもできるし、書き換えられてはいけない環境の変化を見張ることもできるかもしれない。

まとめ

Swift TestingによるTestScopingの導入は、テストの可読性と安定性の両面で大きな前進です。setUp/tearDownの仕組みが欲しいんや、と思ってたらTestScopingという仕組みで大枠からやれることを増やしてくれるのは興味深いですね。

https://github.com/swiftlang/swift-evolution/blob/main/proposals/testing/0007-test-scoping-traits.md

Discussion