写iOS与Swift相关代码也有一段时间了,UIKit与Foundation的一些组件用得也算比较溜了,但一直没有写过XCTest测试代码。今天以几天前完成的小应用TapList为例(该应用相关文章请点击这里),来简要介绍下iOS相关的Unit Test。
简介
Unit Test,即单元测试。一个良好的Unit Test应该具有以下这些特点:
1.操作简便快速。
2.各个Unit Test之间在功能上相互独立。
3.可重复测试。
4.有自我检测功能,即测试者不需要去额外看相关log即能知晓是否有Bug。
5.经常为新代码准备相关Unit Test。。。
在创建Xcode工程的时候,记得勾选Include Unit Tests选项(如下图所示),这样Xcode会在工程目录下自动帮你创建XXXTests文件夹,之后我们在这个文件夹下创建测试相关代码文件。
由于Swift中class的访问权限默认是internal access level,即class只能在同一个module中互相访问。而要运行的app和相关test在两个不同的module中,所以要在test中访问并测试app的代码,只有以下3种途径:
1.将app中相关class和其中的method标为public。
2.将要测试的代码拷贝到test中。
3.在app的相关代码前加上@testable标记。
在TapList中,我们只对public api进行测试,故采用第一种方式。
CoreData的测试小技巧
由于基于SQLite的CoreData在disk上存储数据,故在测试中添加数据后需要手动将之删除,才能进行下次测试,这就违背了一个良好的Unit Test应该具有的操作简便快速原则和可重复测试原则。因此,在测试中,我们希望数据能够仅仅留在内存中,当一个测试结束的时候,内存中的数据就会消失,而不会影响下一次测试。
因此,在测试中,我们不用SQLite作为CoreData的存储方式,而改用InMemory方式。
原工程重构
重构1
我们希望InMemory方式的CoreData管理仅仅在原来存储模式的基础上改变数据库类型,其余则保持不变。因此,最好的办法就是构建一个子类继承原有CoreData的Stack,并对相关CoreData Stack组件进行重定义。Taplist的CoreData模型是在工程创建的时候由Xcode自动生成,首先,我们将之独立成一个类。
打开CoreData-Taplist工程,创建CoreDataStack.swift文件,import CoreData,定义public class CoreDataStack,将Supporting Files下的AppDelegate.swift文件中CoreData相关代码,即4个属性定义和1个saveContext函数移到该类中。将managedObjectModel、persistentStoreCoordinator、managedObjectContext、func saveContext ()设为public,并添加public init()。代码如下:
import Foundation
import CoreData
public class CoreDataStack {
public init() {
}
lazy var applicationDocumentsDirectory: NSURL = {
...
}()
public lazy var managedObjectModel: NSManagedObjectModel = {
...
}()
...
}
在AppDelegate.swift中添加coreDataStack属性。修补Xcode报出的一处Bug。代码如下:
func applicationWillTerminate(application: UIApplication) {
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
// Saves changes in the application's managed object context before the application terminates.
coreDataStack.saveContext()
}
// MARK: - Core Data stack
let coreDataStack = CoreDataStack()
在唯一用到原managedObjectContext的ViewController.swift文件中,修改相关代码如下:
lazy var context: NSManagedObjectContext = {
let appDelegate = UIApplication.sharedApplication().delegate as! AppDelegate
return appDelegate.coreDataStack.managedObjectContext
}()
运行工程,没有报错。进行简单操作,发现功能依旧,说明重构成功。
重构2
由于原工程比较简单,只有一个添加Item的操作,即ViewController.swift中的@IBAction func addItem()。为了方便进行Unit Test,我们为Item专门创建一个类,来管理有关Item的各项操作。
首先在Item.swift中,将class Item设为public。在Item+CoreDataProperties.swift中,将extension也设为public。
public class Item: NSManagedObject {
...
}
public extension Item {
...
}
在CoreData-TapList文件夹下新建ItemService.swift文件,其内部代码补全如下:
import Foundation
import CoreData
import UIKit
public class ItemService {
let managedObjectContext: NSManagedObjectContext
public init(managedObjectContext: NSManagedObjectContext) {
self.managedObjectContext = managedObjectContext
}
public func addItem(name: String, score: NSNumber) -> Item {
let item = NSEntityDescription.insertNewObjectForEntityForName("Item", inManagedObjectContext: self.managedObjectContext) as! Item
item.name = name
item.score = score
item.image = UIImage(named: "meow")
do {
try self.managedObjectContext.save()
} catch let error as NSError {
print("Error: \(error.userInfo)")
}
return item
}
}
在ViewController.swift中,修改addItem函数如下:
@IBAction func addItem() {
let alert = UIAlertController(title: "Add Item", message: nil, preferredStyle: .Alert)
let cancelAction = UIAlertAction(title: "Cancel", style: .Cancel, handler: nil)
let saveAction = UIAlertAction(title: "Save", style: .Default) { (action) in
let nameField = alert.textFields![0]
let scoreField = alert.textFields![1]
let itemService = ItemService(managedObjectContext: self.context)
itemService.addItem(nameField.text!, score: Int(scoreField.text!) ?? 0)
}
alert.addTextFieldWithConfigurationHandler { (textField) in
textField.placeholder = "name"
}
alert.addTextFieldWithConfigurationHandler { (textField) in
textField.placeholder = "score"
}
alert.addAction(cancelAction)
alert.addAction(saveAction)
self.presentViewController(alert, animated: true, completion: nil)
}
以上修改是为了将Item的所有操作封装在一个独立的class中,方便后续测试。其中,ItemService类在初始化时传入一个NSManagedObjectContext,用来进行CoreData相关操作,这为我们后续测试改变CoreData存储类型做好了准备。
运行工程,没有报错。进行添加操作,发现功能依旧,说明重构成功。
Unit Test
构建基于InMemory的CoreData Stack
基于以上重构,TapLiat工程终于可以进行愉快的Unit Test了!还记得我们将要使用InMemory来进行测试么,那就先构建基于InMemory的CoreData Stack吧。
在CoreData-TapListTests文件夹下新建Swift File,名为TestCoreDataStack,确保在Targets选项下只勾选CoreData-TapListTests。如下图所示:
补全其代码如下:
import Foundation
import CoreData
import CoreData_TapList
class TestCoreDataStack: CoreDataStack {
override init() {
super.init()
self.persistentStoreCoordinator = {
let psc = NSPersistentStoreCoordinator(managedObjectModel: self.managedObjectModel)
do {
try psc.addPersistentStoreWithType(NSInMemoryStoreType, configuration: nil, URL: nil, options: nil)
} catch let error as NSError {
print("ERROR: \(error.userInfo)")
}
return psc
}()
}
}
这样,就构建了CoreDataStack的子类,它用InMemory来存储数据。
构建ItemServiceTests
在CoreData-TapListTests文件夹下新建Unit Test Case Class,名为ItemServiceTests,Xcode已经自动帮你选好这是一个XCTestCase的子类。接着确保在Targets选项下只勾选CoreData-TapListTests。创建后可以看到ItemServiceTests里预置了不少测试函数。
在ItemServiceTests中import相关模块:
import CoreData
import CoreData_TapList
定义新属性:
var itemService: ItemService!
var coreDataStack: CoreDataStack!
这里仍用CoreDataStack类型而不是TestCoreDataStack,是因为app中用的一直是CoreDataStack,并不是我们为了测试而建立的TestCoreDataStack。
override func setUp()里,可以完成测试前的配置工作。我们在这里将coreDataStack用子类TestCoreDataStack初始化,并用它来初始化itemService,这样addItem的时候使用的就是基于InMemory的CoreData了。代码补全如下:
override func setUp() {
super.setUp()
// Put setup code here. This method is called before the invocation of each test method in the class.
coreDataStack = TestCoreDataStack()
camperService = CamperService(managedObjectContext: coreDataStack.mainContext, coreDataStack: coreDataStack)
}
override func tearDown()里,可以完成测试结束后的清理工作。我们在这里将InMemory的测试数据清空。代码补全如下:
override func tearDown() {
// Put teardown code here. This method is called after the invocation of each test method in the class.
super.tearDown()
itemService = nil
coreDataStack = nil
}
testExample和testPerformanceExample函数是测试文件给出的测试样例,在这里将之删除。
定义自己的Unit Test函数如下:
func testAddItem() {
let item = itemService.addItem("item1", score: 30)
XCTAssertNotNil(item, "item should not be nil")
XCTAssertTrue(item.name == "item1")
XCTAssertTrue(item.score!.integerValue == 30)
}
在这个测试函数中,我们先用itemService.addItem函数添加了一个item,然后对返回结果进行Assert判断。如果所有Assert都通过,说明添加函数的功能正确。
接下来运行测试代码,在Xcode菜单栏中Product选项中点击Test,或者快捷键Command + U,即运行了测试代码。
测试结果如下图所示,说明所有测试成功通过。
至此,我们已经完成了第一个Unit Test。
CoreData didSave Test
上一个Unit Test测试了addItem函数返回的数据,但是没有测试数据是否真的保存到了CoreData的store中。接下来我们要测试context的save过程。save过程对于测试者来说是透明的,所幸,我们可以通过NSManagedObjectContextDidSaveNotification来对save过程进行观察。
在ItemServiceTests.swift中添加测试代码如下:
func testContextIsSavedAfterAddingItem() {
expectationForNotification(NSManagedObjectContextDidSaveNotification, object: coreDataStack.managedObjectContext) { (notification) -> Bool in
return true
}
itemService.addItem("item1", score: 1)
waitForExpectationsWithTimeout(2.0) { (error) in
XCTAssertNil(error, "Save did not occur")
}
}
这里用到了XCTest的expectation,expectation表示测试代码期待某个事件发生,这里我们用它来期待NSManagedObjectContextDidSaveNotification这个通知的产生。waitForExpectationsWithTimeout表示等待所期待的事件,括号中2.0表示等待2秒。如果在等待时间内,所期待的事件没有发生,则会产生error。因此在这里,通过assert产生的error是否为nil,就能判断save过程是否发生。
运行测试代码,结果表明测试通过,说明CoreData确实保存了item数据。
如果将以下代码从这个测试函数中删除,再次运行测试代码,则会产生Test Failed的提示信息,错误信息在该测试函数中显示。
itemService.addItem("item1", score: 1)
结语
最终Demo已经上传到这里,希望这篇文章对你有所帮助_。