Core Data數據遷移及單元測試

1 前言

文件結構:對于使用CoreData作為數據本地化的APP,在工程中CoreData會管理一個.xcdatamodeld包,其中包含各個版本的.xcdatamodeld數據模型,在APP運行時它們分別會被編譯成.momd的文件夾和.mom的文件,并放于主bundle中。每個.mom文件代表了其對于版本的NSManagedObjectModle,當APP每次啟動初始本地數據庫時,CoreData會檢查當前的NSManagedObjectModle的版本是否和數據庫PersistenceStore中的NSManagedObjectModle的版本是否一致,如果不一致會根據以下兩個數據決定程序下一步的執行結果。如果一致則會正常初始化數據庫。

//是否開啟自動數據遷移
description.shouldMigrateStoreAutomatically = true
//是否自動推斷映射模型,官方文檔中對此屬性的解釋是確定CoreData是否為數據遷移自動推斷映射模型,
//但實際測試中發現,當此屬性為True時,CoreData仍會先在所有Bundle中尋找一個映射模型MappingModel,
//將識別出來的本地存儲Store用的NSManagedObjectModel映射到目標對象模型NSManagedObjectModel
//如找不到對應的MappingModel時才會自動推斷一個映射模型
description.shouldInferMappingModelAutomatically = true

版本判斷:判斷兩個NSManagedObjectModle是否為同一版本時,只需判斷其中實體數組是否相同。因為CoreData在數據遷移時對版本的控制是通過其中所有的實體各個屬性和關系生成一個hashVersion。

數據遷移:如果開啟數據庫自動遷移,CoreData會根據用戶定義好的策略進行數據庫遷移,此過程必須放在Appdelegate中didFinishLaunchingWithOptions方法中,并且不能在子線程執行。CoreData僅支持單個NSManagedObjectModle版本間隔之間的自動遷移,多版本之間遷移需要自定義遷移過程。如果未開啟自動數據遷移,CoreData會拋出異常提示創建PersistentStore的NSManagedObjectModle和打開PersistentStore的NSManagedObjectModle不一致。

執行步驟:Since the migration is performed as a three-step process (first create the data, then relate the data, then validate the data)。在蘋果官方文檔中,CoreData執行數據遷移分三個階段,CoreData根據原始數據模型和目標數據模型去加載或者創建一個數據遷移需要的映射模型,具體表現為以下三步。只有執行完下述步驟,當數據遷移成功后,舊的數據庫才會被清除。

  • 1)CoreData將源數據庫中所有對象拷貝到新的數據庫中。
  • 2)CoreData根據映射模型連接并再次關連所有對象。
  • 3)在數據拷貝過程中,CoreData不會對數據進行校驗,等前兩步結束后,CoreData才會在目標數據庫中對所有數據進行數據校驗。

注意:如果模型結構改動較大,并且自動遷移和自動推斷映射模型屬性都為YES時候(默認設置),CoreData將自動執行數據遷移,這種遷移將不會拋出任何錯誤,遷移成功后會刪除舊數據,導致數據永久丟失。測試時需注意這點,避免來回切換版本運行測試。每次測試應刪除原有程序,再覆蓋安裝。

1 數據遷移類型

根據對數據庫改變的幅度大小,相應的數據遷移可以為以下四個量級。

  • 輕量數據遷移:當數據模型xcdatamodeld改動不大時,為NSPersistentContainer中設置一些屬性,CoreData會自動完成數據遷移的工作。
  • 簡單手動數據遷移:需要指定如何將舊的數據集映射到新的數據集,可以使用GUI工具建立相應的映射模型NSMappingModel,系統會完成部分自動化操作。
  • 復雜手動數據遷移:同樣使用映射模型,但是需要用自定義代碼指定數據的轉換邏輯,需要創建NSEntityMigrationPolicy的子類來實現數據遷移。
  • 漸進式數據遷移:應用于非連續版本數據遷移,如從version1遷移至version4。

2 數據遷移涉及到的類

2.1 NSManagedObjectModle

在工程中會有一個以.xcdatamodeld結尾的包,其中管理若干個.xcdatamodeld文件,每個文件對應一個版本的NSManagedObjectModle。對于每個NSManagedObjectModle,CoreData都會根據其實體集生成一個HashVersion,當CoreData初始化數據庫時,這個信息會以配置信息的方式保存在NSPersistentStore中,留待將來進行數據遷移時使用。它對應了工程中的各個NSManagedObject類,通常通過菜單中的Editor生成這些類。當手動建立這些類時,必須在NSManagedObjectModle對應的實體的屬性面板中的Class字段和Module字段中填入相應的類名和命名空間。

2.2 NSMigrationManager

數據遷移管理者,CoreData進行自動數據遷移時改對象由CoreData復雜進行創建和管理,它是數據遷移的執行者,可以通過觀察期Progress獲取數據遷移進度。

2.3 NSMappingModel

映射模型,它負責將某個版本的NSManagedObjectModle中的所有實體映射到對應的版本中。由CoreData自動推斷或在新建文件中手動創建,CoreData會自動填充大部分字段。

2.4 NSEntityMapping

實體映射模型,位于NSMappingModel,它負責具體的某個實體從源NSManagedObjectModle映射到目標模型中。可以在NSMappingModel中新增或者刪除。

2.5 NSEntityMigrationPolicy

實體遷移策略,位于NSEntityMapping中,它負責具體的某個實體從源NSManagedObjectModle映射到目標模型中,它比NSEntityMapping更高級,可以進行深層次自定義。可以在NSEntityMapping中指定唯一一個NSEntityMigrationPolicy,需要注意的是如果實在Swift中,必須加上工程名的前綴。

3 輕量數據遷移

在執行數據遷移之前,先新建一個xcdatamodeld新版本文件,并將其設置為當前系統采用的數據模型版本文件。啟用NSPersistentStoreDescription中的shouldInferMappingModelAutomatically屬性。默認創建NSPersistentContainer時開啟。當滿足以下條件時,CoreData會推斷出一個映射模型進行數據遷移。隨后編譯運行APP,數據遷移完成。更多滿足輕量數據遷移的條件見官網

  • 刪除實體,屬性或者關系
  • 重命名實體,屬性或者關系
  • 添加新的可選的屬性
  • 添加帶默認值的必選屬性
  • 將可選屬性改為必選屬性,并為其指定默認值
  • 將必選屬性改為可選屬性
  • 改變實體包含關系
  • 添加一個高一層實體,并將屬性沿著層級上下移動
  • 將to-One關系改變為to-Many
  • 將to-many類型的關系中的non-ordered改變為ordered或者反向改變

4 簡單手動數據遷移

當對NSManagedObjectModle模型改變超出輕量數據遷移的限制時,CoreData已經不能自動推斷出一個映射模型,此時我們需要手動創建MappingModel文件,如果新模型中的實體屬性和關系都從原模型的某一個實體中繼承,此時只需進行簡單的手動數據遷移操作,不需要自定義遷移策略MigrationPolicy。

首先建立新版本的NSManagedObjectModel文件,切記必須執行完所有對其的改變操作,并編譯程序成功,再創建對應源和目標版本的映射文件NSMappingModel。CoreData會根據當前選擇的兩個版本的NSManagedObjectModel對NSMappingModel進行部分初始化操作,此時在ENTITY MAPPINGS中大多數的實體映射名稱都為EntityToEntity,其中to連接的前面是source NSManagedObjectModel中的實體,后面是destination NSManagedObjectModel中的實體。如果數據庫有新增并且與源數據庫無關的實體,其不需要映射,因此也不會在這里展示 ,如果新增實體和源數據庫相關,需要在改實體的Entity Mapping選項中指定其Source,此時該實體的Mapping會自動更新。

確保目標數據庫中與源數據庫相關的所有實體都有對應的Entity Mapping,確保其中每個實體所有屬性的Value Expression都被指定。對于某個Entity Mapping,可以使用Filter Predicate限制映射發生的條件,如在名為NoteToAttachment的Entity Mapping中,Filter Predicate指定為image != nil,這表示對于源數據庫中的每個Note實體,如果其image不為空的時候,為其創建一個Attachment對象,并進行Mapping描述的相關賦值操作。

對于關系的映射,對于每個Relationship Mapping,在Key Path填入“$source”,Mapping Name中選擇其指向的實體映射。這時CoreData會產生一個函數

FUNCTION($manager, "destinationInstancesForEntityMappingNamed:sourceInstances:" , "NoteToAttachment", $source)

FUNCTION($manager, "destinationInstancesForSourceRelationshipNamed:sourceInstances:" , "attachments", $source.attachments)

第一個函數中manager指定是Migration Manager,它由CoreData在數據遷移時創建,$source指的是這個EntityMapping的source。這個函數的作用是當前映射執行時,Migration Manager根據函數第二個$source參數找到當前源數據庫中的實體,根據第一個參數指定的映射NoteToAttachment執行映射操作,得到一個目標數據庫中的實體Attachment,并將這個實體Attachment賦值給當前實體執行遷移后的實體對象。該解釋不代表CoreData內部實現方式,僅為簡化說明。另外時間測試發現成對出現的關系只需實現一個即可,這應該和CoreData內部實現有關。

第二個函數中不同的是它將源實體中名字為“attachment”的關系對應的$source.attachments集合映射到數據遷移后的實體中。函數中并未指定需要使用的映射關系,但是在屬性面板中仍可以選擇,此處具體邏輯還有待探究。
隨后編譯運行APP,數據遷移完成。

5 復雜手動數據遷移

當對NSManagedObjectModle模型改變超出簡單手動數據遷移限制時,首先CoreData不能自動推斷出一個映射模型,同時當我們創建映射模型時,CoreData也無法通過NSMappingModel指定新的屬性創建等操作。此時不僅需要手動創建NSMappingModel文件。還需要為其中的某些NSEntityMapping指定遷移策略NSEntityMigrationPolicy

復雜手動數據遷移大多發生在,新模型中的實體屬性和關系不能從原模型的某一個實體中繼承,需要根據原模型中實體的某些屬性新建。此時必須進行復雜的手動數據遷移操作,需要自定義遷移策略MigrationPolicy。如果一個類需要管理多個版本的某個實體遷移策略,可以在NSMappingModel文件中的User Info中添加字段區分,他們可以通過mapping.userInfo獲得。

  • 第一步:建立新的NSManagedObjectModle版本,構建新的數據結構。
  • 第二步:創建NSMappingModel,選擇正確的Source Model和Destination Model。注意當創建NSMappingModel后不能再更改NSManagedObjectModle,否則CoreData在數據遷移時無法找到Mapping Model文件。這是因為CoreData在數據遷移時識別的是hash version,盡管Model版本未改變,但是由于其內容發生改變,因此hash version也發生變化,導致找不到對應hash version版本之間的Mapping Model。如果一定要更改NSManagedObjectModle,則需要刪除NSMapping Model并重新創建。
  • 第三步:在NSMapping Model中通過簡單手動數據遷移中的步驟實現能識別的屬性和關系遷移。刪除無法從源NSManagedObjectModle中推斷的關系和屬性。
  • 第四部:新建NSEntityMigrationPolicy子類,并將其以“工程名.類名”的方式填入NSMapping Model中對應的實體內。
  • 第五步:根據需要實現下面兩個方法。正如方法名描述的,CoreData將會調用所有實體的對象的第一個方法,完成數據遷移三大步驟(對象的映射,關系的映射,數據校驗)中的第一步,再調用第二個方法完成第二步。
override func createDestinationInstances(forSource sInstance: NSManagedObject, in mapping: NSEntityMapping, manager: NSMigrationManager) throws {
}

override func createRelationships(forDestination dInstance: NSManagedObject, in mapping: NSEntityMapping, manager: NSMigrationManager) throws {    
}

第一個方法的使用如下:

override func createDestinationInstances(forSource sInstance: NSManagedObject, in mapping: NSEntityMapping, manager: NSMigrationManager) throws {
  //1.創建目標對象,Manager有兩個CoreData堆棧,分別用于讀取老的源數據和寫入新的目標數據,因此此處一定要使用destinationContext,由于數據庫遷移并未完成,
  //NSManagedObjectModle還未成功加載,因此無法使用ImageAttachment(contex: NSManagedObjectContext)方法
  let description = NSEntityDescription.entity(forEntityName: "ImageAttachment", in: manager.destinationContext)
  let newAttachment = ImageAttachment(entity: description!, insertInto: manager.destinationContext)
  
  //4. 即使是手動的數據遷移策略,但是大多數屬性的遷移應該使用在Mapping Model中定義的expression實現
  do {
    try traversePropertyMappings(mapping:mapping, block: { (propertyMapping, destinationName) in
      if let valueExpression = propertyMapping.valueExpression {
        let context: NSMutableDictionary = ["source": sInstance]
        guard let destinationValue = valueExpression.expressionValue(with: sInstance, context: context) else {
          return
        }
        newAttachment.setValue(destinationValue, forKey: destinationName)
      }
    })
  } catch let error as NSError {
    print("traversePropertyMappings faild \(error), \(error.userInfo)")
  }
  
  //5. 對在Mapping Model中為無法描述的屬性,此處為新的遷移對象賦值
  if let image = sInstance.value(forKey: "image") as? UIImage {
    newAttachment.setValue(image.size.width, forKey: "width")
    newAttachment.setValue(image.size.height, forKey: "height")
  }
  let body = sInstance.value(forKeyPath: "note.body") as? NSString ?? ""
  newAttachment.setValue(body.substring(to: 80), forKey: "caption")
  
  //6. 將NSMigrationManager與sourceInstance、newAttachment和mapping關聯,以便將來在數據遷移第二階段建立關系階段時,Manager可以正確的拿到需要的對象去建立對象間的關系
  manager.associate(sourceInstance: sInstance, withDestinationInstance: newAttachment, for: mapping)
}

//2. 定義函數,其作用是檢查MappingModel文件中當前實體映射的所以Attribute映射(不含Relationship映射)的有效性
private func traversePropertyMappings(mapping:NSEntityMapping, block: (NSPropertyMapping, String) -> ()) throws {
  if let attributeMappings = mapping.attributeMappings {
    for propertyMapping in attributeMappings {
      if let destinationName = propertyMapping.name {
        block(propertyMapping, destinationName)
      } else {
        //3. 當某個Property Mapping的名字為空時,表示在Mapping Model配置錯誤,拋出異常信息給予提示
        let message = "Attribute destination not configured properly"
        let userInfo = [NSLocalizedFailureReasonErrorKey: message]
        throw NSError(domain: errorDomain, code: 0, userInfo: userInfo)
      }
    }
  } else {
    let message = "No Attribute mappings found!"
    let userInfo = [NSLocalizedFailureReasonErrorKey: message]
    throw NSError(domain: errorDomain, code: 0, userInfo: userInfo)
  }
}

第二個方法中,可以通過Manager拿到dInstance的sourceInstance,并通過Mappingname拿到sourceInstance中某個關系指向的對象以該Mapping映射后的對象,從而完成關系的建立。但是通常,由于我們在關系中勾選了Inverse,因此對于成對出現的關系常常其中一個CoreData會自動映射,因此該方法一般不用。
隨后編譯運行APP,數據遷移完成。

6 漸進式數據遷移

CoreData只能自動執行單個版本之間的數據遷移,多版本之間的數據遷移有兩種策略。第一種,為所有的版本組合創建映射模型,這種方式效率太低,直接廢棄。第二種方式是建立一個策略,讓數據庫一個版本接一個版本遷移到最新版本。
此時需要創建單獨的MigrationManager,使其在數據庫初始化時進行數據遷移工作。

執行數據遷移
performMigration()
單個版本的數據遷移
migrateStoreAt(URL storeURL: URL, fromModel from: NSManagedObjectModel, toModel to: NSManagedObjectModel, mappingModel: NSMappingModel? = nil) -> Bool

以下是完整代碼:

import UIKit
import CoreData

class DataMigrationManager: NSObject {
  let enableMigrations: Bool
  let modelName: String
  let storeName: String = "UnCloudNotesDataModel"
  var stack: CoreDataStack {
    guard enableMigrations, !store(at: storeURL, isCompatibleWithModel: currentModel) else {
      return CoreDataStack(modelName: modelName)
    }
    do {
      try performMigration()
    } catch {
      print(error)
    }
    return CoreDataStack(modelName: modelName)
  }
  private var modelList = [NSManagedObjectModel]()
  
  init(modelNamed: String, enableMigrations: Bool = false) {
    self.modelName = modelNamed
    self.enableMigrations = enableMigrations
    super.init()
  }
  
  private func metadataForStoreAtURL(sroreURL: URL) -> [String: Any] {
    let metadata: [String: Any]
    do {
      metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(ofType: NSSQLiteStoreType, at: sroreURL, options: nil)
    } catch {
      metadata = [:]
      print("Error retrieving metadata for store at URL: \(sroreURL): \(error)")
    }
    return metadata
  }
  
  private func store(at storeURL: URL, isCompatibleWithModel model: NSManagedObjectModel) -> Bool {
    let storeMetadata = metadataForStoreAtURL(sroreURL: storeURL)
    return model.isConfiguration(withName: nil, compatibleWithStoreMetadata: storeMetadata)
  }

  private var applicationSupportURL: URL {
    let path = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true).first
    return URL(fileURLWithPath: path!)
  }
  
  private lazy var storeURL: URL = {
    let storeFileName = "\(self.storeName).sqlite"
    return URL(fileURLWithPath: storeFileName, relativeTo: self.applicationSupportURL)
  }()
  
  private var storeModel: NSManagedObjectModel? {
    return NSManagedObjectModel.modelVersionsFor(modelNamed: modelName).filter{self.store(at: storeURL, isCompatibleWithModel: $0)}.first
  }
  
  private lazy var currentModel: NSManagedObjectModel = NSManagedObjectModel.model(named: self.modelName)
  
  func performMigration() throws {
    // 判斷當前程序的模型版本是否為最新版本,此處采用粗暴方法殺死程序,正常開發中,當前的Model一定為最新版本,此判斷內邏輯不會觸發。但是此處最好采用更溫和的方式。
    if !currentModel.isVersion4 {
      //      fatalError("Can only handle migrations to version 4!")
      print("Can only handle migrations to version 4!")
      return
    }

    // 準備當前工程的所有NSManagedObjectModle文件
    modelList = NSManagedObjectModel.modelVersionsFor(modelNamed: "UnCloudNotesDataModel")

    //查找數據庫對應的NSManagedObjectModle
    guard let currentStoreModel = self.storeModel else {
      let message = "Can not find current store model"
      let userInfo = [NSLocalizedFailureReasonErrorKey: message]
      throw NSError(domain: errorDomain, code: 0, userInfo: userInfo)
    }
    
    //查找數據庫對應的NSManagedObjectModle,和最近的下一個版本NSManagedObjectModle在數組中的索引
    guard var sourceModelIndex = modelList.index(of: currentStoreModel) else {
      let message = "Store model is not within momd folder named with current project's name"
      let userInfo = [NSLocalizedFailureReasonErrorKey: message]
      throw NSError(domain: errorDomain, code: 0, userInfo: userInfo)
    }
    var destModelIndex = sourceModelIndex + 1
    
    // 取出目標NSManagedObjectModle
    while destModelIndex < modelList.count {
      let sourceModel = modelList[sourceModelIndex]
      let destModel = modelList[destModelIndex]
      let mappingModel = NSMappingModel(from: nil, forSourceModel: sourceModel, destinationModel: destModel)
      let success = migrateStoreAt(URL: storeURL, fromModel: sourceModel, toModel: destModel, mappingModel: mappingModel)
      if !success {
        let message = "One sub-migration stage is failed"
        let userInfo = [NSLocalizedFailureReasonErrorKey: message]
        throw NSError(domain: errorDomain, code: 0, userInfo: userInfo)
      } else {
        sourceModelIndex = destModelIndex
        destModelIndex += 1
      }
    }
  }

  private func migrateStoreAt(URL storeURL: URL, fromModel from: NSManagedObjectModel, toModel to: NSManagedObjectModel, mappingModel: NSMappingModel? = nil) -> Bool {
    //1 創建遷移管理器
    let migrationManager = NSMigrationManager(sourceModel: from, destinationModel: to)
    migrationManager.addObserver(self, forKeyPath: "migrationProgress", options: .new, context: nil)
    
    //2 確定映射模型
    var migrationMappingModel: NSMappingModel
    var mappingSource: String
    if let mappingModel = mappingModel {
      migrationMappingModel = mappingModel
      mappingSource = "Coustom define"
    } else {
      migrationMappingModel = try! NSMappingModel.inferredMappingModel(forSourceModel: from, destinationModel: to)
      mappingSource = "CoreData infer"
    }
    
    //3 創建臨時的文件路徑URL,存儲遷移后的數據庫
    let targetURL = storeURL.deletingLastPathComponent()
    let destinationName = storeURL.lastPathComponent + "~1"
    let destinationURL = targetURL.appendingPathComponent(destinationName)
    print("Migration start ===========================================")
    print("From Model: \(from.entityVersionHashesByName)")
    print("To Model: \(to.entityVersionHashesByName)")
    print("Mapping model: %@", mappingSource)
    print("Migrating store \(storeURL) to \(destinationURL)")

    //4 進行數據遷移
    let success: Bool
    do {
      try migrationManager.migrateStore(from: storeURL, sourceType: NSSQLiteStoreType, options: nil, with: migrationMappingModel, toDestinationURL: destinationURL, destinationType: NSSQLiteStoreType, destinationOptions: nil)
      success = true
    } catch {
      success = false
      print("Store Migration failed: \(error)")
    }
    
    //5 數據遷移成功后刪除源數據庫,并將新數據庫移動回原路徑
    if success {
      print("Store Migration Completed Successfully")
      
      let fileManager = FileManager.default
      do {
        try fileManager.removeItem(at: storeURL)
        try fileManager.moveItem(at: destinationURL, to: storeURL)
        print("Replace store file completed successfully")
      } catch {
        print("Replace store file faild, Error: \(error)")
      }
    }
    migrationManager.removeObserver(self, forKeyPath: "migrationProgress", context: nil)
    return success
  }

  override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
    if keyPath == "migrationProgress" {
      super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context);
    }
  }
}

extension NSManagedObjectModel {
  class var version4: NSManagedObjectModel {
    return unCloudNotesModel(named: "UnCloudNotesDataModelv4")
  }
  var isVersion4: Bool {
    return self == type(of: self).version4
  }
  
  private class func modelURLs(in modelFolder: String) -> [URL] {
    return Bundle.main.urls(forResourcesWithExtension: "mom", subdirectory: "\(modelFolder).momd") ?? []
  }
  
  class func modelVersionsFor(modelNamed modelName: String) -> [NSManagedObjectModel] {
    return modelURLs(in: modelName).flatMap(NSManagedObjectModel.init)
  }
  
  class func unCloudNotesModel(named modelName: String) -> NSManagedObjectModel {
    let model = modelURLs(in: "UnCloudNotesDataModel").filter {$0.lastPathComponent == "\(modelName).mom" }.first.flatMap(NSManagedObjectModel.init)
    return model ?? NSManagedObjectModel()
  }
  
  // 找到momd文件夾,當用此路徑創建NSManagedObjectModle時CoreData會查詢當前版本的Model路徑URL并實例化一個Model對象。Warning:該方法只有當工程具有多個版本的NSManagedObjectModle時有效
  class func model(named modelName: String, in bundle:Bundle = .main) -> NSManagedObjectModel {
    return bundle.url(forResource: modelName, withExtension: "momd").flatMap(NSManagedObjectModel.init) ?? NSManagedObjectModel();
  }
}

//  判斷NSManagedObjectModle是同一個版本需要判斷其中實體數組是否相同
func == (firstModel: NSManagedObjectModel, otherModel: NSManagedObjectModel) -> Bool {
  return firstModel.entitiesByName == otherModel.entitiesByName;
}

7 使用三方框架的數據遷移

通常在使用CoreData時候并不會自己動手建立CoreData棧,最常用的第三方框架是MagicRecord,在使用MagicRecord時,其內部默認開啟自動數據遷移和自動推斷映射模型。通常其初始化方法需要在APPDelegate的didFinishLaunchApplication中進行,而數據庫遷移需要在其初始化方法前進行。MagicRecord通常只需要一個數據庫名稱完成初始化,可以通過NSPersistentStore.MR_urlForStoreName(storeName)獲取數據庫路徑,執行數據遷移工作。

+ (NSDictionary *) MR_autoMigrationOptions {
    // Adding the journalling mode recommended by apple
    NSMutableDictionary *sqliteOptions = [NSMutableDictionary dictionary];
    [sqliteOptions setObject:@"WAL" forKey:@"journal_mode"];
    
    NSDictionary *options = [NSDictionary dictionaryWithObjectsAndKeys:
                             [NSNumber numberWithBool:YES], NSMigratePersistentStoresAutomaticallyOption,
                             [NSNumber numberWithBool:YES], NSInferMappingModelAutomaticallyOption,
                             sqliteOptions, NSSQLitePragmasOption,
                             nil];
    return options;
}

8 單元測試

在使用CoreData開發App時,有時我們需要測試單個方法是否有效。如果按照慣例直接運行程序查看否個方法或者摸個邏輯是否正確,并且直接對數據庫進行修改,可能會面臨以下問題。官網教程

  • 為了測試一個小的邏輯,需要運行整個程序,還需要進入到目標頁面觸發特定邏輯,這樣很耗時間。
  • 團隊協作開發中,并不希望自己的模塊受到其他開發者模塊變動的影響,即當別人改變數據后導致自己的模塊無法測試。
  • 在對某些設備測試時并不想破壞其中已有的數據。

此時,單元測試能很好的提升工作效率。它并不需要運行整個程序,直接對需要測試部分邏輯檢查。為了更好解決上述問題,單元測試必須符合以下幾個標準。

  • 快:單元測試的運行時間要盡量低,即其邏輯滿足測試需要即可。
  • 獨立:將需要測試的邏輯盡量拆分,每個單元僅賦值獨立的一小塊邏輯,并且單元之間相互不會干擾。
  • 可重復:基于同樣的代碼多次測試應該得到相同的結果,因此單元測試時數據庫需要用in-memory store的方式,確保每次測試后數據庫不會被改變。
  • 自校驗:測試結果需要指出失敗和成功。
  • 時效性:單元測試需要在正常邏輯代碼完成后建立。

對于已有數據的APP,將這CoreData持久化類型改為in-memory store后,在初始化數據庫完成后,不會加載同一URL的數據庫,相反會創建一個新的數據庫,并且每次測試對數據的改動在測試完成后都將被清空,并且將其持久化類型改回SQLite后,原數據依舊存在。

開啟單元測試的方式可以是在建立工程時候直接勾選UnitTest或者在工程中新增類型為UnitTest的Target。接下來為需要測試的模塊新建一個類型為UnitTest的文件。并為每一個需要測試的邏輯新建一個方法。在XCTestCase的子類中setUp()方法會在每次測試開始時調用,而tearDown()會在每次測試結束后調用。

對于同步執行的方法的測試,可以被測試方法執行完后,直接檢測其執行結果。對于異步執行的方法的測試,可以通過創建expectation的方式完成,并為這個期望通過waitForException的方式設置一個超時時間。expectation的創建方式有一下三種。

func expectationTest1() {
  let expection = expectation(description: "Done!")
  someService.callMethodWithCompletionHandler() {
    expection.fulfill()
  }
  waitForExpectations(timeout: 2.0, handler: nil)
}

func expectationTest2() {
  let predicate = NSPredicate(format: "name = %@", "1")
  expectation(for: predicate, evaluatedWith: self) { () -> Bool in
    return true
  }
  waitForExpectations(timeout: 2.0, handler: nil)
}

func expectationTest3() {
  expectation(forNotification: NSNotification.Name.NSManagedObjectContextDidSave.rawValue, object: derivedContext) { (notification) -> Bool in
    return true
  }
  let camper = camperService.addCamper("Bacon Lover", phoneNumber: "910-543-9000")
  XCTAssertNotNil(camper)
  
  waitForExpectations(timeout: 2.0) { (error) in
    XCTAssertNil(error, "Save did not occur")
  }
}

其中方法一通過創建一個expectation,并在某個異步方法的回調中手動觸發expectation,從而觸發waitForExpectations中的回調。方法二通過KVO的方式觀察某個對象的屬性,自動觸發waitForExpectations中的回調。方法三通過監測某個通知從而自動觸發waitForExpectations中的回調。

以下是完整的某個邏輯的單元測試代碼,在完成代碼后。可以通過點擊空心菱形進行單元測試,如果通過菱形將會變綠,否則將會變為紅色的叉,此時就需監測工程中正式的邏輯代碼和單元測試中的代碼,判斷是哪部分代碼出錯并進行更改。

import XCTest
import CampgroundManager
import CoreData

class CamperServiceTests: XCTestCase {
  // Forced unwrapping make sure this line of code can be compiled successfully without
  // the specification of default values of them required at initialization method. Developer
  // should make sure these variables have a value befor using them.
  var camperService: CamperService!
  var coreDataStack: CoreDataStack!
    
  override func setUp() {
    super.setUp()
    coreDataStack = TestCoreDataStack()
    camperService = CamperService(managedObjectContext: coreDataStack.mainContext, coreDataStack: coreDataStack)
  }
  
  override func tearDown() {
    super.tearDown()
    camperService = nil
    coreDataStack = nil
  }
  
  // This test creates a camper and checks the attribute, but does not store anything to persistent
  // store, because the saving action are excuted at background thread
  func testAddCamper() {
    let camper = camperService.addCamper("Bacon Lover", phoneNumber: "910-543-9000")
    XCTAssert((camper != nil), "Camper should not be nil")
    XCTAssert(camper?.fullName == "Bacon Lover")
    XCTAssert(camper?.phoneNumber == "910-543-9000")
  }
  
  // This test creates a camper and checks if the store is successful. Maincontext are pass to
  // the inition method of CamperService, because the maincontext also save the object 
  // after it stored by derived thread. More details are wthin the addCamper method.
  func testRootContextIsSavedAfterAddingCamper() {
    let derivedContext = coreDataStack.newDerivedContext()
    camperService = CamperService(managedObjectContext: coreDataStack.mainContext, coreDataStack: coreDataStack)
    
    expectation(forNotification: NSNotification.Name.NSManagedObjectContextDidSave.rawValue, object: derivedContext) { (notification) -> Bool in
      return true
    }
    
    let camper = camperService.addCamper("Bacon Lover", phoneNumber: "910-543-9000")
    XCTAssertNotNil(camper)
    
    waitForExpectations(timeout: 2.0) { (error) in
      XCTAssertNil(error, "Save did not occur")
    }
  }
}

測試驅動開發模式(TDD-Test-Driven Development)指的是通過單元測試來驗證單個功能邏輯是否正確,根據其結果進行下一步開發。只是在實際開發中并沒有這么多閑心。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,119評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,382評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,038評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,853評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,616評論 6 408
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,112評論 1 323
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,192評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,355評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,869評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,727評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,928評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,467評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,165評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,570評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,813評論 1 282
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,585評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,892評論 2 372

推薦閱讀更多精彩內容