ottijp blog

SwiftUIで適用箇所(テストとプレビュー)によってDIの方法を変える

  • 2024-04-04

環境

概要

DI(Dependency Injection: 依存性注入)したい場合,自前で行う(コンストラクタインジェクション等)かDIライブラリを使うことになります.

SwiftUIでMVVMアーキテクチャを構成する際,テストとプレビューを考慮すると,どちらか1つの方法でDIすると私の期待する作りが実現できなかったので,方法を組み合わせてみました.サンプルアプリケーションで説明します.

ViewModelのテストにはDIライブラリ(swift-dependencies)を,プレビューにはコンストラクタインジェクションを利用しています.

サンプルアプリケーション

httpbin.orgにアクセスし,応答を表示するアプリケーションをサンプルとして作成しました.

DIしない場合の実装

DIしない場合の例です. アプリケーションは以下のような構成です.

no di

完全なコードはこちらです.

ContentView
struct ContentView: View {
  @StateObject var model = ViewModel()

  var body: some View {
    VStack {
      Button(action: model.load) {
        Text("Load")
      }
      Text(model.message)
    }
    .padding()
  }
}
ViewModel
class ViewModel: ObservableObject {
  @Published var message = ""

  func load() {
    Task {
      let client = ApiClient()
      let message = try await client.getMessage()
      await MainActor.run {
        self.message = message
      }
    }
  }
}
ApiClient
class ApiClient {
  func getMessage() async throws -> String {
    let url = URL(string: "https://httpbin.org/uuid")!
    let data = try! await URLSession.shared.data(from: url).0
    return String(data: data, encoding: .utf8)!
  }
}

エラー処理は入れていません.

適用箇所によるDIのやり方の違い

ViewModelのテスト

ViewModelをテストするために,ApiClientのテストダブルをインジェクトしたいです. これはswift-dependenciesを使い,実行時とテスト時のインスタンスを切り替えることで実現します.

DIライブラリを使うのは,依存性の知識がコードベース中に散らばらないようにしたいからです.

ContentViewのプレビュー

プレビュー用に@StateObjectで保持しているViewModelをインジェクトしたいです. これはジェネリクスとコンストラクタインジェクションで実現します.

はじめはswift-dependenciesでインジェクトしようと試みましたが,どうしてもうまくいきませんでした.私のやり方が間違っているのかもしれませんが,swift-dependenciesで解決したViewModelプロトコル準拠のインスタンスを@StateObjectにセットしようと色々試してみましたが,以下のようにエラーが発生してうまくいきませんでした.

NG1
struct ContentView<VM: ViewModel>: View {
  @StateObject @Dependency(\.viewModel) var model: VM // Generic struct 'StateObject' requires that 'Dependency<VM>' conform to 'ObservableObject'

  // 省略
}
NG2
struct ContentView: View {
  @StateObject @Dependency(\.viewModel) var model: any ViewModel // Generic struct 'StateObject' requires that 'Dependency<any ViewModel>' conform to 'ObservableObject'

  // 省略
}
NG3
struct ContentView<VM: ViewModel>: View {
  @StateObject var model: VM

  init() {
    @Dependency(\.viewModel) var model
    _model = StateObject(wrappedValue: model) // Cannot assign value of type 'StateObject<any ViewModel>' to type 'Dependency<any ViewModel>' // Type 'any ViewModel' cannot conform to 'ObservableObject'
  }

  // 省略
}

DIする場合の実装

「適用箇所によるDIのやり方の違い」に書いたようにDIする場合の例です. アプリケーションは以下のような構成です.

with di

完全なコードはこちらです.

ViewModelのテスト

DIコンテナでテスト時のApiClientを切り替えられるようにプロトコル化し,アプリケーション実行時のクラスはDefaultApiClientとして定義しました.

ApiClientのプロトコル化
protocol ApiClient {
  func getMessage() async throws -> String
}

class DefaultApiClient: ApiClient {
  func getMessage() async throws -> String {
    let url = URL(string: "https://httpbin.org/uuid")!
    let data = try! await URLSession.shared.data(from: url).0
    return String(data: data, encoding: .utf8)!
  }
}

これをDIコンテナに登録します.

DIコンテナへの登録
fileprivate enum ApiClientKey: DependencyKey {
  static let liveValue: any ApiClient = DefaultApiClient()
}

extension DependencyValues {
  var apiClient: any ApiClient {
    get { self[ApiClientKey.self] }
    set { self[ApiClientKey.self] = newValue }
  }
}

テスト時はテストモジュールで定義するApiClientMockで差し替えるように,withDependencies関数を利用します.

テスト
fileprivate class ApiClientMock: ApiClient {
  var response = ""

  func getMessage() async throws -> String {
    response
  }
}

final class StateObjectInjectionLearningTests: XCTestCase {
  private let apiClientMock = ApiClientMock()

  func testViewModel() async throws {
    // setup
    apiClientMock.response = "test response"
    let viewModel = DefaultViewModel()

    // execute
    withDependencies {
      $0.apiClient = apiClientMock
    } operation: {
      viewModel.load()
    }
    try await Task.sleep(nanoseconds: 10_000_000)

    // test
    XCTAssertEqual("test response", viewModel.message)
  }
}

ContentViewのプレビュー

プレビュー時にViewModelを切り替えられるようにプロトコル化し,アプリケーション実行時のクラスはDefaultViewModelとして定義しました.

ViewModelのプロトコル化
protocol ViewModel: ObservableObject {
  var message: String { get }
  func load()
}

class DefaultViewModel: ViewModel {
  @Published var message = ""

  func load() {
    Task {
      @Dependency(\.apiClient) var client
      let message = try await client.getMessage()
      await MainActor.run {
        self.message = message
      }
    }
  }
}

プレビュー時はプレビューブロックで定義するPreviewViewModelで差し替えます.

プレビューの作成
#Preview("UUID") {
  class PreviewViewModel: ViewModel {
    var message = "{\n  \"uuid\": \"5fdead26-07b7-4441-8e46-e0bed1fd7429\"\n}"
    func load() {}
  }
  return ContentView(model: PreviewViewModel())
}

#Preview("Unknown") {
  class PreviewViewModel: ViewModel {
    var message = "Unknown message"
    func load() {}
  }
  return ContentView(model: PreviewViewModel())
}

元のViewModelクラスをそのまま使い,ViewModelが依存しているApiClientのみをDIコンテナで入れ替える方法もあると思いますが,私の場合はビューのステートごとにプレビューを作りたいためプロトコル化しています.つまり,次のようなことを意図しています.

  • ローディング表示などの特定のタイミングにおけるステートをプレビューするためには,ApiClientのインジェクトではタイミングをコントロールするのが困難なことがあるので,ViewModelのインジェクトでコントロールしたい.
  • プレビュー時に,ContentViewが直接依存していないApiClientを意識したくない.
  • ViewModelの動作はプレビューではなく自動テストで担保する.

ottijp
都内でアプリケーションエンジニアをしています
© 2024, ottijp