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の動作はプレビューではなく自動テストで担保する.