🕵️

nonisolated が付いた関数等を含む actor 専用プロトコルのテスト向けモックアップを作る(Swift)

2024/05/19に公開

actor 専用プロトコル

次のような ControllerProtocol を考えます。

ControllerProtocol.swift
protocol ControllerProtocol: Actor {
    func someFunc1()
    nonisolated func someFunc2()
}

この ControllerProtocolActor に適合するため、actor 専用プロトコルになります。

✅ ControllerProtocol は actor 専用プロトコル
actor Controller: ControllerProtocol {
    func someFunc1() { /* ... */ }
    nonisolated func someFunc2() { /* ... */ }
}

actor 専用プロトコルとした ControllerProtocol をクラスに適用させようとしてコンパイルエラーが表示されている画面のスクリーンショット
actor 専用プロトコルをクラス・構造体・列挙型に付けようとするとコンパイルエラーになる

actor 専用プロトコルのテスト向けモックアップを作る

さらに、テストのためにこの ControllerProtocol のモックアップを作ることを考えます。

ControllerMock.swift
actor ControllerMock: ControllerProtocol {
    /* ... */
}

今回は

  • 関数が呼ばれた回数がカウントされ、それを外から取得できる
  • 関数が呼ばれた際に実行される処理を外から与えることができる

の2つを満たせるモックアップを作ります。

isolate された関数が呼ばれた回数や呼ばれたときの処理を与えられるようにする

まずは isolate された関数 someFunc1() の準備を行います。

ControllerMock.swift
  actor ControllerMock: ControllerProtocol {
+     // MARK: - `someFunc1()`
+     private(set) var someFunc1CallCount = 0
+     private var someFunc1Handler: (() -> ())?
+     func set(someFunc1Handler: (@Sendable () -> ())?) {
+         self.someFunc1Handler = someFunc1Handler
+     }
+     func someFunc1() {
+         someFunc1CallCount += 1
+         someFunc1Handler?()
+     }
      
      /* ... */
  }

someFunc1() が呼ばれると someFunc1CallCount の値が 1 増えます。また事前に set(someFunc1Handler:) によって与えた処理が実行されます。

SomeTests.swift
import XCTest

final class SomeTests: XCTestCase {
    let mock = ControllerMock()
    
    func testSomeFunc1() async {
        await mock.set(someFunc1Handler: { print("外から与えた someFunc1 の処理") })
        // どこかでこの mock を注入し、それ経由で someFunc1() が呼ばれる想定
        await mock.someFunc1()  // 今回は省略のため someFunc1() を直接呼ぶ
        
        let someFunc1CallCount = await mock.someFunc1CallCount
        XCTAssertEqual(someFunc1CallCount, 1)  // ✅
    }

    /* ... */
}

この testSomeFunc1() を実行すると、コンソールに 外から与えた someFunc1 の処理 と出力され、someFunc1CallCount1 になることが確認できます。

isolate された計算プロパティ[1]が呼ばれた回数や呼ばれたときの処理を与えられるようにする

計算プロパティ の場合

計算プロパティ[1:1] の場合も同様にできます。

protocol ControllerProtocol: Actor {
    /* ... */
    var someProperty1: String { get }
    /* ... */
}
actor ControllerMock: ControllerProtocol {
    /* ... */
    
    // MARK: - `someProperty1`
    private(set) var someProperty1CallCount = 0
    private var someProperty1Handler: (() -> String)!
    func set(someProperty1Handler: @escaping @Sendable () -> String) {
        self.someProperty1Handler = someProperty1Handler
    }
    var someProperty1: String {
        someProperty1CallCount += 1
        return someProperty1Handler()
    }
    
    /* ... */
}
final class SomeTests: XCTestCase {
    let mock = ControllerMock()

    /* ... */
    
    func testSomeProperty1() async {
        await mock.set(someProperty1Handler: { "外から与えた someProperty1" })
        // どこかでこの mock を注入し、それ経由で someProperty1 が呼ばれる想定
        let someProperty1 = await mock.someProperty1  // 今回は省略のため someProperty1 を直接呼ぶ
        XCTAssertEqual(someProperty1, "外から与えた someProperty1")
        let someProperty1CallCount = await mock.someProperty1CallCount
        XCTAssertEqual(someProperty1CallCount, 1)
    }
    
    /* ... */
}

nonisolated な関数が呼ばれた回数や呼ばれたときの処理を与えられるようにする

そして本題の nonisolated な関数 someFunc2() の準備を行います。isolate された関数 someFunc1() のときのように、someFunc2CallCountsomeFunc2Handler を用意することにしましょう。

isolate された関数 someFunc1() のときと同じように、someFunc2() のための someFunc2CallCount と someFunc2Handler を用意しようとしてコンパイルエラーが表示されている画面のスクリーンショット

すると、Actor-isolated property 'someFunc2CallCount' can not be mutated from a non-isolated contextActor-isolated property 'someFunc2Handler' can not be referenced from a non-isolated context というコンパイルエラーとなってしまいました。someFunc2CallCountsomeFunc2Handler は actor に isolate されているため、nonisolated となっている someFunc2() の中から変更したり参照したりすることができません。

では、someFunc2CallCountsomeFunc2Handler も nonisolated にすればよいのでしょうか。

格納プロパティに nonisolated キーワードを付けてコンパイルエラーが表示されている画面のスクリーンショット

今度は 'nonisolated' can not be applied to stored properties というコンパイルエラーとなってしまいました。格納プロパティ[2]に nonisolated を適用することができないからです。

格納プロパティ[2:1]はそのままにするとして、someFunc2() の中の実装を変えてみます。

someFunc2() の中で isolate されているプロパティたちを Task の中から非同期に変更するようにしたコードのスクリーンショット

someFunc2() の中で Task を作り、その中から actor に isolate されている someFunc2CallCountsomeFunc2Handler を非同期で変更したり呼び出したりするように変更してみました。

コンパイルエラーは無くなったので、100回連続でこのテストを実行してみましょう。

someFunc2() のためのテストを100回連続で実行し、2回成功、98回失敗しているテスト結果のスクリーンショット

テスト結果を見ると、2回成功、98回失敗となっていました。someFunc2() の中の Taskoperation が完了する前に someFunc2()Void を返してしまう(終了してしまう)ので、タイミングによっては someFunc2CallCount を取得したときに 1 ではなく 0 のままとなっている可能性があります。

このままではテストのためのモックアップとして不適当です。

Swift 5.10 以降の場合: nonisolated(unsafe) を使う

先に「格納プロパティ[2:2]に nonisolated を適用することができない」と述べましたが、Swift 5.10 にて追加された SE-0412 による nonisolated(unsafe) を格納プロパティ[2:3]に対して使うことができます。

  actor ControllerMock: ControllerProtocol {
      /* ... */
      
+     // MARK: - `someFunc2()`
+     private(set) nonisolated(unsafe) var someFunc2CallCount = 0
+     private nonisolated(unsafe) var someFunc2Handler: (@Sendable () -> ())?
+     nonisolated func set(someFunc2Handler: (@Sendable () -> ())?) {
+         self.someFunc2Handler = someFunc2Handler
+     }
+     nonisolated func someFunc2() {
+         someFunc2CallCount += 1
+         someFunc2Handler?()
+     }
      
      /* ... */
}

SE-0412 のタイトルにあるとおり、nonisolated(unsafe) はグローバル変数に付けることが意図されたアノテーションではありますが、このケースにおいてもコンパイルが通り実行できます。また、これはテストのためのモックアップであり、適切なアクセス修飾子によって影響範囲を必要最小限に絞ることで nonisolated(unsafe) の付いたプロパティが意図しない方法で用いられるリスクを許容できるでしょう。

SomeTests.swift
import XCTest

final class SomeTests: XCTestCase {
    let mock = ControllerMock()
    
    /* ... */
    
    func testSomeFunc2() {
        mock.set(someFunc2Handler: { print("外から与えた someFunc2 の処理") })  // `nonisolated` にしため await キーワードは不要
        // どこかでこの mock を注入し、それ経由で someFunc2() が呼ばれる想定
        mock.someFunc2()  // 今回は省略のため someFunc2() を直接呼ぶ
        
        let someFunc2CallCount = mock.someFunc2CallCount  // `nonisolated(unsafe)` のため await キーワードは不要
        XCTAssertEqual(someFunc2CallCount, 1)  // ✅
    }
    
    /* ... */
}

この testSomeFunc2() を実行すると、コンソールに 外から与えた someFunc2 の処理 と出力され、someFunc2CallCount1 になることが確認できます。

Swift 5.10 未満の場合: isolate されない場所にプロパティを移動させる

先に「格納プロパティ[2:4]に nonisolated を適用することができない」と述べましたが、計算プロパティ[1:2]は nonisolated にできます。これを利用し、モックアップ側で用いたいプロパティを nonisolated な計算プロパティ[1:3]に、それらを actor に isolate されない場所に格納しておく方法を考えます。

  actor ControllerMock: ControllerProtocol {
+     private struct NonIsolatedPropertyStorage {
+         static var shared = Self()
+         private init() {}
+         static func reset() { shared = Self() }
+         
+         var someFunc2CallCount = 0
+         var someFunc2Handler: (@Sendable () -> ())?
+    }
+    
+     init() {
+         NonIsolatedPropertyStorage.reset()
+     }
      
      /* ... */
      
+     // MARK: - `someFunc2()`
+     private(set) nonisolated var someFunc2CallCount: Int {
+         get { NonIsolatedPropertyStorage.shared.someFunc2CallCount }
+         set { NonIsolatedPropertyStorage.shared.someFunc2CallCount = newValue }
+     }
+     private nonisolated var someFunc2Handler: (@Sendable () -> ())? {
+         get { NonIsolatedPropertyStorage.shared.someFunc2Handler }
+         set { NonIsolatedPropertyStorage.shared.someFunc2Handler = newValue }
+     }
+     nonisolated func set(someFunc2Handler: (@Sendable () -> ())?) {
+         self.someFunc2Handler = someFunc2Handler
+     }
+     nonisolated func someFunc2() {
+         someFunc2CallCount += 1
+         someFunc2Handler?()
+     }
      
      /* ... */
  }

上記のコードは「actor に isolate されない場所」として NonIsolatedPropertyStorage を作る例です。モックアップ側で用いたいプロパティを nonisolated な計算プロパティ[1:4]にし、getter と setter によって NonIsolatedPropertyStorage 内の格納プロパティ[2:5]へのアクセスを行うようにしています。モックアップ側で用いたいプロパティが増えれば増えるほど、必要なコード量が増えていきます[3]

SomeTests.swift
import XCTest

final class SomeTests: XCTestCase {
    let mock = ControllerMock()
    
    /* ... */
    
    func testSomeFunc2() {
        mock.set(someFunc2Handler: { print("外から与えた someFunc2 の処理") })  // `nonisolated` にしため await キーワードは不要
        // どこかでこの mock を注入し、それ経由で someFunc2() が呼ばれる想定
        mock.someFunc2()  // 今回は省略のため someFunc2() を直接呼ぶ
        
        let someFunc2CallCount = mock.someFunc2CallCount  // `nonisolated(unsafe)` のため await キーワードは不要
        XCTAssertEqual(someFunc2CallCount, 1)  // ✅
    }
    
    /* ... */
}

この testSomeFunc2() を実行すると、コンソールに 外から与えた someFunc2 の処理 と出力され、someFunc2CallCount1 になることが確認できます。

nonisolated な計算プロパティ[1:5]が呼ばれた回数や呼ばれたときの処理を与えられるようにする

計算プロパティ の場合

計算プロパティ[1:6] の場合も同様にできます。

protocol ControllerProtocol: Actor {
    /* ... */
    nonisolated var someProperty2: String { get }
    /* ... */
}

Swift 5.10 以降の場合: nonisolated(unsafe) を使う

actor ControllerMock: ControllerProtocol {
    /* ... */
    
    // MARK: - `someProperty2`
    private(set) nonisolated(unsafe) var someProperty2CallCount = 0
    private nonisolated(unsafe) var someProperty2Handler: (() -> String)!
    nonisolated func set(someProperty2Handler: @escaping @Sendable () -> String) {
        self.someProperty2Handler = someProperty2Handler
    }
    nonisolated var someProperty2: String {
        someProperty2CallCount += 1
        return someProperty2Handler()
    }
    
    /* ... */
}
final class SomeTests: XCTestCase {
    let mock = ControllerMock()

    /* ... */
    
   func testSomeProperty2() {
        mock.set(someProperty2Handler: { "外から与えた someProperty2" })  // `nonisolated` にしため await キーワードは不要
        // どこかでこの mock を注入し、それ経由で someProperty2 が呼ばれる想定
        let someProperty2 = mock.someProperty2  // 今回は省略のため someProperty2 を直接呼ぶ
        XCTAssertEqual(someProperty2, "外から与えた someProperty2")
        let someProperty2CallCount = mock.someProperty2CallCount  // `nonisolated(unsafe)` のため await キーワードは不要
        XCTAssertEqual(someProperty2CallCount, 1)  // ✅
    }
    
    /* ... */
}

Swift 5.10 未満の場合: isolate されない場所にプロパティを移動させる

actor ControllerMock: ControllerProtocol {
    private struct NonIsolatedPropertyStorage {
        static var shared = Self()
        private init() {}
        static func reset() { shared = Self() }
        
        var someProperty2CallCount = 0
        var someProperty2Handler: (() -> String)!
    }
    
    init() {
        NonIsolatedPropertyStorage.reset()
    }
    
    /* ... */
    
    // MARK: - `someProperty2`
    private(set) nonisolated var someProperty2CallCount: Int {
        get { NonIsolatedPropertyStorage.shared.someProperty2CallCount }
        set { NonIsolatedPropertyStorage.shared.someProperty2CallCount = newValue }
    }
    private nonisolated var someProperty2Handler: (() -> String)! {
        get { NonIsolatedPropertyStorage.shared.someProperty2Handler }
        set { NonIsolatedPropertyStorage.shared.someProperty2Handler = newValue }
    }
    nonisolated func set(someProperty2Handler: @escaping @Sendable () -> String) {
        self.someProperty2Handler = someProperty2Handler
    }
    nonisolated var someProperty2: String {
        someProperty2CallCount += 1
        return someProperty2Handler()
    }
    
    /* ... */
}
final class SomeTests: XCTestCase {
    let mock = ControllerMock()

    /* ... */
    
   func testSomeProperty2() {
        mock.set(someProperty2Handler: { "外から与えた someProperty2" })  // `nonisolated` にしため await キーワードは不要
        // どこかでこの mock を注入し、それ経由で someProperty2 が呼ばれる想定
        let someProperty2 = mock.someProperty2  // 今回は省略のため someProperty2 を直接呼ぶ
        XCTAssertEqual(someProperty2, "外から与えた someProperty2")
        let someProperty2CallCount = mock.someProperty2CallCount  // `nonisolated(unsafe)` のため await キーワードは不要
        XCTAssertEqual(someProperty2CallCount, 1)  // ✅
    }
    
    /* ... */
}
脚注
  1. "Computed Property" の日本語訳とした「計算プロパティ」は The Swift Programming Language日本語版 · プロパティ(Properties) をもとにした。 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  2. "Stored Property" の日本語訳とした「格納プロパティ」は The Swift Programming Language日本語版 · プロパティ(Properties) をもとにした。 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  3. モックアップ側で用いたいプロパティと「actor に isolate されない場所」に作成する格納プロパティ[2:6]は1対1で対応するため、たとえば Swift マクロでコンパイル時に自動でコード生成を行うマクロを書いてそれを用いる… などの方法も考えられるだろう。 ↩︎

Discussion