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

创建工程的时候勾选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。如下图所示:

在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已经上传到这里,希望这篇文章对你有所帮助_

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 221,198评论 6 514
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 94,334评论 3 398
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 167,643评论 0 360
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,495评论 1 296
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,502评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 52,156评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,743评论 3 421
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,659评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 46,200评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,282评论 3 340
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,424评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 36,107评论 5 349
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,789评论 3 333
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,264评论 0 23
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,390评论 1 271
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,798评论 3 376
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,435评论 2 359

推荐阅读更多精彩内容

  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,117评论 4 61
  • 为什么要离婚呢?因为你结婚了啊。 从初相识的互相感觉不错,到慢慢了解之后的坚定,到最后进入婚姻的殿堂,都是在自己思...
    此木无言阅读 613评论 0 0