swift-dependencies の使い方・メリット・注意点
概要
iOS 開発において DI といえばこれというような方法やライブラリは現状ないと思います。そんな中で、 pointfree が開発している swift-dependencies は今後のスタンダードになりうるような良いライブラリだと感じたので紹介したいと思います。 swift-dependencies はもともと swift-composable-architecture (TCA) から切り出されたライブラリですが、 TCA を利用していないアプリでも使うことができます。
この記事では、 swift-dependencies の内部実装などには踏み込まず、
- 使い方
- メリット
- 利用にあたって注意すべき点
についてまとめようと思います。
リンク
- pointfreeco/swift-dependencies - github
- swift-dependencies documentation
- swift-dependencies-additions - github
使い方
使い方を見ていく上での例として、ボタンをタップされるたびにランダムな数値を表示する SwiftUI の画面を考えます。
画面には、数値を表示する Text
/ 数値を取得するための Button
/ 発生したエラーを表示するための Text
を並べておきます。
struct RandomNumberScreen: View {
@StateObject private var viewModel: RandomNumberViewModel = .init()
var body: some View {
VStack {
Text(viewModel.numberString)
.monospaced()
Button("Fetch") {
Task { await viewModel.fetch() }
}
if let errorString = viewModel.errorString {
Text(errorString)
.foregroundStyle(.red)
}
}
}
}
数値を取得して表示するためのロジックは view model に持たせます。ランダムな数値の取得には Random Number API を利用しています。
struct RandomNumberError: Error {}
@MainActor
final class RandomNumberViewModel: ObservableObject {
@Published private(set) var numberString: String = "0"
@Published private(set) var errorString: String?
private let randomNumberAPIURL = URL(string: "https://www.randomnumberapi.com/api/v1.0/random?min=0&max=100")!
func fetch() async {
do {
let (data, response) = try await URLSession.shared.data(from: randomNumberAPIURL)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw RandomNumberError()
}
guard let numbers = try? JSONDecoder().decode([Int].self, from: data),
let number = numbers.first else {
throw RandomNumberError()
}
numberString = String(number)
} catch {
errorString = "Something went wrong"
}
}
}
現状のコードでも正しく動作しますが、
-
RandomNumberViewModel
が表示のためのロジックだけではなく、 API を叩いて結果をパースするロジックも含んでいるので今後肥大化していくことが予想される -
RandomNumberViewModel
のテストをしようとすると実際に API を叩いてしまうので、テストが不安定になる上にエラー時の確認などがしづらい
などの問題点があります。そのため、このような場合 API リクエストを行うコードを以下のように切り出すことが一般的だと思います[1]。
protocol RandomNumberClientProtocol: Sendable {
func fetch() async throws -> Int
}
public struct RandomNumberClient: RandomNumberClientProtocol {
private let randomNumberAPIURL = URL(string: "https://www.randomnumberapi.com/api/v1.0/random?min=0&max=100")!
public init() {}
public func fetch() async throws -> Int {
let (data, response) = try await URLSession.shared.data(from: randomNumberAPIURL)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw RandomNumberError()
}
guard let numbers = try? JSONDecoder().decode([Int].self, from: data),
let number = numbers.first else {
throw RandomNumberError()
}
return number
}
}
この RandomNumberClient
を依存として RandomNumberViewModel
に注入することにします。
DependencyKey
と DependencyValues
依存の登録: まず、 RandomNumberClient
を依存として利用できるように登録する作業を行います。 swift-dependencies を import した上で、以下の2点を行います。
import Dependencies
// 1. DependencyKey の作成
private enum RandomNumberClientKey: DependencyKey {
static let liveValue: any RandomNumberClientProtocol = RandomNumberClient()
}
// 2. DependencyValues の拡張
extension DependencyValues {
public var randomNumberClient: any RandomNumberClientProtocol {
get { self[RandomNumberClientKey.self] }
set { self[RandomNumberClientKey.self] = newValue }
}
}
swift-dependencies の動作のイメージとしては、 DependencyValues
という依存を保存しておく環境としての Key-Value ストアがあり、依存を利用したいときに必要に応じてそこから取り出す、と理解することができると思っています。
まずは、 1. DependencyKey の作成
にて RandomNumberClient
を DependencyValues
に保存したり取得したりするために使う Key を作成します。 Key は DependencyKey
というプロトコルに準拠している必要があり、 liveValue
という static プロパティの実装が要求されます。この liveValue
がアプリ実行時に依存として利用される値になるので、 RandomNumberClientProtocol
の実装である RandomNumberClient
のインスタンスを代入しておきます。
2. DependencyValues の拡張
では、その Key を使って実際に RandomNumberClient
保存・取得するための処理を追加しています。
この辺りの作業は依存を登録する際の決まりきった作業で、 swift-dependencies を使い始める段階ではあまり深く理解していなくても大丈夫だと思っています。
@Dependency
依存の取得: 続いて、上記で依存として登録した RandomNumberClient
を RandomNumberViewModel
から使っていきます。依存を取得するためには、 @Dependency
というプロパティラッパーを利用します。
@MainActor
final class RandomNumberViewModel: ObservableObject {
@Published private(set) var numberString: String = "0"
@Published private(set) var errorString: String?
@Dependency(\.randomNumberClient) private var randomNumberClient
func fetch() async {
do {
let number = try await randomNumberClient.fetch()
numberString = String(number)
errorString = nil
} catch {
errorString = "Something went wrong"
}
}
}
@Dependency
の初期化時に渡している key path は DependencyValues
からそのプロパティへの key path で、依存を登録時に DependencyValues
を拡張したことにより利用可能になっています。アプリを実行すると @Dependency
の wrappedValue
には、 swift-dependencies が RandomNumberClient
を渡してくれるため、これだけで以前と同じように動作します。
withDependencies
依存の差し替え: RandomNumberViewModel
のテストを書くことを考えます。テストで実際の API にリクエストしたくないので、以下のように RandomNumberClient
のモックを作成します。
public struct RandomNumberClientMock: RandomNumberClientProtocol {
private let fetchResult: Result<Int, Error>
public init(fetchResult: Result<Int, Error>) {
self.fetchResult = fetchResult
}
public func fetch() async throws -> Int {
try fetchResult.get()
}
}
このモックを RandomNumberViewModel
に差し込むために、 withDependencies
という関数を使います。
let viewModel = withDependencies {
$0.randomNumberClient = RandomNumberClientMock(fetchResult: .success(42))
} operation: {
RandomNumberViewModel()
}
// RandomNumberClientMock が利用される
await viewModel.randomNumberClient
withDependencies
の第一引数で依存を取り出すための環境である DependencyValues
の上書きを行い、第二引数にはその環境下で行いたい処理を書くイメージです。今回の場合は、第一引数のクロージャで DependencyValues
が RandomNumberClientKey
に対してアプリ実行時の RandomNumberClient
ではなく RandomNumberClientMock
を取り出すように上書きをし、第二引数で RandomNumberViewModel
を初期化することで、得られた RandomNumberViewModel
は RandomNumberClientMock
を使うようになります。
上記を利用して、以下のようなテストを書くことができます。
func test_数値の取得に成功するとnumberStringに対応する文字列が入る() async throws {
let viewModel = withDependencies {
$0.randomNumberClient = RandomNumberClientMock(fetchResult: .success(42))
} operation: {
RandomNumberViewModel()
}
await viewModel.fetch()
XCTAssertEqual(viewModel.numberString, "42")
XCTAssertEqual(viewModel.errorString, nil)
}
エラー時のテストも、以下のように簡単にできます。
func test_数値の取得に失敗するとerrorStringにメッセージが入る() async throws {
struct APIError: Error {}
let viewModel = withDependencies {
$0.randomNumberClient = RandomNumberClientMock(fetchResult: .failure(APIError()))
} operation: {
RandomNumberViewModel()
}
await viewModel.fetch()
XCTAssertEqual(viewModel.numberString, "0")
XCTAssertEqual(viewModel.errorString, "Something went wrong")
}
以上のように、 withDependencies
により依存の liveValue
を上書きし、別の実装に差し替えることができます。
アプリ実行時に withDependencies
を書くことがまったくないわけではないですが、アプリ実行時の依存は liveValue
をそのまま使えばいいことがほとんどなので、 withDependencies
を書くのはテストやプレビューが主になるでしょう。
テストの例は上記で紹介しましたが、プレビューにおいても以下のように望みの条件における表示を確認するために withDependencies
で依存を上書きすることができます[2]。
struct RandomNumberScreen_Previews: PreviewProvider {
static var previews: some View {
// API リクエスト成功ケースの表示確認
RandomNumberScreen(
viewModel: withDependencies {
$0.randomNumberClient = RandomNumberClientMock(fetchResult: .success(42))
} operation: {
.init()
}
)
// API リクエスト失敗ケースの表示確認
RandomNumberScreen(
viewModel: withDependencies {
$0.randomNumberClient = RandomNumberClientMock(fetchResult: .failure(NSError(domain: "a", code: 0)))
} operation: {
.init()
}
)
}
}
Context ごとのデフォルトの依存
上記の例では、 DependencyKey
に準拠する際に liveValue
のみしか定義しませんでした。 liveValue
にはアプリ実行時にデフォルトで(= withDependencies
で明示的に依存が上書きされない場合に)利用される依存を指定しますが、これに加えてテスト時 / プレビュー時に利用される testValue
と previewValue
を定義することもできます。 testValue
や previewValue
が指定されない場合は liveValue
にフォールバックされるので、テストやプレビューで withDependencies
を利用しない場合は liveValue
が使われることになります。
テストやプレビューでは結局 withDependencies
で上書きするんだから、 testValue
や previewValue
はあえて定義する必要がないという考えもあるでしょう。ただし、その場合はテストやプレビューから意図せず本物の依存である liveValue
が使われることで本物の API にリクエストが飛んだり本物の DB にアクセスされるリスクがあることが認識しておく必要があります。デフォルトの値として testValue
と previewValue
を定義しておくことで、そのようなリスクを避けることができます。
swift-dependencies のドキュメントでは、 testValue
には呼ばれるとエラーになる unimplemented
を定義することが推奨されています。
private enum RandomNumberClientKey: DependencyKey {
static let liveValue: any RandomNumberClientProtocol = RandomNumberClient()
static let testValue: any RandomNumberClientProtocol = unimplemented()
}
これにより、テストでは withDependencies
で明示的に上書かれていない依存を利用した瞬間にテストが落ちることになります。これにより、「依存が意図しない形で呼び出されている」という事象が発生している場合にテストで検知できることになります。なぜ意図しない依存の呼び出しが検知できるのがうれしいのかについては、のちほど 予期しない副作用の実行をテスト時に検知できる にて補足します。
意図しない依存の呼び出しの検知はテストの責務でありプレビューで行う必要はないので、 previewValue
にはいい感じのデフォルト値を返す実装を提供してもよいことになっています。これにより、プレビューのためだけに、直接表示に関係ないものも含めてすべての依存を定義する手間を省くことができる場合があります。
private enum RandomNumberClientKey: DependencyKey {
static let liveValue: any RandomNumberClientProtocol = RandomNumberClient()
static let testValue: any RandomNumberClientProtocol = unimplemented()
static let previewValue: any RandomNumberClientProtocol = RandomNumberClientMock(fetchResult: .success(42))
}
メリット
swift-dependencies を利用するメリットを紹介していきます。特定の手法のメリットを挙げる際は比較対象を明示したいですが、 iOS 開発における DI の手法は
- コンストラクタインジェクション
- バスタードインジェクション
- 型パラメータインジェクション
- Swinject などの動的 DI ライブラリの利用
- needle などの静的 DI ライブラリの利用
など多岐にわたるため整理することが難しく、雑多な形で列挙することにします。そのため、読んだ方が、このメリットは今自分が使っている方法より良い / このメリットは大したことない・あまり意味がない、など適宜読み取っていただけると幸いです。挙げていくメリット1つ1つを見ると他の DI 手法でも実現できるというものが多いのですが、 swift-dependencies はこれらのメリットをすべて持ち、デメリットが少ないのがよいところだと思っています。
また、この記事は swift-dependencies を紹介する記事なので swift-dependencies の視点からメリットを挙げていますが、当然他の DI 手法にもそれぞれメリットがあります。
依存の登録が簡単
まず、依存を注入できるようにするための準備は 依存の登録: DependencyKey
と DependencyValues
の処理を書くだけでよく、これは他の DI ライブラリと比較しても簡単な方なのではないかと思います。
依存の利用が簡単かつ柔軟
@Dependency
プロパティラッパーを書くだけで依存が利用できるようになるため、依存の利用も簡単であると言えると感じています。コンストラクタインジェクションや型パラメータインジェクションを利用している場合は、例えば RandomNumberViewModel
に RandomNumberClient
を依存として追加したい場合、 RandomNumberViewModel
を生成する側のコードにも手を加えなければいけないと思います。これに対して swift-dependencies では依存を利用する箇所に @Dependency
を追加するだけで大丈夫です。
また、 @Dependency
はプロパティラッパーであることから、コンストラクタインジェクションが効かない自由関数や static メソッド、 extension メソッドへの依存注入にも柔軟に利用することができます。
func randomNumberString() async throws -> String {
@Dependency(\.randomNumberClient) var randomNumberClient
return try await String(randomNumberClient.fetch())
}
enum SomeEnum {
static func randomNumberString() async throws -> String {
@Dependency(\.randomNumberClient) var randomNumberClient
return try await String(randomNumberClient.fetch())
}
}
extension String {
func appendingRandomNumber() async throws -> Self {
@Dependency(\.randomNumberClient) var randomNumberClient
return try await Self(randomNumberClient.fetch())
}
}
これらの処理をテストする際の依存の差し替えも、以下のように withDependencies
を利用するだけなので簡単です。このように、テスト容易性を担保しつつ簡単に依存が注入できるのはうれしい点です。
func test_appendingRandomNumber() async throws {
let actual = try await withDependencies {
$0.randomNumberClient = RandomNumberClientMock(fetchResult: .success(42))
} operation: {
try await "Hello".appendingRandomNumber()
}
XCTAssertEqual(actual, "Hello42")
}
依存の利用が安全
上記のように依存の利用が簡単であるにも関わらず、同時に安全であることにも触れておきたいと思います。実行時に依存が解決される動的 DI ライブラリでは、実行して初めて依存が適切に設定されていないことがわかったり、そもそも設定されていなくてアプリがクラッシュしたりといったことがあり得る点がネックになると思います。これに対して、 swift-dependencies には依存に対して liveValue
が存在することが型レベルで保証されているため、このような心配がありません。当然、 liveValue
に unimplemented
が指定されている場合などは実行時にクラッシュしますが、普通に開発している限りは実行時に依存が適切に設定されていることがわかっているため、どこからでも安心して依存を利用することができます。
依存の差し替えが細かくできる
withDependencies
がどこでも呼び出せるため、依存の差し替えを細かくかつ柔軟に行えます。例えば、 RandomNumberViewModel
にて、1回目のリクエストが失敗してエラーメッセージが表示された後に2回目のリクエストが成功した際、取得した数値が表示されているだけではなくエラーメッセージが消えていることもテストしたいとします。
swift-dependencies を利用していない状況でこのテストを書くためには、モックが1回目の呼び出しに対しては失敗、2回目の呼び出しに対しては成功してくれる必要があります。例えば、呼び出し回数を内部的に記録しておき、それに対して結果を出し分ける以下のようなモックを書くことで実現できます。
public class RandomNumberClientMock2: RandomNumberClientProtocol {
struct NoMockResultError: Error {}
private let fetchResults: [Result<Int, Error>]
public init(fetchResults: [Result<Int, Error>]) {
self.fetchResults = fetchResults
}
var fetchCalledCount: Int = 0
public func fetch() async throws -> Int {
defer { fetchCalledCount += 1 }
guard fetchCalledCount < fetchResults.count else {
XCTFail()
throw NoMockResultError()
}
return try fetchResults[fetchCalledCount].get()
}
}
このモックを以下のように設定することで、1回目のリクエストに対してはエラー、2回目に対しては成功を返してくれるようになります。
let viewModel = withDependencies {
$0.randomNumberClient = RandomNumberClientMock2(
fetchResults: [.failure(APIError()), .success(42)]
)
} operation: {
RandomNumberViewModel()
}
このアプローチが悪いと思っているわけではなく、むしろこれまで自分はこの方法をとってきましたが、モックに複雑なロジックが入ってくることでモックの作成が面倒になったり、モックに不手際があるせいでテストが失敗する可能性が増加するという問題はあると思います。また、ほとんどの場合はモックは1回しか結果を返さないにも関わらず、モックのインターフェースや実装が複数の結果を返せるような汎用性を持っている点も少し気になります。
これに対して、 swift-dependencies の withDpendencies
を fetch
の実行時にも上書きすることができるので、単純なモックのまま目的を達することができます。
func test_一度数値の取得に失敗した状態から数値の取得に成功するとnumberStringに文字列が入りつつerrorStringはnilに戻る() async throws {
struct APIError: Error {}
let viewModel = RandomNumberViewModel()
await withDependencies {
$0.randomNumberClient = RandomNumberClientMock(fetchResult: .failure(APIError()))
} operation: {
await viewModel.fetch()
}
XCTAssertEqual(viewModel.numberString, "0")
XCTAssertEqual(viewModel.errorString, "Something went wrong")
await withDependencies {
$0.randomNumberClient = RandomNumberClientMock(fetchResult: .success(42))
} operation: {
await viewModel.fetch()
}
XCTAssertEqual(viewModel.numberString, "42")
XCTAssertEqual(viewModel.errorString, nil)
}
この方法では withDependencies
を呼び出しのたびに書かなければいけないというデメリットがあるものの、モックがシンプルに保たれる点やモックの上書きが直感的に思えるという点から、個人的にはこの方法の方が優れていると感じています。実際にどちらの方法がよいかに関しては意見が分かれるかもしれませんが、メソッド呼び出しごとに依存をコントロールすることが容易に行えること自体は swift-dependencies のメリットだと思います。
深い依存の差し替えができる
この記事の例では RandomNumberViewModel
が RandomNumberClient
に依存しているだけでしたが、実際のアプリではもっと依存の層が複雑になることが考えられます。例えば RandomNumberViewModel
-> FetchRandomNumberUseCase
-> RandomNumberRepository
-> RandomNumberClient
という依存関係になっているとします。
このような構成にて DI ライブラリを使っていない場合、 RandomNumberViewModel
のテストにて FetchRandomNumberUseCase
/ RandomNumberRepository
は本物を使って RandomNumberClient
をモックに差し替えたいとすると、 RandomNumberViewModel
から見て非常に深い依存である RandomNumberClient
を差し替えるのはかなり面倒になると思います。
let viewModel = RandomNumberViewModel(
fetchRandomNumberUseCase: .init(
randomNumberRepository: .init(
randomNumberClient: RandomNumberClientMock())
)
)
)
これに対して、すべての依存を swift-dependencies に対応させていれば、以下のように深い依存であっても、直接の依存とまったく同じように差し替えができます。これにより、途中のレイヤーは本物を使いつつ任意のレイヤーのみモックすることが簡単にできます。
let viewModel = withDependencies {
$0.randomNumberClient = RandomNumberClientMock()
} operation: {
RandomNumberViewModel()
}
もちろん、以下のように直接の依存である FetchRandomNumberUseCase
をモックに差し替えることもできるので、どういうテストをしたいかによって、柔軟にモックの方法を変えることができます。
let viewModel = withDependencies {
$0.fetchRandomNumberUseCase = FetchRandomNumberUseCaseMock()
} operation: {
RandomNumberViewModel()
}
よく使う依存が組み込みで用意されている
この記事では自分で定義した依存である RandomNumberClient
を利用する例を考えましたが、 swift-dependencies に組み込まれている依存もあり、それらは何らかの用意をせずとも @Dependency
でいきなり利用することができます。
組み込みの依存の例として uuid
を紹介します。例えば以下のような struct の factory メソッドの doubling
を考えます。
struct Model: Equatable {
let uuid: UUID
let number: Int
static func doubling(number: Int) -> Self {
.init(uuid: UUID(), number: number * 2)
}
}
UUID()
は呼び出しごとに値が変わるため、 Model.doubling
のテストが難しくなっていることがわかると思います。
ffunc test_doubling() throws {
let actual = Model.doubling(number: 10)
XCTAssertEqual(
actual,
.init(uuid: UUID(), number: 20) // ❗️ uuid が不一致
)
}
この状況を解決するために、 UUID
を生成する方法を提供してくれる uuid
を利用することにします。 uuid
は swift-dependencies 側で用意してくれているので、何も準備せずに利用できます。
struct Model: Equatable {
// ...
static func doubling(number: Int) -> Self {
@Dependency(\.uuid) var uuid
return .init(uuid: uuid(), number: number * 2)
}
}
これにより、テスト時に UUID
生成ロジックを好きに差し替えることができるようになります。 UUID
生成の戦略として "00000000-0000-0000-0000-000000000000"
から始まり、呼ばれるたびに "00000000-0000-0000-0000-000000000001"
"00000000-0000-0000-0000-000000000002"
とインクリメントしていく incrementing
も合わせて提供されているので、以下のようにテストを書き換えることで Model.doubling
のテストができるようになりました。
func test_doubling() throws {
let actual = withDependencies {
$0.uuid = .incrementing
} operation: {
Model.doubling(number: 10)
}
XCTAssertEqual(
actual,
.init(uuid: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, number: 20)
)
}
uuid
の他に、 Date.now
を指定できる @Dependency(\.date.now)
も利用頻度が高いと思います。他にも、 continuousClock
や timeZone
、 openURL
など、さまざまな処理をコントロール可能にするための依存がデフォルトで提供されています。
また、 swift-dependencies の拡張という扱いの swift-dependencies-additions というライブラリではさらに色々な依存がデフォルトで提供されています。例えば、 UIApplication.shared
を抽象化した @Dependency(\.application)
や、 CFBundleVersion
や CFBundleShortVersionString
を含めて Info.plist
に含まれる情報を抽象化した @Dependency(\.bundleInfo)
などがあり、とても便利です。
予期しない副作用の実行をテスト時に検知できる
swift-dependencies の推奨する方法でテストを書くと、予期しない依存の呼び出しによる副作用をテストで検知できる可能性が高いです。 RandomNumberClient
は API から値を取得するだけで、アプリの内部や外部の状態を変化させる副作用は持たないため予期しない呼び出しが走っても問題にならない場合が多いと思いますが、依存の性質によっては予期しない呼び出しが問題になることもあります。
例えば、 RandomNumberScreen
にログアウト機能を追加するとします。ログアウト機能のロジックを担う依存を以下のように追加します。
protocol AuthenticationServiceProtocol {
func logOut() async
}
enum AuthenticationServiceKey: DependencyKey {
static let liveValue: any AuthenticationServiceProtocol = AuthenticationService()
static let testValue: any AuthenticationServiceProtocol = unimplemented()
}
extension DependencyValues {
var authenticationService: AuthenticationServiceProtocol {
get { self[AuthenticationServiceKey.self] }
set { self[AuthenticationServiceKey.self] = newValue }
}
}
この依存の呼び出しを RandomNumberViewModel
に追加します。
final class RandomNumberViewModel: ObservableObject {
// ...
@Dependency(\.authenticationService) private var authenticationService
func fetch() async {
// ...
}
func logOutButtonTapped() async {
await authenticationService.logOut()
}
}
ログアウト機能に関してはこれで実装できましたが、ここでうっかりが発生して fetch
の呼び出しの最後にもログアウト処理を誤ってつけてしまったとします。
final class RandomNumberViewModel: ObservableObject {
// ...
@Dependency(\.authenticationService) private var authenticationService
func fetch() async {
// ...
// ❗️ 意図しない場所で logOut 処理を呼び出してしまっている
await authenticationService.logOut()
}
func logOutButtonTapped() async {
await authenticationService.logOut()
}
}
このミスは既存の fetch
のテストで検知できるでしょうか? DI の方法やテスト時のモックの方法によって検知できることもあれば、できないこともあると思います。しかし、依存がすべて swift-dependencies の推奨する方法で管理されていれば、 fetch
のテストが存在しさえすれば必ず検知することができます。 fetch
のテストで authenticationService
が withDependencies
で上書きされていないためテスト中は authenticationService
が testValue
である unimplmented
になっており、呼び出された時点でテストが落ちるためです。
func test_数値の取得に成功するとnumberStringに対応する文字列が入る() async throws {
let viewModel = withDependencies {
$0.randomNumberClient = RandomNumberClientMock(fetchResult: .success(42))
// $0.authenticationService を上書きしていない
} operation: {
RandomNumberViewModel()
}
await viewModel.fetch() // ❗️ Fatal error: unimplemented()
XCTAssertEqual(viewModel.numberString, "42")
XCTAssertEqual(viewModel.errorString, nil)
}
今回の例は非常に極端で、このような単純な処理で致命的なミスが発生することは考えづらいですが、より複雑なロジックを持つアプリではなんらかの修正によって想定していない副作用が発生することが十分あり得ます。これが常にテストにて検知できる状態になっていることで、開発をより安全に進めることができます。
注意点
最後に、swift-dependencies を利用していて感じた注意点を挙げておきます。アプリ実行時の依存解決ではほとんどの場合単純に liveValue
が使われるだけなので、あまり困ることはありません。テスト時に withDependencies
で依存を上書きしようとする際に知っておくべき点を以下に2つ挙げます。
withDependencies により依存が上書きされる範囲を理解しておく
ここまで withDependencies
で依存を上書きすることで、 @Dependency
で利用されるインスタンスを差し替えられることを述べました。この依存の上書きがどの範囲まで及ぶのかを認識しておく必要があります。例えば、以下のコードでは viewModel
の初期化時に withDependencies
で依存の randomNumberClient
を上書きしていますが、 withDependencies
の外で viewModel
の fetch
を読んでもちゃんと指定した依存が使われるのか、使われるとしたらなぜかを理解しておく必要があるということです。
let viewModel = withDependencies {
$0.randomNumberClient = RandomNumberClientMock(fetchResult: .success(42))
} operation: {
RandomNumberViewModel()
}
// ^ の withDependencies で DI した RandomNumberClientMock が利用される
await viewModel.fetch()
swift-dependencies のコード を読みつつ動作検証をした結果 withDependencies
で上書きした依存が @Dependency
にて使われるためには、 withDependencies
の第二引数のクロージャの中で
a. @Dependency
の init
が呼ばれる
b. @Dependency
の wrappedValue
にアクセスされる
のどちらかが必要で、 a / b の条件をそれぞれ満たす withDependencies
があった場合は b の方が優先されるようになっていると思います(間違っていたら教えてください)。以下の例ではすべて withDependencies
で上書きした randomNumberClient
が使われます。
// a が満たされるケース
let viewModel = withDependencies {
$0.randomNumberClient = RandomNumberClientMock(fetchResult: .success(1))
} operation: {
RandomNumberViewModel()
}
await viewModel.fetch() // 1 が取得される
// b が満たされるケース
let viewModel = RandomNumberViewModel()
await withDependencies {
$0.randomNumberClient = RandomNumberClientMock(fetchResult: .success(2))
} operation: {
await viewModel.fetch() // 2 が取得される
}
// a と b 両方が満たされるケース
await withDependencies {
$0.randomNumberClient = RandomNumberClientMock(fetchResult: .success(3))
} operation: {
let viewModel = RandomNumberViewModel()
await viewModel.fetch() // 3 が取得される
}
// a と b 両方が別々に満たされ、 b が優先されるケース
let viewModel = withDependencies {
$0.randomNumberClient = RandomNumberClientMock(fetchResult: .success(4))
} operation: {
RandomNumberViewModel()
}
await withDependencies {
$0.randomNumberClient = RandomNumberClientMock(fetchResult: .success(5))
} operation: {
await viewModel.fetch() // 5 が取得される。4 ではないことに注意
}
逆に、 withDependencies
で行う依存の上書きが無効になるパターンも挙げてみます。 fetch
とロジックは同じだが randomNumberClient
の依存をメソッド内で取得する fetch2
を追加します。
final class RandomNumberViewModel: ObservableObject {
// ...
func fetch2() async {
@Dependency(\.randomNumberClient) var randomNumberClient
do {
let number = try await randomNumberClient.fetch()
numberString = String(number)
errorString = nil
} catch {
errorString = "Something went wrong"
}
}
}
この状態で、以下のように RandomNumberViewModel
の初期化時に withDependencies
を適用すると、 a と b どちらの条件も満たさないので fetch2
実行時には testValue
が使われてしまいます。
// a も b も満たされないケース
let viewModel = withDependencies {
$0.randomNumberClient = RandomNumberClientMock(fetchResult: .success(1))
} operation: {
RandomNumberViewModel()
}
await viewModel.fetch2() // ❗️ testValue が使われてしまう
fetch2
にて依存を差し替えるには fetch2
の呼び出しに対して withDependencies
を適用する必要があります。
// a と b 両方が満たされるケース
let viewModel = RandomNumberViewModel()
await withDependencies {
$0.randomNumberClient = RandomNumberClientMock(fetchResult: .success(2))
} operation: {
await viewModel.fetch2() // 2 が取得される
}
escaping closure で withDependencies の依存が途切れる
withDependencies
による依存の上書きについてもう一点注意点があります。依存の差し替えが有効なためには withDependenceis
の第二引数の処理から @Dependency
まで task local values が継承されていることが必要です。つまり、処理の途中で @escaping
closure が挟まった場合、 withDependencies
で上書きした依存が使われなくなってしまう可能性があります。
困ることが多そうな例だと、 Task.detached
や DispatchQueue.main.async
の @escaping
クロージャで処理を囲むとそこで task local values が途切れるため、 withDependencies
で入れた依存も途切れてしまいます。
let viewModel = RandomNumberViewModel()
await withDependencies {
$0.randomNumberClient = RandomNumberClientMock(fetchResult: .success(2))
} operation: {
Task.detached {
await viewModel.fetch() // ❗️ testValue が使われてしまう
}
DispatchQueue.main.async {
await viewModel.fetch() // ❗️ testValue が使われてしまう
}
}
とくに、 Swift Concurrency 対応前のアプリでは DispatchQueue.main.async
があちこちで使われていることが多いと思うので、注意が必要です。
@escaping
closure であっても task local values が引き継がれる例外として、 Task.init
と Task Groups の addTask
が挙げられます。そのため、以下の例では withDependencies
で上書きした依存が有効です。
let viewModel = RandomNumberViewModel()
await withDependencies {
$0.randomNumberClient = RandomNumberClientMock(fetchResult: .success(2))
} operation: {
Task {
await viewModel.fetch() // 2 が取得される
}
}
どうしても Task.detached
や DispatchQueue.main.async
、他の @escaping
なクロージャを使わなければいけない場面では withEscapedDependencies
という明示的に依存を引き継ぐ関数を使うことができます。
let viewModel = RandomNumberViewModel()
await withDependencies {
$0.randomNumberClient = RandomNumberClientMock(fetchResult: .success(2))
} operation: {
withEscapedDependencies { dependencies in
Task.detached {
await dependencies.yield {
// このクロージャの中では withEscapedDependencies を呼んだ時点での依存が有効になる
await viewModel.fetch() // 2 が取得される
}
}
}
}
しかし、毎回このような対応を入れるのは大変なので、 swift-dependencies を使う上ではアプリが Swift Concurrency に対応していて非同期処理に DispatchQueue
を使っている箇所が少なく、かつ Task.detached
ではなく Task.init
を利用している方がスムーズでしょう。
-
swift-dependencies では依存の抽象化のために protocol よりも struct + closure を使うことが推奨されています。個人的にも struct + closure の形式で依存を定義するメリットが大きいと思っていますが、この記事では swift-dependencies の紹介に集中するために、現状の iOS 開発においてより一般的と思われる protocol による抽象化を利用します。 ref: https://pointfreeco.github.io/swift-dependencies/main/documentation/dependencies/designingdependencies ↩︎
-
ここでは
withDependencies
で上書きした依存が@StateObject
に反映されるようにするために、無理やり@StateObject
の初期化タイミングとそのwrappedValue
であるRandomNumberViewModel
の初期化タイミングを合わせています。この方法は https://qiita.com/fuziki/items/d9e231f5748743071a2c に述べられているような問題がある認識ですが、プレビューなら気にしなくていいと思っています。 ↩︎
Discussion