在 iOS 业务开发过程经常面对网络请求,数据持久化这样带有副作用的操作。为了能够在测试中 mock 这些操作,通常的做法就是抽象一层 protocol 出来,然后编写不同的实现。

比如需要处理一个简陋的注册业务(示例省略了一点细节),需要用户输入信息后发送网络请求,成功后返回对应用户对象。

首先为网络请求定义一个 protocol:

protocol SignUpRepositoryProtocol: RepositoryProtocol {
    func handleSignUp(name: String, email: String, pwd: String) -> AnyPublisher<User, Error> 
}

对其进行实现:

struct SignUpRepository: SignUpRepositoryProtocol {
    func handleSignUp(name: String, email: String, pwd: String) -> AnyPublisher<User, Error> {
        client(.signUp(name, email, pwd))
            .map
            .decode
            .eraseToAnyPublisher()
    }
}

将其注入到 ViewModel 或是 Interactor 中(取决于你采取的架构是什么 :p ),并且调用对应方法:

class SignUpViewModel {
    enum State {
      case loading
      case success
      case failed
    }
    let repository: SignUpRepositoryPortocol
    var state: State = .loading
    init(repository: SignUpRepositoryPortocol) {
        self.repository = repository
    }

    func onSubmit(name: String, email: String, pwd: String) {
        repository.hanldeSignUp(name: name, email: email, pwd: pwd)
          .sink {[weak self] completion in 
            switch completion {
              case .failure: 
                self.?state = .failed
              case .finished: break
            }
          } receiveValue: {[weak self] result in 
              self?.state = .success
          }.store(in: &bag)
    }
}

由此如果需要测试对应的方法,只需要再创建一份 MockSignUpRepository 的实现即可,比如想要测试注册成功或失败场景下的处理:

struct MockSignUpRepository: SignUpRepositoryPortocol {
    let shouldSignUpSuccess: Bool
    
    func handleSignUp(name: String, email: String, pwd: String) -> AnyPublisher<User, Error> {
        if shouldSignUpSuccess {
            Just(User.mock)
                .mapError{ _ in SignUpError.someError }
                .eraseToAnyPublisher()
        } else {
            Fail(error: SignUpError.someError)
                .eraseToAnyPublisher() 
        }
    }
}

在编写测试的时候,传入 SignUpViewModel 的依赖替换成我们想要测试的 Mock 实现:

func shouldSignUpSuccessWhenXXX() {
    // Given
    let sut = SignUpViewModel(repository: MockSignUpSuccessRepository(shouldSignUpSuccess: true))
  
    // When
    sut.onSubmit(...) // 合法输入
  
    // Then
    XCTAssertEqual(sut.state, .success)
}

func shouldSignUpFailWhenXXX() {
    // Given
    let sut = SignUpViewModel(repository: MockSignUpSuccessRepository(shouldSignUpSuccess: false))
  
    // When
    sut.onSubmit(...) // 非法输入
  
    // Then
    XCTAssertEqual(sut.state, .failed)
}

这时候似乎一切都很美好,但现在再补充一些业务需求,如果需要返回不同的错误类型怎么办?比如用户名错误,那就需要额外的布尔值来表示;再比如邮箱错误,那又需要增加新的布尔值。而这还仅仅只是一个方法的几个分支逻辑处理。当出现较多的逻辑分支之后,如果我们实际的业务再发生变动需要重构,那还得去对 Mock 类也进行重构,同时还需要确保这些控制逻辑分支的布尔值也得到了正确的更新。

let sut = SignUpViewModel(
    repository: MockSignUpSuccessRepository(
        shouldSignUpSuccess: false, 
        shouldShowUsernameError: true,
        shouldShowEmailError: true,
        shouldUsernamePassValidation: true,
        shouldEmailPassValidation: true,
        // ...😱
   )
)

这时候就可以介绍另一种依赖注入方式了。首先定义一个 Repository 对象,这个对象就像之前的 Repository 一样,区别是网络请求通过一个属性来持有,同时会提供一个标记为 private 的默认实现:

struct Repository {  
    var handleSignUp = handleSignUp(name:, email:, pwd:)
}

private func handleSignUp(name: String, email: String, pwd: String) -> AnyPublisher<User, Error> {
    client(.signUp(name, email, pwd))
        .map
        .decode
        .eraseToAnyPublisher()
}

然后将这个 Repository 实例放到一个 Environment 对象中:

struct Environment {
    var repo = Repository()
}

同时替换 ViewModel 中之前对 Repository 的引用:

class SignUpViewModel {
    enum State {
      case loading
      case success
      case failed
    }
    // let repository: SignUpRepositoryPortocol
    let current: Environment // 👈
    var state: State = .loading
    
    init(current: Environment) {
        self.current = current // 👈
    }

    func onSubmit(name: String, email: String, pwd: String) {
        current.repo.hanldeSignUp(name, email, pwd) // 👈
          .sink { completion in 
            switch completion {
              case .failure: 
                self.state = .failed
              case .finished: break
            }
          } receiveValue: {[weak self] result in 
              self?.state = .success
          }.store(in: &bag)
    }
}

代码几乎和之前相同,但保持了更高的可替换性,怎么体现的呢?需要 mock 网络请求 时,可以给 Repository 创建一个 extension:

extension Repository {
    static let mock = Repository(
        handleSignUp: Just(User.mock)
                          .mapError{ _ in SignUpError.someError }
                          .eraseToAnyPublisher()
    )
}

然后在测试代码构建 ViewModel 的时候就可以将 Mock 传递进去:

func shouldSignUpSuccessWhenXXX() {
    // Given
    let sut = SignUpViewModel(current: Environment(repo: Repository.mock))
  
    // When
    sut.onSubmit(...) // 合法输入
  
    // Then
    XCTAssertEqual(sut.state, .success)
}

对于方法多分支的逻辑,则可以独立实现一份,而不是重新创建整个 MockRepository 类或者是用一些变量来控制分支逻辑:

func shouldSignUpFailWhenXXX() {
    // Given
    let sut = SignUpViewModel(
        current: Environment(
            repo: Repository(handleSignUp: 
                Fail(error: SignUpError.someError).eraseToAnyPublisher()
            )
        )
    )
  
    // When
    sut.onSubmit(...) // 非法输入
  
    // Then
    XCTAssertEqual(sut.state, .failed)
}

这种方式来注入依赖的优势在于:

  • 不需要像 protocol 那样写太多模版代码
  • mock 的逻辑分支很容易可以实现相互独立的版本
  • 依赖的副作用会更容易 mock,特别是系统类