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 を行うためには、 @Suite
を class
か actor
で記述し、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
が初期化・破棄されていることがわかります。
TestScoping
Xcode 16.3 (Swift 6.1) からの新要素 これに対して、やはり課題感はあったようで、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
内でTest
やTest.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 へ値をバインドし、同時にその値を利用可能なタスクコンテキストを作成できます。
今回は、そのタスクコンテキスト内で provideScope
の performing 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を絶賛募集中です!
少しでも興味を持っていただけた方は、ぜひ以下のリンクからエントリーしてみてください。
ソフトウェアに限らず、ハードウェア制作も含めて、ものづくりと健康が大好きなあなた。ぜひ一緒に楽しみましょう😎
Discussion