SwiftUI 依赖注入:我如何在测试时替换网络层实现
SwiftUI 依赖注入:我如何在测试时替换网络层实现
上个月花了一周时间给项目加单元测试,发现 ViewModel 里的网络调用全是硬编码,根本没法 mock。
问题不在测试本身,而在架构设计时没考虑"可替换性"。这篇文章说说我是怎么把网络层改造成可测试的。
硬编码的反模式
最早的 ViewModel 大概是这样的:
final class ProfileViewModel: ObservableObject {
@Published var user: User?
@Published var isLoading = false
func loadUser() async {
isLoading = true
defer { isLoading = false }
do {
let data = try await URLSession.shared.data(from: URL(string: "https://api.example.com/user")!)
let user = try JSONDecoder().decode(User.self, from: data)
self.user = user
} catch {
print("Failed: \(error)")
}
}
}
这段代码的问题:网络请求和业务逻辑耦合,测试时无法替换 URLSession.shared。
Protocol 作为依赖抽象
第一步:定义协议抽象网络行为:
protocol APIClient {
func fetchUser() async throws -> User
func fetchArticles(page: Int) async throws -> [Article]
func updateProfile(_ profile: Profile) async throws -> User
}
extension APIClient where Self == URLSessionAPIClient {
static var live: URLSessionAPIClient { URLSessionAPIClient() }
}
struct URLSessionAPIClient: APIClient {
func fetchUser() async throws -> User {
let (data, _) = try await URLSession.shared.data(from: URL(string: "https://api.example.com/user")!)
return try JSONDecoder().decode(User.self, from: data)
}
func fetchArticles(page: Int) async throws -> [Article] {
let url = URL(string: "https://api.example.com/articles?page=\(page)")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([Article].self, from: data)
}
func updateProfile(_ profile: Profile) async throws -> User {
var request = URLRequest(url: URL(string: "https://api.example.com/profile")!)
request.httpMethod = "PUT"
request.httpBody = try JSONEncoder().encode(profile)
let (data, _) = try await URLSession.shared.data(for: request)
return try JSONDecoder().decode(User.self, from: data)
}
}
Mock 实现
enum MockError: Error {
case userNotFound
case networkError
}
struct MockAPIClient: APIClient {
var fetchUserResult: Result<User, Error> = .success(User.mock)
var fetchArticlesResult: Result<[Article], Error> = .success([.mock])
var updateProfileResult: Result<User, Error> = .success(User.mock)
func fetchUser() async throws -> User {
try fetchUserResult.get()
}
func fetchArticles(page: Int) async throws -> [Article] {
try fetchArticlesResult.get()
}
func updateProfile(_ profile: Profile) async throws -> User {
try updateProfileResult.get()
}
}
// 扩展 mock 数据
extension User {
static var mock: User {
User(id: "1", name: "Test User", email: "test@example.com")
}
}
注入方式一:初始化参数
@MainActor
final class ProfileViewModel: ObservableObject {
@Published var user: User?
@Published var isLoading = false
private let apiClient: APIClient
init(apiClient: APIClient = .live) {
self.apiClient = apiClient
}
func loadUser() async {
isLoading = true
defer { isLoading = false }
do {
user = try await apiClient.fetchUser()
} catch {
print("Failed: \(error)")
}
}
}
测试时:
@MainActor
func testLoadUserSuccess() async {
let mock = MockAPIClient()
let viewModel = ProfileViewModel(apiClient: mock)
await viewModel.loadUser()
XCTAssertNotNil(viewModel.user)
XCTAssertEqual(viewModel.user?.name, "Test User")
}
注入方式二:@Environment
初始化参数的方式在简单场景下够用,但如果依赖层级很深(比如 ViewModel 本身需要注入到多个 View 里),用 @Environment 更方便:
struct ContentView: View {
@StateObject private var profileVM = ProfileViewModel()
var body: some View {
ProfileView()
.environmentObject(profileVM)
}
}
struct ProfileView: View {
@EnvironmentObject var viewModel: ProfileViewModel
var body: some View {
if let user = viewModel.user {
Text(user.name)
} else {
ProgressView()
}
.task {
await viewModel.loadUser()
}
}
}
问题:@EnvironmentObject 依赖运行时注入,测试时需要额外设置 environment。
更好的做法:把协议本身做成 EnvironmentKey:
struct APIClientKey: EnvironmentKey {
static let defaultValue: APIClient = URLSessionAPIClient.live
}
extension EnvironmentValues {
var apiClient: APIClient {
get { self[APIClientKey.self] }
set { self[APIClientKey.self] = newValue }
}
}
View 里使用:
struct ArticleListView: View {
@Environment(\.apiClient) private var apiClient
@State private var articles: [Article] = []
var body: some View {
List(articles) { article in
Text(article.title)
}
.task {
articles = (try? await apiClient.fetchArticles(page: 0)) ?? []
}
}
}
测试时注入 mock:
struct ArticleListView_Previews: PreviewProvider {
static var previews: some View {
ArticleListView()
.environment(\.apiClient, MockAPIClient())
}
}
更深入的解耦:Repository 模式
如果业务逻辑变得更复杂,ViewModel 不应该直接依赖 APIClient,而是通过 Repository:
protocol UserRepository {
func getUser() async throws -> User
func updateUser(_ user: User) async throws -> User
}
final class UserRepositoryImpl: UserRepository {
private let apiClient: APIClient
init(apiClient: APIClient) {
self.apiClient = apiClient
}
func getUser() async throws -> User {
try await apiClient.fetchUser()
}
func updateUser(_ user: User) async throws -> User {
let profile = Profile(from: user)
return try await apiClient.updateProfile(profile)
}
}
ViewModel 依赖 Repository:
@MainActor
final class ProfileViewModel: ObservableObject {
@Published var user: User?
@Published var isLoading = false
private let repository: UserRepository
init(repository: UserRepository = UserRepositoryImpl()) {
self.repository = repository
}
func loadUser() async {
isLoading = true
defer { isLoading = false }
do {
user = try await repository.getUser()
} catch {
print("Failed: \(error)")
}
}
}
这样测试时可以用 MockUserRepository 完全替换掉数据层,而 mock repository 内部不需要调用任何网络代码。
多层依赖的 Environment 扩展
当依赖层级变深时(比如 App 里有多个 Repository、多个 Service),可以为每一层定义 EnvironmentKey:
struct UserRepositoryKey: EnvironmentKey {
static let defaultValue: UserRepository = UserRepositoryImpl()
}
struct ArticleRepositoryKey: EnvironmentKey {
static let defaultValue: ArticleRepository = ArticleRepositoryImpl()
}
extension EnvironmentValues {
var userRepository: UserRepository {
get { self[UserRepositoryKey.self] }
set { self[UserRepositoryKey.self] = newValue }
}
var articleRepository: ArticleRepository {
get { self[ArticleRepositoryKey.self] }
set { self[ArticleRepositoryKey.self] = newValue }
}
}
在 App 入口处设置默认值:
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.userRepository, UserRepositoryImpl())
.environment(\.articleRepository, ArticleRepositoryImpl())
}
}
}
异步初始化的问题
有个细节需要注意:Environment 的值在 View 初始化时就已经确定了,但有些依赖需要异步初始化:
// 错误:View 初始化时 environment 还没准备好
struct BadView: View {
@Environment(\.userRepository) private var repository
@State private var user: User?
init(userId: String) {
self.userId = userId
// 错误:repository 还没注入
}
}
解决方案:用 @StateObject + lazy initialization:
struct GoodView: View {
@Environment(\.userRepository) private var repository
@StateObject private var viewModel: ProfileViewModel
init(userId: String) {
_viewModel = StateObject(wrappedValue: ProfileViewModel(
userId: userId,
repository: UserRepositoryImpl() // 或者从 environment 获取
))
}
}
或者更优雅的方式:在 ViewModel 初始化时从 Environment 读取:
struct ViewModelFromEnvironment: View {
@StateObject private var viewModel = ViewModel()
@Environment(\.repository) private var repository
var body: some View {
Text("Hello")
.onAppear {
viewModel.setRepository(repository)
}
}
}
总结
SwiftUI 依赖注入的核心思路:
- 用 Protocol 抽象依赖:不依赖具体实现,只依赖行为接口
- 分层设计:APIClient → Repository → ViewModel,每层都可以独立替换
- Environment vs 初始化参数:简单依赖用初始化参数,跨组件共享用 Environment
- 为每一层定义 EnvironmentKey:形成完整的依赖链
- Mock 要简单:不要让 mock 代码比真实实现还复杂
这套模式做扎实了,单元测试的覆盖率可以从 20% 提高到 70%+,而且改代码时不用担心连锁反应。
