🏋️‍♂️

Swift Testingで`@Test`のところにテスト準備の非同期副作用実行を書きたい

に公開

筋トレした後のサウナしながらの時間で考えたので、ツッコミどころはあるかもしれません。もし他に良い案があったらコメントに記載をお願いします。

はじめに

テストコードでテストケース前に副作用を実行する際に、そのテストケース直前に書いてテストケース自体には書かないということをやりたいことがあると思います。

SwiftTestingではArrangeをテストの直前にかけるようになりましたが、非同期処理や副作用を実行できないのが惜しいなと思ってました。もちろんヘルパー関数などにしても良いですが、AAA(Arrange, Action, Assertion)をもっと目立つようにしたいのをSwift Testingで実現できるんじゃないかとずっと考えていました。

具体的には次のようにしたいって感じです。

// MARK: - テストケースとその準備

@Test(
  // MARK: Arrange もともとココへArrangeは書けるが非同期処理や副作用実行もしたい
)
func testCase1() async throws {
  // テストケース自体
  // MARK: Action

  // MARK: Assertion
}

雑にいうと、これができるんすねという話です。もともと@Testのあとにパラメタライズド・テスト (parameterized test)ができる仕組みがあったのですが、そういう感じでどうにかできないかと思ってました。

前提

Swift TestingのTestTraitおよびTestScopingプロトコルやTaskLocalについては以前書いてるんで知らなければまずこちらをみてみてください

https://zenn.dev/yimajo/articles/9614539cada888

モチベーション

「やりたことはTestTraitおよびTestScopingのsetUpでできるじゃん」と思われるでしょうし、そりゃそうなんですが、テストケースごとに違うArrangeをし副作用実行をしたい。たとえばデータベースのReadテストをしたいとき、Writeしたい。そのWriteを近くに書きたいなと思ってたわけです。

もちろん「テストケースからヘルパー関数呼んだらいいだけじゃん」もありますが最後の手段。なぜなら、ヘルパー関数にしてしまうとテストのWriteのテストケースから離れてしまってそれがどういうスレッドで動いているとか、そもそも関数にすると他のテストケースでも使われる。なので、できればそのテストのArrangeはすぐ近くにかきたい。というモチベーションです。

実現する具体例

どういうことをやりたいかを具体例で示します。

対象としてRealmを使ったテストを例にしてみます。理由としてはRealmはアプリに採用しやすかった過去があり、しかしスレッドをまたがる処理に対して経験がないと何が起こってるかわからないからです。特にテストコードはUIでアプリを動かす際のタイミングの余白がなく即時に実行されることで正確な仕組みの知識が要求されますし、Swift Concurrency以前からあり、Swift Concurrency対応されていないRLMRealmを使っている場合はかなり厄介なはずです。

さらに、参照カウントの仕組み上、ヘルパー関数で作成したRealmのデータもそのインスタンスの寿命に縛られることもあるでしょう。

テストケース

Realmを使った読み込みのテストを想定してみます。次のように@TestにArrangeのクロージャを配列で渡せるようにできる、ということが分かればいいと思います。

// MARK: テストケース
@Test(MyTestTrait(
    actions: [ // ここでテストの前提の副作用を記述できる
        { realm in
            print("Arrange Step 1:", realm, Thread.current)
        },
        { realm in
            // 必要もないと思うけど配列にもできるという例
            print("Arrange Step 2:", realm, Thread.current)
        }
    ]
))
func testCase1() async throws {
    // TaskLocalでrealmを取得しそれ以外は使わない
    guard let realm = RealmContext.current?.realm else {
        XCTFail("Realm not injected")
        return
    }
    print("🧪 Using realm Action:", realm, Thread.current)
    // MARK: Action
    // ここで読み取りなどをする
    // MARK: Assertion
    // ここで読み取った値の検証
}

TaskLocalをどうするか

TaskLocalのためにRealmContextというのを自前で用意する必要があります。

TaskLocalはRealmを持つようにするいわゆるstaticなやつでいいと思います。

struct RealmContext {
    @TaskLocal static var current: RealmContext?
    let realm: RLMRealm
}

TestTrait,TestScopingをどうするか

示した具体例ではMyTestTrait.arrangeRealmData()というArrangeする仕組みを使っていました。その実装を作っておく必要があります。

念のため書いておくだけですが、MyTestTraitというのは名前はなんでもいいです。サンプルっぽいのでそういう名前にしただけです。

struct MyTestTrait {
    // このactionsはテストケース側で入力する
    let actions: [@Sendable (RLMRealm) async throws -> ()]
}

// TestTrait, TestScopingに準拠してMyTestTraitをテスト用の準備できるようにします。

extension MyTestTrait: TestTrait, TestScoping {
    // provideScope 内で setup/teardown を呼び出し、テスト関数の実行をラップする
    func provideScope(
        for test: Test,
        testCase: Test.Case?,
        performing function: @Sendable () async throws -> ()
    ) async throws {
        // テスト実行前にセットアップ
        let realm = {
            // inMemoryを用意
            let config = RLMRealmConfiguration.default()
            config.inMemoryIdentifier = UUID().uuidString
            RLMRealmConfiguration.setDefault(config)
            return RLMRealm.default()
            // NOTE: ここではグローバルな.defautlを書き換えています。
            // つまり、ここで設定した内容が他のテストにも引き継がれてしまいます
            // もっと良い方法があると思います(調べてないです)。
        }()

        // Arrangeステップを順に実行
        for action in actions {
            try await action(realm)
        }
        // NOTE: actionsをそのまま使ってしまってますが、
        // それが安全かはまだ調べきれてないです。

        try await RealmContext.$current.withValue(.init(realm: realm)) {
            // 🟢 このスコープ内でRealmContext.currentが使える
            // 本当は引数にRealmを渡したいがそれもできなさそうなんで。
            try await function()
        }
    }
}

説明を省くためにハードコードしまくってますが、だいたいこんな感じです。

これでテストケースとArrangeの副作用実行フェーズで同様のRealmインスタンスを利用しやすくなったんじゃないかと思いますけどどうでしょう。

伸び代としては、globalActor指定してもいい箇所もあるのでは、とも思いますが、それはやりたい人がその要件に従ってやるのが一番で、他にももっと良い方法があれば教えてください。

蛇足

  • このコードはSwift Language Mode v6では試していません
    • RLMRealmはSendableでないのでもっと保護する必要が出るとは思います
  • そもそもTaskLocalを前提としたアプリ開発にしとけばもっと考えることは少ないんだけど、世の中の作られているアプリはそうではないのでこういう例にしてみてます
  • そもそもDIのライブラリを使ってたりするとまた違いますが、それはそれです
  • そもそも副作用のテストをする意味があるのかというとそれは本当にそう

Discussion