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)指的是通过单元测试来验证单个功能逻辑是否正确,根据其结果进行下一步开发。只是在实际开发中并没有这么多闲心。

推荐阅读更多精彩内容