| Swift , iOS , 架构 , 测试 , XCTest
如今,项目中的业务逻辑越来越复杂,代码量也疯狂飙升,某个 View Controller 可能会有几千行代码。这些项目的代码易读性非常差,维护难度也比较大,测试的难度更大!
如果需要进行人工测试,而且如果App中的运行时状态非常多,就会非常消耗测试人员。
有什么办法可以解决或者至少缓解一下这个问题吗?
目前来看,自动化测试是一个最优解。对于开发人员而言,单元测试是最常见的自动化测试,也是比较有效的测试方法。
然而,想写出易测试的代码需要掌握一定的知识和技巧。接下来,请和 Ficow 一起看看如何基于依赖注入和控制反转构建易测试的代码吧~
由于 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()
}
}
如果想测试以上代码,可以做出类似这样的调整:
首先,找到 不易测试的依赖,然后利用 协议 对这个依赖进行抽象。在这个示例中,不易测试的依赖就是发起请求的代码,所以我们需要对这部分进行抽象。
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
}
}
单元测试需要覆盖操作成功和失败的情况,这样才算是保证了这部分代码的正确性。
以上示例的完整代码,您可以在 这里获取。
对于以上示例代码,最核心的调整就是:
常见的依赖注入方式有三种:
如果您对控制反转的概念依然感到困惑,还可以参考这篇非常详尽的文章:Dependency Inversion – A Little Swifty Architecture。
实施依赖注入和控制反转的流程,大致如下:
其实,遵循依赖注入和控制反转的代码,不论采用了什么架构模式,往往都是易测试的。
如果项目的代码量很大、业务逻辑非常复杂、引入的依赖太多,构建测试代码也会变得困难,这时候您可以考虑为项目引入一个比 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