nonisolated が付いた関数等を含む actor 専用プロトコルのテスト向けモックアップを作る(Swift)
actor 専用プロトコル
次のような ControllerProtocol
を考えます。
protocol ControllerProtocol: Actor {
func someFunc1()
nonisolated func someFunc2()
}
この ControllerProtocol
は Actor
に適合するため、actor 専用プロトコルになります。
actor Controller: ControllerProtocol {
func someFunc1() { /* ... */ }
nonisolated func someFunc2() { /* ... */ }
}
actor 専用プロトコルをクラス・構造体・列挙型に付けようとするとコンパイルエラーになる
actor 専用プロトコルのテスト向けモックアップを作る
さらに、テストのためにこの ControllerProtocol
のモックアップを作ることを考えます。
actor ControllerMock: ControllerProtocol {
/* ... */
}
今回は
- 関数が呼ばれた回数がカウントされ、それを外から取得できる
- 関数が呼ばれた際に実行される処理を外から与えることができる
の2つを満たせるモックアップを作ります。
isolate された関数が呼ばれた回数や呼ばれたときの処理を与えられるようにする
まずは isolate された関数 someFunc1()
の準備を行います。
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:)
によって与えた処理が実行されます。
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 の処理
と出力され、someFunc1CallCount
が 1
になることが確認できます。
[1]が呼ばれた回数や呼ばれたときの処理を与えられるようにする
isolate された計算プロパティ計算プロパティ の場合
計算プロパティ[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()
のときのように、someFunc2CallCount
と someFunc2Handler
を用意することにしましょう。
すると、Actor-isolated property 'someFunc2CallCount' can not be mutated from a non-isolated context
・Actor-isolated property 'someFunc2Handler' can not be referenced from a non-isolated context
というコンパイルエラーとなってしまいました。someFunc2CallCount
と someFunc2Handler
は actor に isolate されているため、nonisolated となっている someFunc2()
の中から変更したり参照したりすることができません。
では、someFunc2CallCount
と someFunc2Handler
も nonisolated にすればよいのでしょうか。
今度は 'nonisolated' can not be applied to stored properties
というコンパイルエラーとなってしまいました。格納プロパティ[2]に nonisolated を適用することができないからです。
格納プロパティ[2:1]はそのままにするとして、someFunc2()
の中の実装を変えてみます。
someFunc2()
の中で Task
を作り、その中から actor に isolate されている someFunc2CallCount
と someFunc2Handler
を非同期で変更したり呼び出したりするように変更してみました。
コンパイルエラーは無くなったので、100回連続でこのテストを実行してみましょう。
テスト結果を見ると、2回成功、98回失敗となっていました。someFunc2()
の中の Task
の operation
が完了する前に someFunc2()
は Void
を返してしまう(終了してしまう)ので、タイミングによっては someFunc2CallCount
を取得したときに 1
ではなく 0
のままとなっている可能性があります。
このままではテストのためのモックアップとして不適当です。
nonisolated(unsafe)
を使う
Swift 5.10 以降の場合: 先に「格納プロパティ[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)
の付いたプロパティが意図しない方法で用いられるリスクを許容できるでしょう。
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 の処理
と出力され、someFunc2CallCount
が 1
になることが確認できます。
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]。
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 の処理
と出力され、someFunc2CallCount
が 1
になることが確認できます。
[1:5]が呼ばれた回数や呼ばれたときの処理を与えられるようにする
nonisolated な計算プロパティ計算プロパティ の場合
計算プロパティ[1:6] の場合も同様にできます。
protocol ControllerProtocol: Actor {
/* ... */
nonisolated var someProperty2: String { get }
/* ... */
}
nonisolated(unsafe)
を使う
Swift 5.10 以降の場合: 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) // ✅
}
/* ... */
}
-
"Computed Property" の日本語訳とした「計算プロパティ」は The Swift Programming Language日本語版 · プロパティ(Properties) をもとにした。 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
-
"Stored Property" の日本語訳とした「格納プロパティ」は The Swift Programming Language日本語版 · プロパティ(Properties) をもとにした。 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
-
モックアップ側で用いたいプロパティと「actor に isolate されない場所」に作成する格納プロパティ[2:6]は1対1で対応するため、たとえば Swift マクロでコンパイル時に自動でコード生成を行うマクロを書いてそれを用いる… などの方法も考えられるだろう。 ↩︎
Discussion