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 依赖注入的核心思路:

  1. 用 Protocol 抽象依赖:不依赖具体实现,只依赖行为接口
  2. 分层设计:APIClient → Repository → ViewModel,每层都可以独立替换
  3. Environment vs 初始化参数:简单依赖用初始化参数,跨组件共享用 Environment
  4. 为每一层定义 EnvironmentKey:形成完整的依赖链
  5. Mock 要简单:不要让 mock 代码比真实实现还复杂

这套模式做扎实了,单元测试的覆盖率可以从 20% 提高到 70%+,而且改代码时不用担心连锁反应。

最后更新 4/20/2026, 6:02:32 AM