SwiftUIで適用箇所(テストとプレビュー)によってDIの方法を変える
環境
- Xcode: 15.1
- swift: 5
- swift-dependencies: 1.2.2
概要
DI(Dependency Injection: 依存性注入)したい場合,自前で行う(コンストラクタインジェクション等)かDIライブラリを使うことになります.
SwiftUIでMVVMアーキテクチャを構成する際,テストとプレビューを考慮すると,どちらか1つの方法でDIすると私の期待する作りが実現できなかったので,方法を組み合わせてみました.サンプルアプリケーションで説明します.
ViewModelのテストにはDIライブラリ(swift-dependencies)を,プレビューにはコンストラクタインジェクションを利用しています.
サンプルアプリケーション
httpbin.orgにアクセスし,応答を表示するアプリケーションをサンプルとして作成しました.
DIしない場合の実装
DIしない場合の例です. アプリケーションは以下のような構成です.
完全なコードはこちらです.
struct ContentView: View {
@StateObject var model = ViewModel()
var body: some View {
VStack {
Button(action: model.load) {
Text("Load")
}
Text(model.message)
}
.padding()
}
}
class ViewModel: ObservableObject {
@Published var message = ""
func load() {
Task {
let client = ApiClient()
let message = try await client.getMessage()
await MainActor.run {
self.message = message
}
}
}
}
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
にセットしようと色々試してみましたが,以下のようにエラーが発生してうまくいきませんでした.
struct ContentView<VM: ViewModel>: View {
@StateObject @Dependency(\.viewModel) var model: VM // Generic struct 'StateObject' requires that 'Dependency<VM>' conform to 'ObservableObject'
// 省略
}
struct ContentView: View {
@StateObject @Dependency(\.viewModel) var model: any ViewModel // Generic struct 'StateObject' requires that 'Dependency<any ViewModel>' conform to 'ObservableObject'
// 省略
}
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する場合の例です. アプリケーションは以下のような構成です.
完全なコードはこちらです.
ViewModelのテスト
DIコンテナでテスト時のApiClient
を切り替えられるようにプロトコル化し,アプリケーション実行時のクラスはDefaultApiClient
として定義しました.
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コンテナに登録します.
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
として定義しました.
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
の動作はプレビューではなく自動テストで担保する.