在Swift中对MVVM架构进行单元测试

为确保新代码不会破坏已经实施的旧代码,最佳实践是编写单元测试。 对于应用程序体系结构,编写这些测试可能是一个挑战。 遵循MVVM模式, 如何对视图及其viewModel进行单元测试? 这就是我想在这里使用依赖注入的内容。

MVVM体系结构的主要优点是将逻辑解耦并保持关注点分离。 每个类和文件都有特定的目标。 该代码保持模块化,可重用且易于测试。 测试用例应采用相同的逻辑:编写的每个测试仅覆盖一个用例,而仅覆盖一个用例,请隔离逻辑并确保其正常工作。

在此示例中,我还将保持关注点分离,一次测试一个元素。 我的意思是,对于MVVM模式,我至少要有3个测试文件:一个用于我的模型,一个用于我的视图,一个用于我的viewModel。

但是在深入研究代码之前,如果您不熟悉MVVM,我建议您先看一下我最近编写的实现MVVM模式的介绍。

准备您的viewModel

为了能够针对我的viewModel运行测试,我需要能够使用其他服务,并能够通过依赖注入来模拟我的服务。 第一步是为服务创建一个协议,然后将其实现到当前服务,最后更新视图模型。

我这就是我的协议和服务的样子

protocol CurrencyServiceProtocol : class { 
func fetchConverter(_ completion: @escaping ((Result) -> Void))
}
 final class CurrencyService : RequestHandler, CurrencyServiceProtocol { ... } 

然后我可以在viewModel中使用依赖项注入和默认参数

 weak var service: CurrencyServiceProtocol? 
 init(service: CurrencyServiceProtocol = CurrencyService.shared, dataSource : GenericDataSource?) { 
self.dataSource = dataSource self.service = service
}
 func fetchCurrencies(_ completion: ((Result) -> Void)? = nil) { 
guard let service = service else {
completion?(Result.failure(ErrorResult.custom(string: "Missing service")))
return
}
...

现在我们准备编写测试了。

ViewModel测试用例

在我的测试方面,第一件事是为每个特定的测试准备课程。 由于我的viewModel可以使用服务和数据源,因此我将在此处进行模拟。

 class CurrencyViewModelTests: XCTestCase { 
var viewModel : CurrencyViewModel!
var dataSource : GenericDataSource!
fileprivate var service : MockCurrencyService!
  override func setUp() { 
super.setUp()
self.service = MockCurrencyService()
self.dataSource = GenericDataSource()
self.viewModel = CurrencyViewModel(service: service, dataSource: dataSource)
}
  override func tearDown() { 
self.viewModel = nil
self.dataSource = nil
self.service = nil
super.tearDown()
}
}

为了模拟服务,我实现了相同的先前协议,使用局部变量伪造数据。

 fileprivate class MockCurrencyService : CurrencyServiceProtocol { 
var converter : Converter?
  func fetchConverter(_ completion: @escaping ((Result) -> Void)) { 
if let converter = converter {
completion(Result.success(converter))
} else {
completion(Result.failure(ErrorResult.custom(string: "No converter")))
}
}
}

现在我们准备运行我们的第一个测试

 func testFetchWithNoService() { 
// giving no service to a view model
viewModel.service = nil
  // expected to not be able to fetch currencies 
viewModel.fetchCurrencies { result in
  switch result { 
case .success(_) :
XCTAssert(false, "ViewModel should not be able to fetch without service")
default:
break
}
}
}

查看测试案例

在我的示例应用程序中,视图由UIViewController表示,但是除了实际的UITableView及其数据源外,没有太多要测试的内容。

因此,我以同样的方式将精力集中在这两个元素之间的关系上。 这是它的味道。

 class CurrencyDataSourceTests: XCTestCase { 
var dataSource : CurrencyDataSource!
  override func setUp() { 
super.setUp()
dataSource = CurrencyDataSource()
}
  override func tearDown() { 
dataSource = nil
super.tearDown()
}
  func testValueInDataSource() { 
// giving data value
let euroRate = CurrencyRate(currencyIso: "EUR", rate: 1.14)
let dollarRate = CurrencyRate(currencyIso: "EUR", rate: 1.40)
dataSource.data.value = [euroRate, dollarRate]
let tableView = UITableView()
tableView.dataSource = dataSource
  // expected one section 
XCTAssertEqual(dataSource.numberOfSections(in: tableView), 1, "Expected one section in table view")
  // expected two cells 
XCTAssertEqual(dataSource.tableView(tableView, numberOfRowsInSection: 0), 2, "Expected no cell in table view")
}

模型测试用例

这里的最佳实践是仅测试模型的解析方面。 但是,我当前的iOS应用程序不允许直接将JSON数据注入到我的模型中。 因此,我在这里做了一些尝试,并测试了我的主要Parser服务和模型。

在您的生产应用程序上,最好在这里进行两个测试,一个用于解析器,一个用于模型。 原因很简单:如果明天更改解析器,则也不必重写模型测试用例。

一个小提示:为避免调用任何网络,我只是向应用程序中添加了一个JSON文件来模拟来自服务器的数据。 如果您想与api的旧版本复古兼容,这是一件好事。 但是,新的api版本也需要一个新的JSON文件和测试用例。

 class CurrencyTests: XCTestCase { 
func testParseCurrency() {
// giving a sample json file
guard let data = FileManager.readJson(forResource: "sample") else {
XCTAssert(false, "Can't get data from sample.json")
return
}
  // giving completion after parsing 
// expected valid converter with valid data
let completion : ((Result) -> Void) = { result in

switch result {
case .failure(_):
XCTAssert(false, "Expected valid converter")
case .success(let converter):
XCTAssertEqual(converter.base, "GBP", "Expected GBP base")
XCTAssertEqual(converter.date, "2018-02-01", "Expected 2018-02-01 date")
XCTAssertEqual(converter.rates.count, 32, "Expected 32 rates")
}
}
  ParserHelper.parse(data: data, completion: completion) } 

总之 ,通过依赖项注入方法和一些小的更新,我们设法测试了MVVM体系结构的每个部分,还显示了保持关注点分离的重要性。

但是,我的示例应用程序的内容非常小而简单。 添加更多的屏幕和导航可能使跟踪测试方法变得更加困难。 当我尝试对单元进行单元测试并在其UI生命周期(1)之外调用UI方法时,也遇到了相同的问题。

我认为这就是警告: 如果要测试的功能变得复杂,最好将其重构为易于维护的小片段

这个单元测试示例都可以在Github上获得。

(1)NSInternalInconsistencyException滚动UITableViews·问题#1007·kif-framework / KIF·GitHub