CoreData之从项目重构到Unit Test

写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文件夹,之后我们在这个文件夹下创建测试相关代码文件。

CoreData之从项目重构到Unit Test_第1张图片
创建工程的时候勾选Include Unit Tests选项

由于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。如下图所示:

CoreData之从项目重构到Unit Test_第2张图片
在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,即运行了测试代码。

测试结果如下图所示,说明所有测试成功通过。

CoreData之从项目重构到Unit Test_第3张图片
测试结果

至此,我们已经完成了第一个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已经上传到这里,希望这篇文章对你有所帮助_

你可能感兴趣的:(CoreData之从项目重构到Unit Test)