🐤

TestScoping と @TaskLocal を使って Swift Testing で setUp/tearDown する方法

に公開

はじめに

はじめまして。メディロムのものづくり大好きおじさん(仮)と申します。

野菜価格の高騰を受けて、最近水耕栽培をはじめました。超楽しいです。
日照が1日の半分程度しか確保できない窓際で、LED ライトなしでも育成可能なおすすめ野菜がありましたら、ぜひコメントをください。今のところ、バジルとシソ、サニーレタスを育成しています。

本題に入ります。

最近久々にネイティブ iOS アプリを書く機会があり、大規模なリファクタリングで Xcode 16.3 を使うことができました。
その中で、テストも Swift Testing へ置き換えていくこととなりましたが、XCTest や Quick では容易に行えた setUp/tearDown の記述をどう置き換えるかで少し悩んだので、現状たどり着いた自分の中での最適解をここに残します。

ちなみに筆者の背景として、今まで携わってきた iOS 開発を含むプロジェクトは、

  • Objective-C で書き始めた MVP 構成のプロジェクト
  • 長年熟成された UIKit + VIPER ライク + Rx/Combine なプロジェクト
  • Flutter with Riverpod

であり、Swift Concurrency 自体に触れたことがない状態からのスタートでした。

今回に限らず、古代からタイムスリップしてきた筆者が便利に感じた今風の Xcode/Swift 活用法を、これから数回に渡って紹介していきたいと思います。

Xcode 16.2 (Swift 6.0) までのワークアラウンド

Xcode 16.2 まで、Swift Testing で setUp/tearDown を行うためには、 @Suiteclassactor で記述し、setUp のかわりに init(), tearDown の代わりに deinit() を使う必要がありました。
(といっても筆者自身は今回まで Quick/Nimble を使用しており、その状況に出会ったことはないのですが……)

正直これでもほとんどのユースケースには対応できている気はしますが、init()/deinit()@Test ごとに実行されることを期待するのは違和感がありますし、 deinit()async にできないため、tearDown 内で非同期処理ができない、といった問題もありそうです。

以下は、このパターンを利用したテストの例です。

@Suite
actor SignInUseCaseTests {
    private let storage: MockAuthStorage
    private let gateway: MockAPIGateway
    private let useCase: SignInUseCase
    
    init() async {
        print("setUp")
        storage = MockAuthStorage()
        await storage.set(token: nil)
        gateway = MockAPIGateway()
        useCase = SignInUseCase(apiGateway: gateway, authStorage: storage)
    }

    deinit {
        print("tearDown")
        // await storage.set(token: nil) // async deinit はできない
    }
    
    @Test
    func testSuccess() async throws {
        await gateway.setSignInResponse(.success("test-token"))
        
        await #expect(throws: Never.self) {
            try await self.useCase.execute(
                email: "test-email", password: "test-password"
            )
        }
        
        #expect(await storage.token == "test-token")
    }
    
    @Test
    func testUserNotFound() async throws {
        await gateway.setSignInResponse(.failure(APIError.http(status: 404)))
        
        await #expect(throws: SignInError.userNotFound) {
            try await self.useCase.execute(
                email: "test-email", password: "test-password"
            )
        }
        
        #expect(await storage.token == nil)
    }
}

->

◇ Test run started.
↳ Testing Library Version: 124
↳ Target Platform: arm64-apple-ios13.0-simulator
◇ Suite SignInUseCaseTests started.
◇ Test testSuccess() started.
◇ Test testUserNotFound() started.
setUp
setUp
tearDown
✔ Test testSuccess() passed after 0.100 seconds.
tearDown
✔ Test testUserNotFound() passed after 0.104 seconds.
✔ Suite SignInUseCaseTests passed after 0.104 seconds.
✔ Test run with 2 tests passed after 0.104 seconds.

setUp, tearDown が2回実行されていることから、それぞれの @Test のたびに actor が初期化・破棄されていることがわかります。

Xcode 16.3 (Swift 6.1) からの新要素 TestScoping

これに対して、やはり課題感はあったようで、Proposal: ST-0007 を経て、TestScoping という概念が導入されました。

これは、テストをラップするような形で、前後に処理を実行できる Trait です。(Trait とは、アノテーションで指定することにより、テストの挙動をカスタマイズする機能です)

以下に例を示します。

struct SampleScope1: TestTrait, TestScoping {
    let someValue: Int
    
    func provideScope(
        for test: Test,
        testCase: Test.Case?,
        performing function: () async throws -> Void
    ) async throws {
        print("1️⃣ setUp Scope 1 \(test.name), value = \(someValue)")
        try await Task.sleep(for: .milliseconds(100))

        try await function()

        try await Task.sleep(for: .milliseconds(100))
        print("1️⃣ tearDown Scope 1 \(test.name)")
    }
}

struct SampleScope2: TestTrait, TestScoping {
    func provideScope(
        for test: Test,
        testCase: Test.Case?,
        performing function: () async throws -> Void
    ) async throws {
        print("2️⃣ setUp Scope 2 \(test.name)")
        try await function()
        print("2️⃣ tearDown Scope 2 \(test.name)")
    }
}

@Suite(.serialized) // 結果をわかりやすくするため直列に実行
struct TestScopingIntroduction {
    @Test(SampleScope1(someValue: 1))
    func test1() async throws {
        print("test1")
    }
    
    @Test(SampleScope1(someValue: 2), SampleScope2())
    func test2() async throws {
        print("test2")
    }
}

->

◇ Test run started.
↳ Testing Library Version: 124
↳ Target Platform: arm64-apple-ios13.0-simulator
◇ Suite TestScopingIntroduction started.
◇ Test test1() started.
1️⃣ setUp Scope 1 test1(), value = 1
test1
1️⃣ tearDown Scope 1 test1()
✔ Test test1() passed after 0.209 seconds.
◇ Test test2() started.
1️⃣ setUp Scope 1 test2(), value = 2
2️⃣ setUp Scope 2 test2()
test2
2️⃣ tearDown Scope 2 test2()
1️⃣ tearDown Scope 1 test2()
✔ Test test2() passed after 0.206 seconds.
✔ Suite TestScopingIntroduction passed after 0.417 seconds.
✔ Test run with 2 tests passed after 0.418 seconds.

なにやら便利そうな雰囲気がありますね。

  • tearDown のタイミングで await できる
  • @Test ごとに異なる Scope を、複数与えられる
  • provideScope 内で TestTest.Case にアクセスできる

ことがわかります。
また、SuiteTrait と組み合わせて、isRecursive プロパティに false を返せば、@Suite 内で1回だけ実行する、ということも可能なようです。(class func setUp/tearDown のような使い方が想定できます)

しかし、これでだけでは不便な点もあります。
特に、Scope 側から @Test 側に値を渡す手段がありません。
前項内の例でいえば、初期化した storage, gateway, useCase へアクセスすることができません。

これに関して、Proposal 内では、@TaskLocal を用いた方法が示されていました。

@TaskLocal を用いて TestScope 内へ値を渡す

ここでは @TaskLocal の詳細には触れませんが、特定のタスクコンテキストの中からのみアクセス可能なグローバル値のようなもの (task-local value) を定義するマクロです。マクロによって、その task-local value へアクセスする TaskLocal<T> 型のキーが生成されます。

TaskLocal<T>withValue メソッドを使用することで、task-local value へ値をバインドし、同時にその値を利用可能なタスクコンテキストを作成できます。
今回は、そのタスクコンテキスト内で provideScopeperforming function を実行することで、@Test へ task-local value として値を渡します。

そのための簡単なヘルパを用意してみたので、以下に紹介します。

protocol TestScope: TestTrait, TestScoping {
    associatedtype LocalValue: Sendable

    var taskLocal: TaskLocal<LocalValue?> { get }

    func setUp() async throws -> LocalValue

    func tearDown(localValue: LocalValue) async throws
}

extension TestScope {
    func provideScope(
        for test: Test,
        testCase: Test.Case?,
        performing function: () async throws -> Void
    ) async throws {
        let local = try await setUp()

        do {
            try await taskLocal.withValue(local) {
                try await function()
            }
        } catch {
            // defer async {} ってできないんですね。
            try await tearDown(localValue: local) 
            throw error
        }
        try await tearDown(localValue: local)
    }
}

これを用いて、最初のテストは次のように書けます。

private struct Scope: TestScope {
    typealias LocalValue = (
        storage: MockAuthStorage,
        gateway: MockAPIGateway,
        useCase: SignInUseCase
    )
    
    @TaskLocal static var values: LocalValue?
    var taskLocal: TaskLocal<LocalValue?> { Self.$values }
    
    func setUp() async throws -> LocalValue {
        print("setUp")
        let storage = MockAuthStorage()
        let gateway = MockAPIGateway()
        let useCase = SignInUseCase(apiGateway: gateway, authStorage: storage)
        return (storage, gateway, useCase)
    }
    
    func tearDown(localValue: (storage: MockAuthStorage, gateway: MockAPIGateway, useCase: SignInUseCase)) async throws {
        print("tearDown")
        await localValue.storage.clear()
    }
}

@Suite
struct SignInUseCaseTestsWithTestScope {
    @Test(Scope())
    func testSuccess() async throws {
        let (storage, gateway, useCase) = Scope.values!
        
        await gateway.setSignInResponse(.success("test-token"))
        
        await #expect(throws: Never.self) {
            try await useCase.execute(
                email: "test-email", password: "test-password"
            )
        }
        
        #expect(await storage.token == "test-token")
    }
    
    @Test(Scope())
    func testUserNotFound() async throws {
        let (storage, gateway, useCase) = Scope.values!

        await gateway.setSignInResponse(.failure(APIError.http(status: 404)))
        
        await #expect(throws: SignInError.userNotFound) {
            try await useCase.execute(
                email: "test-email", password: "test-password"
            )
        }
        
        #expect(await storage.token == nil)
    }
}

->

◇ Test run started.
↳ Testing Library Version: 124
↳ Target Platform: arm64-apple-ios13.0-simulator
◇ Suite SignInUseCaseTestsWithTestScope started.
◇ Test testSuccess() started.
◇ Test testUserNotFound() started.
setUp
setUp
tearDown
✔ Test testSuccess() passed after 0.105 seconds.
tearDown
✔ Test testUserNotFound() passed after 0.109 seconds.
✔ Suite SignInUseCaseTestsWithTestScope passed after 0.109 seconds.
✔ Test run with 2 tests passed after 0.109 seconds.

並列に実行されていますが、一見グローバル値のような values がそれぞれのスコープ内で独立して存在しています。
今回はひとつのファイル内のみでの使用を想定しましたが、前項で述べたような TestScoping の柔軟性を活かして、再利用可能な Scope を作成してもよさそうです。

まとめ

今回は、Xcode 16.3 で新たに導入された TestScoping と、Swift Concurrency の便利な機能である @TaskLocal を用いて、Swift Testing で柔軟性の高い setUp/tearDown を行う方法と、簡単なヘルパを紹介しました。

Swift Testing は、マクロを使って柔軟にテストを定義できたり、最近の Swift らしく並列実行に力を入れていたりと、とても便利なフレームワークだと感じます。
また、なんとなく心の底で思っていた、「そんなにかっこいいマッチャを大量に定義しなくても、全部 == で評価してtrueが返ることを確認すればよいのでは?」という疑問に Yes を返してくれたようで、少し嬉しいです。

冒頭に書いたように、太古の時代からタイムスリップしてきた身としては、最新の iOS 開発を取り巻く環境は非常に洗練され、快適で、クリエイティビティを刺激するものだと感じます。
これからも、そういった機能の紹介や、ちょっとした便利ヘルパ等を紹介していきたいな、と思います。

今後ともよろしくお願いいたします。


採用情報

メディロムグループでは以下のようなサービスを展開しています。

  • 全国300店舗以上のリラクゼーションスタジオ「Re.Ra.Ku」
  • 世界初!充電不要の活動量計「MOTHER bracelet」
  • ヘルスケアコーチングアプリ「Lav」

ヘルスケア領域に興味があるエンジニア、PMを絶賛募集中です!
少しでも興味を持っていただけた方は、ぜひ以下のリンクからエントリーしてみてください。
ソフトウェアに限らず、ハードウェア制作も含めて、ものづくりと健康が大好きなあなた。ぜひ一緒に楽しみましょう😎

https://medirom.co.jp/recruit

メディロムグループ Tech Blog

Discussion