很难写单元测试?快使用【依赖注入】、【控制反转】~

| Swift , iOS , 架构 , 测试 , XCTest

 

内容概览

  • 前言
  • 不易测试的 MVC 代码
  • 易测试的 MVC 代码
    • 依赖注入 和 控制反转
  • 总结

 

前言

 

如今,项目中的业务逻辑越来越复杂,代码量也疯狂飙升,某个 View Controller 可能会有几千行代码。这些项目的代码易读性非常差,维护难度也比较大,测试的难度更大!

如果需要进行人工测试,而且如果App中的运行时状态非常多,就会非常消耗测试人员。

有什么办法可以解决或者至少缓解一下这个问题吗?

目前来看,自动化测试是一个最优解。对于开发人员而言,单元测试是最常见的自动化测试,也是比较有效的测试方法。

然而,想写出易测试的代码需要掌握一定的知识和技巧。接下来,请和 Ficow 一起看看如何基于依赖注入和控制反转构建易测试的代码吧~

 

不易测试的 MVC 代码

 

由于 MVC 是最常用且最简单的App设计模式,本文的示例代码基于 MVC 模式构建,以降低理解的难度。

 

类似如下代码就是不易测试的 MVC 代码:

class MVCViewController: UIViewController {

    var userProfile: MVCUserProfile?
    private let indicator = UIActivityIndicatorView()

    // ViewController 负责接收 UI 交互事件,然后调用 Model 的方法处理数据,最终将返回结果展示到 View 上
    func saveNewName(_ name: String) {
        indicator.startAnimating()
        userProfile?.updateName(name) { [weak self] error in
            defer { self?.indicator.stopAnimating() }
            if let error = error {
                self?.showError(error)
                return
            }
            self?.showSuccess()
        }
    }

    func showSuccess() {
        // ...
    }

    func showError(_ error: Error) {
        // ...
    }
}

由于在 Model 中会直接发起网络请求,这部分的逻辑 不易测试

fileprivate let TestURL = URL(string: "https://baidu.com")!

class MVCUserProfile {
    static let updateNameURL = TestURL

    let id: UUID
    var name: String

    init(id: UUID, name: String) {
        self.id = id
        self.name = name
    }

    func updateName(_ newName: String, completion: @escaping ((Error?) -> Void)) {
        URLSession(configuration: .default).dataTask(with: Self.updateNameURL) { [weak self] data, response, error in
            if error == nil {
                self?.name = newName
            }
            completion(error)
        }.resume()
    }
}

 

易测试的 MVC 代码

 

如果想测试以上代码,可以做出类似这样的调整:

首先,找到 不易测试的依赖,然后利用 协议 对这个依赖进行抽象。在这个示例中,不易测试的依赖就是发起请求的代码,所以我们需要对这部分进行抽象。

protocol DataTaskMaker: AnyObject {
    func dataTask(with url: URL, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void)
}

class DefaultDataTaskMaker: DataTaskMaker {
    let session = URLSession(configuration: .default)

    func dataTask(with url: URL, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) {
        session
            .dataTask(with: url, completionHandler: completionHandler)
            .resume()
    }
}

现在,有了 DataTaskMaker 这个协议,我们已经将不易测试的依赖抽象出来。

并且,DefaultDataTaskMaker 提供了这个协议的实现,所以在默认情况下我们会使用这个类型进行依赖注入。在需要进行测试的时候,将注入的依赖换成一个易于测试的类型即可,具体操作方式请参考后文。

class TestableMVCViewController: UIViewController {
    // taskMaker 可以被替换为其他遵守 DataTaskMaker 协议的对象
    var taskMaker: DataTaskMaker = DefaultDataTaskMaker()

    var userProfile: TestableMVCUserProfile?
    let resultLabel = UILabel()
    private let indicator = UIActivityIndicatorView()

    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
        // ...
    }

    func saveNewName(_ name: String) {
        indicator.startAnimating()
        userProfile?.updateName(name, taskMaker: taskMaker) { [weak self] error in
            defer { self?.indicator.stopAnimating() }
            if let error = error {
                self?.showError(error)
                return
            }
            self?.showSuccess()
        }
    }

    func showSuccess() {
        resultLabel.text = "success"
    }

    func showError(_ error: Error) {
        resultLabel.text = error.localizedDescription
    }
}

在 ViewController 中,我们需要找到 检验测试结果的标准。resultLabel 可以展示请求是否成功,所以在测试时,我们可以用它的 text 来验证测试结果。

现在,让 Model 也不再依赖具体的网络请求类型,而是依赖 DataTaskMaker 抽象协议:

class TestableMVCUserProfile {
    static let updateNameURL = TestURL

    let id: UUID
    var name: String

    init(id: UUID, name: String) {
        self.id = id
        self.name = name
    }

    func updateName(_ newName: String,
                    taskMaker: DataTaskMaker, // 允许注入发起网络请求的对象
                    completion: @escaping ((Error?) -> Void)) {
        taskMaker.dataTask(with: Self.updateNameURL) { [weak self] data, response, error in
            if error == nil {
                self?.name = newName
            }
            completion(error)
        }
    }
}

最后,单元测试代码大致如下:

class TestableMVCViewControllerTests: XCTestCase {
    var controller: TestableMVCViewController!
    var completionHandler: ((Data?, URLResponse?, Error?) -> Void)?

    override func setUpWithError() throws {
        controller = TestableMVCViewController()
        controller.taskMaker = self
        controller.userProfile = TestableMVCUserProfile(id: UUID(), name: "name")
    }

    func testSaveNewNameFailed() throws {
        controller.saveNewName("test")
        let error = NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey : "error"])
        completionHandler?(nil, nil, error)
        XCTAssertEqual(controller.userProfile?.name, "name")
        XCTAssertEqual(controller.resultLabel.text, error.localizedDescription)
    }

    func testSaveNewNameSucceed() throws {
        let newName = "new"
        controller.saveNewName(newName)
        completionHandler?(Data(), nil, nil)
        XCTAssertEqual(controller.userProfile?.name, newName)
        XCTAssertEqual(controller.resultLabel.text, "success")
    }
}

extension TestableMVCViewControllerTests: DataTaskMaker {
    func dataTask(with url: URL, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) {
        self.completionHandler = completionHandler
    }
}

单元测试需要覆盖操作成功和失败的情况,这样才算是保证了这部分代码的正确性。

以上示例的完整代码,您可以在 这里获取

 

依赖注入 和 控制反转

这些调整的核心思路就是 依赖注入控制反转

对于以上示例代码,最核心的调整就是:

  1. ViewController 中的 DataTaskMaker 实例,可以在测试的时候,通过对象的属性 setter 进行注入,以便模拟数据的读写操作。
  2. TestableMVCUserProfile 中的业务逻辑,原本依赖的是一个具体的 URLSession 类型。现在业务逻辑代码部分提供了 DataTaskMaker 抽象协议,需要由依赖(URLSession)去实现这个协议,以满足业务逻辑的需要。最终,业务逻辑代码部分不再关心提供数据的是否为实际的网络请求或者数据库读写等操作。

常见的依赖注入方式有三种:

  1. 实例化对象时,通过初始化方法注入;
  2. 直接访问对象的属性,修改属性的值;
  3. 调用方法时,通过参数注入;

如果您对控制反转的概念依然感到困惑,还可以参考这篇非常详尽的文章:Dependency Inversion – A Little Swifty Architecture

 

总结

 

实施依赖注入和控制反转的流程,大致如下:

  1. 识别调用方的依赖;
  2. 为该依赖定义协议,在协议中创建需要用到的属性/方法;
  3. 被调用方去遵循该协议;
  4. 将遵循协议的被调用方注入给调用方;
  5. 调用方根据协议调用协议中的属性/方法;

其实,遵循依赖注入和控制反转的代码,不论采用了什么架构模式,往往都是易测试的。

如果项目的代码量很大、业务逻辑非常复杂、引入的依赖太多,构建测试代码也会变得困难,这时候您可以考虑为项目引入一个比 MVC 更好的设计模式。如 MVP, MVVM, VIPER, … 。这样,可以从根本上为整个项目进行解耦。如果涉及到较为复杂的状态管理,甚至需要引入单向数据流模式,如 REDUX。

如果对 App 设计模式感兴趣,您也可以参考 Ficow 的文章:
MVC 和 MVP 设计模式

 

参考内容:
依赖注入的三种方式
A Little Architecture - The Clean Code Blog
Dependency Inversion – A Little Swifty Architecture

 

觉得不错?点个赞呗~

本文链接:很难写单元测试?快使用【依赖注入】、【控制反转】~

转载声明:本站文章如无特别说明,皆为原创。转载请注明:Ficow Shen's Blog

评论区(期待你的留言)