📦

swift-dependencies の使い方・メリット・注意点

2023/04/20に公開

概要

iOS 開発において DI といえばこれというような方法やライブラリは現状ないと思います。そんな中で、 pointfree が開発している swift-dependencies は今後のスタンダードになりうるような良いライブラリだと感じたので紹介したいと思います。 swift-dependencies はもともと swift-composable-architecture (TCA) から切り出されたライブラリですが、 TCA を利用していないアプリでも使うことができます。

この記事では、 swift-dependencies の内部実装などには踏み込まず、

  • 使い方
  • メリット
  • 利用にあたって注意すべき点

についてまとめようと思います。

リンク

使い方

使い方を見ていく上での例として、ボタンをタップされるたびにランダムな数値を表示する 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 に注入することにします。

依存の登録: DependencyKeyDependencyValues

まず、 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 の作成 にて RandomNumberClientDependencyValues に保存したり取得したりするために使う Key を作成します。 Key は DependencyKey というプロトコルに準拠している必要があり、 liveValue という static プロパティの実装が要求されます。この liveValue がアプリ実行時に依存として利用される値になるので、 RandomNumberClientProtocol の実装である RandomNumberClient のインスタンスを代入しておきます。
2. DependencyValues の拡張 では、その Key を使って実際に RandomNumberClient 保存・取得するための処理を追加しています。

この辺りの作業は依存を登録する際の決まりきった作業で、 swift-dependencies を使い始める段階ではあまり深く理解していなくても大丈夫だと思っています。

依存の取得: @Dependency

続いて、上記で依存として登録した RandomNumberClientRandomNumberViewModel から使っていきます。依存を取得するためには、 @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 を拡張したことにより利用可能になっています。アプリを実行すると @DependencywrappedValue には、 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 の上書きを行い、第二引数にはその環境下で行いたい処理を書くイメージです。今回の場合は、第一引数のクロージャで DependencyValuesRandomNumberClientKey に対してアプリ実行時の RandomNumberClient ではなく RandomNumberClientMock を取り出すように上書きをし、第二引数で RandomNumberViewModel を初期化することで、得られた RandomNumberViewModelRandomNumberClientMock を使うようになります。

上記を利用して、以下のようなテストを書くことができます。

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 で明示的に依存が上書きされない場合に)利用される依存を指定しますが、これに加えてテスト時 / プレビュー時に利用される testValuepreviewValue を定義することもできます。 testValuepreviewValue が指定されない場合は liveValue にフォールバックされるので、テストやプレビューで withDependencies を利用しない場合は liveValue が使われることになります。
テストやプレビューでは結局 withDependencies で上書きするんだから、 testValuepreviewValue はあえて定義する必要がないという考えもあるでしょう。ただし、その場合はテストやプレビューから意図せず本物の依存である liveValue が使われることで本物の API にリクエストが飛んだり本物の DB にアクセスされるリスクがあることが認識しておく必要があります。デフォルトの値として testValuepreviewValue を定義しておくことで、そのようなリスクを避けることができます。

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 の手法は

など多岐にわたるため整理することが難しく、雑多な形で列挙することにします。そのため、読んだ方が、このメリットは今自分が使っている方法より良い / このメリットは大したことない・あまり意味がない、など適宜読み取っていただけると幸いです。挙げていくメリット1つ1つを見ると他の DI 手法でも実現できるというものが多いのですが、 swift-dependencies はこれらのメリットをすべて持ち、デメリットが少ないのがよいところだと思っています。

また、この記事は swift-dependencies を紹介する記事なので swift-dependencies の視点からメリットを挙げていますが、当然他の DI 手法にもそれぞれメリットがあります。

依存の登録が簡単

まず、依存を注入できるようにするための準備は 依存の登録: DependencyKeyDependencyValues の処理を書くだけでよく、これは他の DI ライブラリと比較しても簡単な方なのではないかと思います。

依存の利用が簡単かつ柔軟

@Dependency プロパティラッパーを書くだけで依存が利用できるようになるため、依存の利用も簡単であると言えると感じています。コンストラクタインジェクションや型パラメータインジェクションを利用している場合は、例えば RandomNumberViewModelRandomNumberClient を依存として追加したい場合、 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 が存在することが型レベルで保証されているため、このような心配がありません。当然、 liveValueunimplemented が指定されている場合などは実行時にクラッシュしますが、普通に開発している限りは実行時に依存が適切に設定されていることがわかっているため、どこからでも安心して依存を利用することができます。

依存の差し替えが細かくできる

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 の withDpendenciesfetch の実行時にも上書きすることができるので、単純なモックのまま目的を達することができます。

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 のメリットだと思います。

深い依存の差し替えができる

この記事の例では RandomNumberViewModelRandomNumberClient に依存しているだけでしたが、実際のアプリではもっと依存の層が複雑になることが考えられます。例えば 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) も利用頻度が高いと思います。他にも、 continuousClocktimeZoneopenURL など、さまざまな処理をコントロール可能にするための依存がデフォルトで提供されています。

また、 swift-dependencies の拡張という扱いの swift-dependencies-additions というライブラリではさらに色々な依存がデフォルトで提供されています。例えば、 UIApplication.shared を抽象化した @Dependency(\.application) や、 CFBundleVersionCFBundleShortVersionString を含めて 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 のテストで authenticationServicewithDependencies で上書きされていないためテスト中は authenticationServicetestValue である 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 の外で viewModelfetch を読んでもちゃんと指定した依存が使われるのか、使われるとしたらなぜかを理解しておく必要があるということです。

let viewModel = withDependencies {
    $0.randomNumberClient = RandomNumberClientMock(fetchResult: .success(42))
} operation: {
    RandomNumberViewModel()
}

// ^ の withDependencies で DI した RandomNumberClientMock が利用される
await viewModel.fetch()

swift-dependencies のコード を読みつつ動作検証をした結果 withDependencies で上書きした依存が @Dependency にて使われるためには、 withDependencies の第二引数のクロージャの中で

a. @Dependencyinit が呼ばれる
b. @DependencywrappedValue にアクセスされる

のどちらかが必要で、 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.detachedDispatchQueue.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 であっても tasl 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.detachedDispatchQueue.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 を利用している方がスムーズでしょう。

脚注
  1. swift-dependencies では依存の抽象化のために protocol よりも struct + closure を使うことが推奨されています。個人的にも struct + closure の形式で依存を定義するメリットが大きいと思っていますが、この記事では swift-dependencies の紹介に集中するために、現状の iOS 開発においてより一般的と思われる protocol による抽象化を利用します。 ref: https://pointfreeco.github.io/swift-dependencies/main/documentation/dependencies/designingdependencies ↩︎

  2. ここでは withDependencies で上書きした依存が @StateObject に反映されるようにするために、無理やり @StateObject の初期化タイミングとその wrappedValue である RandomNumberViewModel の初期化タイミングを合わせています。この方法は https://qiita.com/fuziki/items/d9e231f5748743071a2c に述べられているような問題がある認識ですが、プレビューなら気にしなくていいと思っています。 ↩︎

Discussion