歡迎來到真實世界 - Unit Test for Core Data

Published Sep 04, 2017
歡迎來到真實世界 - Unit Test for Core Data

Introduction

Once an idea has taken hold of the brain it's almost impossible to eradicate. - Cobb
一旦某個想法掌握了你的腦袋,它將會變得難被鏟除。-Cobb

Inception(全面啟動)一直都是個人最愛的電影之一,劇情燒腦、世界觀完整、畫面夢幻,而且又是這種穿越虛實的題材,幾乎所有元素都深得我心。
進入夢境就電影來說不算太新的梗,Inception跟其它夢境電影不一樣的是,進入夢境有個更複雜的原因:要徹底改變某個人的想法。老梗電影如果進入某人的夢境,很有可能就只是要引出一些秘密,像是金庫密碼之類的(腦海中馬上浮現:左三、右二、左一),但是在Inception裡,進入別人的夢境是為了要改變某人的想法,讓他乖乖地聽話做一些很蠢的事。這點其實非常的有趣,在生活中,的確有些想法根植在腦海中,久久揮之不去,就像被誰植入了思想一樣,比方說戈巴契夫頭髮最多、海珊最不愛打仗,到長大後我還是一直深信不移。

MV5BMTM0MjUzNjkwMl5BMl5BanBnXkFtZTcwNjY0OTk1Mw@@._V1_SY1000_CR0,0,685,1000_AL_.jpg

所以這跟我們今天的主題有甚麼關係呢?非常有關係!因為從上篇文章開始,內容就一直都圍繞著一個主題:我最喜歡的電影...阿不是,是如何在iOS上做乾淨的、與真實環境互相獨立的單元測試。
在上次介紹完Depedency Injection之後,想必大家應該都很了解如何在自己的模組裡,把現實環境抽掉,植入一個假的環境了。如果還不是很清楚Depedency Inject在測試上的應用,可以參考拙做:歡迎來到真實世界 - Unit Test for Networking
接下來,我們會延續上次的主題,繼續來探討需要跟真實世界互動的元件的Unit Test。在這篇文章,我們主要研究的題目是:怎樣做Core Data的Unit Test。

Core Data是一個iOS上面資料結構的封裝,它把跟資料庫相關的邏輯都封裝起來,並且提供大量高階的方法,讓你盡量避免在儲存資料時,還需要把資料庫邏輯跟商業邏輯都寫在一起,讓存取資料變的更直觀。如果要順利地閱讀這篇文章,需要的背景知識就是Core Data的基本運作原理、Core Data的concurrency等。如果不太了解Core Data的運作,可以參考一些教學文章:
A Complete Core Data Application · objc.io
An Introductory Core Data Tutorial - Cocoacasts

TL;DR

這篇文章會包含以下主題:

  • 使用NSPersistentContainer建置Core Data stack
  • 設定In-memory Persistent Store
  • 基本測試技巧:Fake、Stub
  • 在XCTestCase內做非同步的測試

Requirement

這篇文章中使用的開發環境為:

  • Swift 3
  • iOS 10
  • Xcode 8

因為我們會使用NSPersistentContainer來設定Core Data Stack,所以iOS 10是必要的。

Our task

今天我們的目標是要實做一個簡單的todo list系統,可以新增、讀取、修改、刪除(CRUD, create, read, update, delete) todo list,我們希望這個系統,可以在關掉app再打開之後,還能繼續記得上次修改的資料。沒錯,這個todo list app比腦袋還強!

所以我們的任務來了,我們需要一個系統可以:

  1. 新增一個Todo
  2. 取出所有Todo
  3. 刪除一個Todo
  4. 把暫存的資料推進persistent store

因為手機上資源有限,我們不希望每次存取都動到真的資料庫,而是要在真的有需要的時候,才把資料寫入資料庫,所以我們在設計這個系統的時候,會把儲存資料這件事抽出來(第4點),讓它可以納入商業邏輯的考量之中,未來就可以視情況,看是要讓使用者按下儲存後才真的儲存,或是資料累積到一定的量才儲存。

Core Data在iOS 10之後,其實已經大幅簡化了設定難度,以往需要寫一大堆boilerplate的狀況已經不復見了,在使用上也不一定需要很深入的了解,就能夠做基本的操作。雖然話是這麼說沒錯,但如果能夠了解整個Core Data stack,還有各個元件互動的方式,對於設計模組跟寫測試還是有一定的幫助的,所以下面這個小節,會簡單提一下Core Data stack裡面有那些元件,還有這些元件是怎樣互動的。

Core Data stack

Core Data stack是整個Core Data的基本架構,描述Core Data在運作時,有那些元件,這些元件各自會有那些任務等等。

根據Apple的文件,Core Data Stack分成下面幾個部份:

  • Managed Object Context (NSManagedObjectContext),提供不同的運行環境給managed objects。
  • Persistent Store Coordinator (NSPersistentStoreCoordinator),負責整合每個Data Stores。
  • Managed Object Model (NSManagedObjectModel),負責描述資料的結構、長相等等。
  • Persistent Object Store,負責底層的資料庫寫入跟讀取等。

coredatastack.002.png
Core Data stack

以往要設定好這個stack,需要寫非常多的code來個別設定。而在iOS 10之後,官方提供了一個新的類別:NSPersistentContainer,把上面整個stack都打包起來,現在只要初始化一個NSPersistentContainer,並且代入一些參數,接下來所有操作都可以圍繞在這個container上面就好,相當方便。

設定環境

知道了Core Data stack是怎樣的東西之後,我們就要來設定我們的Core Data環境。如果你是在啟動Project時,就勾選Use Core Data,那Xcode會自動幫你在AppDelegate裡面產生必要的code,但如果你是後來才加的,記得要在AppDelegate裡面設定好container:

    lazy var persistentContainer: NSPersistentContainer = {
        let container = NSPersistentContainer(name: "PersistentTodoList")
        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        })
        return container
    }()

一般來說一個App只會有一個persistent store,這也就是為甚麼我們需要一個全域的container,目的就是為了方便讓其它class取用相同的container,而不會造成混淆。

一個基本的Todo list,至少要能夠寫上要做的事項,並且要能標示是否完成,所以我們的Data Model需要描述一個具有以下attributes的entity:

  • name (string)
  • finished (bool)

所以設定上會長這樣:
Screen Shot 2017-09-03 at 14.47.41.png

設計TodoStorageManager

設定完Core Data stack之後,我們可以開始來設計我們主要拿來操作Core Data的類別:TodoStorageManager,這個manager的功用,就是負責所有跟Core Data的互動,簡化操作的細結,讓資料處理可以盡量獨立於其它邏輯之外。我們目前規劃的功能,就是簡單的CRUD。

因為這個manager主要操作的環境就是Core Data,所以我們的dependency自然而然就會是persistent container,初始化設定如下:

    class ToDoStorgeManager {
    
    let persistentContainer: NSPersistentContainer!
    
    //MARK: Init with dependency
    init(container: NSPersistentContainer) {
        self.persistentContainer = container
self.persistentContainer.viewContext.automaticallyMergesChangesFromParent = true
    }
    
    convenience init() {
        //Use the default container for production environment
        guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
            fatalError("Can not get shared app delegate")
        }
        self.init(container: appDelegate.persistentContainer)
    }
}

上面設定了一個convenience init,放入預設的dependency,也就是全域的container,這樣一來在production環境要取用這個manager會比較簡單,也比較容易在看code時候就知道它的功用。

另外,我們希望在操作Core Data時,寫入在背景thread執行,這樣可以避免效能上的問題。在NSPersistentContainer裡,預設會提供兩種context:viewContext跟backgroundContext。viewContext就是運行在main thread上的context,而利用NSPersistentContainer.newBackgroundContext()產生的context,就是backgroundContext,會在背景thread執行資料庫操作。

在這邊我們預設我們的背景任務(所有寫入的任務)都只需要在同一個背景thread執行,所以我們會直接建立一個backgroundContext供使用:

lazy var backgroundContext: NSManagedObjectContext = {
    return self.persistentContainer.newBackgroundContext()
}()

最後,操作的部份實作如下:

    //MARK: CRUD
    func insertTodoItem( name: String, finished: Bool ) -> ToDoItem? {

        guard let toDoItem = NSEntityDescription.insertNewObject(forEntityName: "ToDoItem", into: backgroundContext) as? ToDoItem else { return nil }
        toDoItem.name = name
        toDoItem.finished = finished
        
        return toDoItem
    }
    
    func remove( objectID: NSManagedObjectID ) {
        let obj = backgroundContext.object(with: objectID)
        backgroundContext.delete(obj)
    }
    
    func fetchAll() -> [ToDoItem] {
        let request: NSFetchRequest<ToDoItem> = ToDoItem.fetchRequest()
        let results = try? persistentContainer.viewContext.fetch(request)
        return results ?? [ToDoItem]()
    }

    func save() {
        if backgroundContext.hasChanges {
            do {
                try backgroundContext.save()
            } catch {
                print("Save error \(error)")
            }
        }
        
    }

到這裡,最簡單的manager就寫完了,它現在可以新增、讀取跟刪除todo item了。

Unit Test Setup

接著我們要開始來撰寫測試的code,依照我們的規劃,我們需要以下幾個測試case:

  1. 要能成功新增Todo並回傳ToDoItem物件
  2. 要能取得目前資料庫中的todos
  3. 要能成功刪除資料庫中的todo
  4. 呼叫save(),要能夠對資料庫執行Save

確定了我們的目標之後,我們就可以開始來設置我們的測試了,首先我們先設定好我們的SUT:

    var sut: ToDoStorgeManager! 
    override func setUp() {
        super.setUp()
        sut = ToDoStorgeManager(container: mockPersistantContainer)
    }

其中mockPersistentContainer就是一個mock container,我們想要拿它來取代真的container。對Core Data來說,persistent store應該要是sqlite或是binary store,這樣才能把資料永久儲存下來。問題是,這兩樣都是我們目前無法mock的東西,所以我們需要利用不一樣的測試技巧,來達到替換環境的目的。

Fake - In-memory data store

Core Data本身有提供一種特別的store type:NSInMemoryStoreType。這個NSInMemoryStoreType,對Core Data來說,也是一種persistent store,跟sqlite或是binary不一樣的地方是,這個store的資料,只會儲存在記憶體裡面。也就是說只要App一關掉,所有的資料就會消失。我們可以利用這樣的特性,來做一個假的、只存在記憶體裡面的store,讓unit test code可以自由取用,又不用擔心資料跟production的搞混。這種方法,通常在測試技巧裡面被稱做為:Fake,也就是透過製作一個非常接近真實環境的物件,來讓測試能夠盡可能與真實隔離,但又能像是在真實環境中執行一樣。

Objects actually have working implementations, but usually take some shortcut which makes them not suitable for production - Martin Fowler

所以我們就利用這個假資料庫,來製作我們的mock container吧!

lazy var mockPersistantContainer: NSPersistentContainer = {
        
        let container = NSPersistentContainer(name: "PersistentTodoList", managedObjectModel: self.managedObjectModel)
        let description = NSPersistentStoreDescription()
        description.type = NSInMemoryStoreType
        description.shouldAddStoreAsynchronously = false // Make it simpler in test env
        
        container.persistentStoreDescriptions = [description]
        container.loadPersistentStores { (description, error) in
            // Check if the data store is in memory
            precondition( description.type == NSInMemoryStoreType )

            // Check if creating container wrong
            if let error = error {
                fatalError("Create an in-mem coordinator failed \(error)")
            }
        }
        return container
    }()

讓我們來仔細分析一下裡面的內容:

let container = NSPersistentContainer(name: "PersistentTodoList", managedObjectModel: self.managedObjectModel)

這行會初始化一個container,並且指定managedModel。因為在我們的test target裡面,namespace跟production target是不一樣的,如果我們不指定managedModel,NSPersistentContainer會無法抓到我們的的managedModel,也就是.xcdatamodeld檔案。所以這邊我們必須要手動讓test code先抓到那個.xcdatamodeld,然後餵進container裡面,如此一來才能夠共享同樣的database schema。這個managedObjectModel設定如下:

    lazy var managedObjectModel: NSManagedObjectModel = {
        let managedObjectModel = NSManagedObjectModel.mergedModel(from: [Bundle(for: type(of: self))] )!
        return managedObjectModel
    }()

直接透過bundle把managedObjectModel抓進test target使用,而不仰賴NSPersistentContainer的自動判斷機制。另外,因為預設.xcdatamodeld是不會compile進test target的,所以我們也需要把.xcdatamodeld這個檔案加到test target裡面:

Screen Shot 2017-09-02 at 21.42.12.png

接下來,就是這個Fake的關鍵:

    let description = NSPersistentStoreDescription()
    description.type = NSInMemoryStoreType

container的屬性,可以透過NSPersistentStoreDescription來指定,所以我們就把我們的store設定成是InMemoryType,現在,對這個container的操作,就像在夢境裡一樣,不管你怎樣做,都不會影響真實世界了!

Stub - Canned responses

Stubs provide canned answers to calls made during the test, usually not responding at all to anything outside what's programmed in for the test. - Martin Fowler

根據Martin Fowler的定義,stub就是一些已經寫死的罐頭回傳值,常見的使用情境是:我要測試我的parser正不正確,我就先準備好一份raw data,讓它通過parser,最後比對出來的結果跟我準備的資料有沒有對應。在我們的這個case,我們利用stub,來測試我們對資料庫的操作,是不是有真的在進行,也就是,我們準備一些stubs,把它們塞進剛剛的假資料庫中,因為我們已經預先知道資料庫中有那些資料了,接下來我們就可以測試執行新增是不是真的會多一筆資料,刪除是不是會少一筆資料等等。以下是我們產生Stub的方法:

func initStubs() {
        
        func insertTodoItem( name: String, finished: Bool ) -> ToDoItem? {
            
            let obj = NSEntityDescription.insertNewObject(forEntityName: "ToDoItem", into: mockPersistantContainer.viewContext)
            
            obj.setValue("1", forKey: "name")
            obj.setValue(false, forKey: "finished")

            return obj as? ToDoItem
        }
        
        _ = insertTodoItem(name: "1", finished: false)
        _ = insertTodoItem(name: "2", finished: false)
        _ = insertTodoItem(name: "3", finished: false)
        _ = insertTodoItem(name: "4", finished: false)
        _ = insertTodoItem(name: "5", finished: false)
        
        do {
            try mockPersistantContainer.viewContext.save()
        }  catch {
            print("create fakes error \(error)")
        }
        
    }

在這邊,我們直接操作資料庫,放進五筆資料,當作我們的stubs。需要注意的是,因為namespace不同的關係,我們的insert跟edit都不能直接使用ToDoItem,也就是不能用Xcode自動產生的NSManagedObject subclass,而是要用最原始的操作Core Data的方式:用NSManagedObject.setValue(_, forKey:)來存取資料,不然會發生找不到entity的問題。偉哉Xcode!另外,這邊使用viewContext來做寫入,是因為我希望讓這個寫入是同步的,要確保這些Stub在test case執行前,就已經產生好並且放入資料庫。

再來,對Unit Test來說,每跑一個test case,都要像是展開新的人生一樣,要是全新的空白的。小魯的人生是黑白的,而測試更慘,只能是空白的。千萬不能出現這個test case被上一個test case影響的狀況,這點非常重要,所以我們要有一個能夠把資料全部清掉的方法:

    func flushData() {
        
        let fetchRequest:NSFetchRequest<NSFetchRequestResult> = NSFetchRequest<NSFetchRequestResult>(entityName: "ToDoItem")
        let objs = try! mockPersistantContainer.viewContext.fetch(fetchRequest)
        for case let obj as NSManagedObject in objs {
            mockPersistantContainer.viewContext.delete(obj)
        }
        try! mockPersistantContainer.viewContext.save()

    }

這邊一樣,不能對ToDoItem這個自動生成的class做任何操作,所以我們使用最原始的方試操作Core Data,再跟我念一次,偉哉Xcode~

把這兩個方法加到setUp()跟tearDown()之後,我們就可以確保每個test case都是嶄新的開始了!

    override func setUp() {
        super.setUp()
        initStubs() // Create stubs
        sut = ToDoStorgeManager(container: mockPersistantContainer)
    }
    
    override func tearDown() {
        flushData() // Clear all stubs
        super.tearDown()
    }

Test Cases

經過了長時間的鋪陳,終於要進入我們的主菜了,主菜相對很簡單,只要跟隨 Given, When, Assert這樣的模式,就可以順利寫出每個test case,以下我們先把新增、讀取、跟移除的test case寫完:

    func test_create_todo() {
        
        //Given the name & status
        let name = "Todo1"
        let finished = false
        
        //When add todo
        let todo = sut.insertTodoItem(name: name, finished: finished)
        
        //Assert: return todo item
        XCTAssertNotNil( todo )

    }
    
    func test_fetch_all_todo() {
        
        //Given a storage with two todo
        
        //When fetch
        let results = sut.fetchAll()
        
        //Assert return two todo items
        XCTAssertEqual(results.count, 5)
    }
    
    func test_remove_todo() {
        
        //Given a item in persistent store
        let items = sut.fetchAll()
        let item = items[0]
        
        let numberOfItems = items.count
        
        //When remove a item
        sut.remove(objectID: item.objectID)
        sut.save()
        
        //Assert number of item - 1
        XCTAssertEqual(numberOfItemsInPersistentStore(), numberOfItems-1)
        
    }

    //Convenient method for getting the number of data in store now
    func numberOfItemsInPersistentStore() -> Int {
        let request: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: "ToDoItem")
        let results = try! mockPersistantContainer.viewContext.fetch(request)
        return results.count
    }

在test_fetch_all_todo()中,因為我們已經知道stub總共的數量是5個,所以我們直接使用5來當做我們的assertion。

另外,在test_remove_todo()裡面,我們做了一些妥協,因為我們的寫入是寫到backgroundContext,但我們的numberOfItemsInPersistentStore()卻是讀viewContext的資料,所以我們需要先執行save()這個side effect,才能assert正確的數量。未來可以透過mock一個完全客制化的NSPersistentContainer,產生指定的backgroundContext來解決這個問題,不過這邊讓我們先專注在fake跟stub就好。

上面這些東西的操作,全部都是在記憶體中操作,但對我們的SUT來說,因為接口一模一樣,都是NSPersistentContainer,所以互動的方式跟production是一模一樣的。

Expectations to Notification

到此,我們還有一個case還沒有寫到:

  1. 呼叫save(),要能夠對資料庫執行Save

這個case的目的,是要確保我們的ToDoStorageManager.save()真的有呼叫到NSManagedObjectContext.save(),讓資料真的有寫入persistent store。依照我們平常mock的作法,我們應該要建立一個NSManagedObjectContextProtocol,並且產生一個mock來conform這個protocol,然後利用mock來得知NSManagedObjectContext.save()是不是真的有被呼叫(再次宣傳一下,可以參考拙作)。但問題來了,我們的dependency是NSPersistentContainer,所有的context都被封裝在這個container之中,並且目前也沒有方法可以在container中植入自己的context,所以我們必須要找別的方法來做這個behavior test。

如果不能透過mock來了解save()的運作,我們還有另外一個妥協的方法,在Core Data,只要context有變動,都會發出對應的notification,我們只要聽這些notification,就可以知道我們的SUT是不是真的有對context下save()。在這邊我們會專注在NSManagedObjectContextDidSave這個notification,這個notification會在資料被存回persistent store後觸發,只要有收到這個notification,就表示NSManagedContext.save()是有被觸發的。

所以我們在setUp()裡面,註冊這個notification:

    NotificationCenter.default.addObserver(self, selector: #selector(contextSaved(notification:)), name: NSNotification.Name.NSManagedObjectContextDidSave , object: nil)

然後設定好這個notification的handler:

    func contextSaved( notification: Notification ) {
    }

接著我們來看一下我們save()的測試code:

    func test_save() {
                
        //Give a todo item
        let name = "Todo1"
        let finished = false
        
        _ = expectationForSaveNotification()
        
        _ = sut.insertTodoItem(name: name, finished: finished)
        
        //When save
        sut.save()
        
        //Assert save is called via notification (wait)
        waitForExpectations(timeout: 1, handler: nil)

    }

在這一個case之中,我們做的事情是:新增一個todo list,並且等待NSManagedObjectContextDidSave(也就是expectationForSaveNotification),在一秒內收到這個notification(也就是waitForExpectations),這個test case才算有過。因為notification是一個非同步的行為,所以我們需要利用XCTestExpectation,來做非同步的測試。讓我們先來看一下expectationForSaveNotification()的實作:

    func expectationForSaveNotification() -> XCTestExpectation {
        let expect = expectation(description: "Context Saved")
        //After Do something async {
            expect.fulfill()
      // }
        return expect
    }

expectation(description: "Context Saved”)是一個在XCTestCase已經定義的method,目地是要設定一個XCTestExpectation物件,並且將這個expectation加入清單,接著只要在你的test case裡面加上waitForExpectations(timeout: TimeInterval, handler: XCTest.XCWaitCompletionHandler? = nil),那個test case就會一直等待,等到expect.fulfill()被觸發之後,才會通過測試,如果在timeout設定的時間前,都沒收到任何fulfill()的呼叫,這個case就會被判定失敗。

接著,因為我們等待的是一個notification,所以上面的After Do something async,會這樣實作:

    //MARK: Convinient function for notification
    var saveNotificationCompleteHandler: ((Notification)->())?
    
    func waitForSavedNotification(completeHandler: @escaping ((Notification)->()) ) {
        saveNotificationCompleteHandler = completeHandler
    }
    
    func contextSaved( notification: Notification ) {
        saveNotificationCompleteHandler?(notification)
    }

在waitForSavedNotification中,我們先把complete handler存下來,等到在contextSaved(Notification:)中收到notification之後,再呼叫這個handler,達到async的效果,一旦這個complete handler被呼叫了,就會觸發expect.fulfill(),而waitForExpectations就會被滿足,測試case也就順利通過了!

Recap

在這篇文章中,我們學習到了:

  1. 怎樣用NSPersistentContainer設定Core Data stack
  2. 怎樣設定Fake及Stub for Core Data
  3. 怎麼在XCTestCase中做非同步的測試

完整的程式可以在我的Github上面找到。

Summary

對Core Data來說,其實有許許多多種測試的方法,最常見的還是mock NSManagedObjectContext,也有許多文章已經有做過探討:
How to Create Mocks and Stubs in Swift - Andrew Bancroft
Real-World Testing with XCTest · objc.io

不過為了要符合iOS 10以後新的Core Data stack用法,小弟試著做了一些研究,試著在NSPersistentContainer的狀況下做測試,雖然很多地方需要稍微繞路一下,但整體來說該測試的地方都還是可以完整地測試,並且也相當獨立於真實環境之外,希望這些方法能夠帶來一點幫助。

最後,在寫這篇文章的同時,已經越來越多人投入Realm的懷抱了,找了一個大大詢問Core Data的細節,想不到他直接跟我說他只有用Realm從來沒用過Core Data,這就是時代的眼淚嗎QQ

References

Core Data Programming Guide: Persistent Store Types and Behaviors

iOS 10 Core Data In Memory Store for Unit Tests - Stack Overflow

Data Models and Model Objects · objc.io

Real-World Testing with XCTest · objc.io

Easier Core Data Setup with Persistent Containers

TestDouble

Discover and read more posts from ShihTing Huang (Neo)
get started
Enjoy this post?

Leave a like and comment for ShihTing

2Replies
Shih-Wei Liu
a month ago

NSPersistentContainer只能用在最低支援iOS 10以上的專案,目前實用性應該不高。

ShihTing Huang (Neo)
a month ago

對阿,只支援iOS 10以上,我也沒有用在我的production code上面,不過就是預先survey一下,看看未來可行性這樣~