CoreData持久性代码的灵活轻松的单元测试

现代和高质量的iOS应用程序有望完美运行。 确保完美无瑕,耐回归的代码的重要输入是在开发过程中添加全面的单元和集成测试。 本文逐步介绍了使用CoreData作为其持久层为iOS应用程序构建可重复的自动化数据库单元测试的方法。

目标受众

本文假定您了解在iOS应用程序中使用CoreData的基础知识,并且可能已在自己的工作中使用它。 但是,本文的重点是体系结构,即使您不知道如何使用CoreData进行编码,但如果您了解iOS中的数据持久性和单元测试的基础,此处的概念仍然有意义。

代码样例

本文中的代码和概念是使用Xcode 10(测试版)和Swift 4.2开发的。

本文包括用于说明概念的代码摘录,但并未在本文的正文中嵌入此解决方案的所有代码,而是在本文结尾处提供了指向我的GitHub存储库中的示例应用程序的链接。

CoreData是iOS(和macOS)应用程序的默认本地持久性选择。 核心数据从根本上讲是持久化数据存储上的对象关系映射(ORM)层。 虽然CoreData对象的物理存储是从开发人员抽象出来的,但CoreData几乎总是与SQLite一起使用。

如果您是CoreData的新手,或者只是需要复习,那么这里有很多很棒的资源,例如Apple自己的Core Data编程指南和RayWenderlich.com上的Core Data入门指南。

以下是典型应用程序如何访问CoreData的高度简化图。 我将在下面讨论架构的每个元素。

AppDelegate。 该对象表示iOS应用程序的入口点,并且所有iOS开发人员都应该熟悉。 如果使用 Xcode 10中的“ 使用CoreData”选项创建项目,则Xcode将为您创建基本的CoreData堆栈。 在AppDelegate对象中,您会发现以下属性爆炸了。

本质上,此属性是您的应用程序用来访问CoreData管理的数据的挂钩。

   AppDelegate:UIResponder,UIApplicationDelegate { 


惰性 varpersistentContainer:NSPersistentContainer = {...}


}

NSPersistentContainer属性中具有一个设置,该设置指定是将其数据保存到SQLite磁盘文件(NSSQLiteStoreType) ,内存(NSInMemoryStoreType)还是其他位置(NSBinaryStoreType) 。 后一种情况很少见,在本文中我将不再讨论。 如果未指定任何设置(默认设置), CoreData将使用NSSQLiteStoreType来配置容器。

.xcdatamodel。 当创建具有CoreData支持的项目时,Xcode将自动创建一个数据模型文件 ,其根名称与新项目名称和扩展名xcdatamodel相匹配。 Xcode数据模型编辑器将您不断发展的设计存储在此文件中,并使用此元数据文件为您生成低级CoreData实体模型类。 在Xcode 10中,生成的模型类将自动提供给您的XCTest目标(在某些旧版本的Xcode中不是这种情况,是的!)。

存储管理器。 虽然直接在整个应用程序中访问CoreData和自动生成的实体模型类当然是可能的并且可以接受的,但是将数据操作封装在服务类中是很常见的。 在这种架构中,我已经做到了。 这种方法简化了其余应用程序的数据访问代码,并提供了某种程度的封装,以防基础数据库物理层将来发生变化。

初始化StorageManager对象后(请参阅这些项目符号中标注的图中的红色圆圈数字):

  • 它使用。 xcdatamodel (1)生成了用于执行基础数据库访问的模型类。
  • 它将使用在AppDelegate类中实例化的全局persistentContainer对象(2),该对象使用默认的SQLite(3)进行数据存储。

生产应用代码(例如ViewController)。 图表中的此框表示在应用程序中获取或保存数据的位置。 这可能是View Controller,View Model或您自己编写的其他类中的代码。 在这种体系结构中,所有此类访问都是通过调用StorageManager对象的方法进行的,而不是直接与CoreData进行交互。

SQLite数据库。 在生产应用程序中,StorageManager获取并更改数据库对存储在应用程序沙箱中的物理文件的更改,如上图中的(3)所示。 这些更改不在RAM中,并且数据库在程序运行之间保持不变。

本文的主要目标是创建一个混合体系结构,其中将持久SQLite数据库用于生产应用程序,而将易失性内存数据库用于单元测试。

单元测试的基本要求是,在每次单元测试运行开始时,应用程序状态应完全相同。 拥有基于磁盘的SQLite数据库对这一要求提出了挑战。 根据定义,数据库文件是持久文件,因此按定义运行的每个测试都会影响它们的状态,并且无法保证每个测试都是相同的。

也就是说,我们可以使用现有的CoreData配置简单而轻松地将单元测试添加到项目中。 生成的体系结构如下:

在这种方法中,生产应用程序单元测试目标都使用相同的StorageManager和xcdatamodel生成的模型类。 这很好,因为数据访问对象和调用方法未更改。

但是,问题在于,应用程序目标和测试目标都将使用相同的容器类型,并使用默认的SQLite设置(1)配置该容器类型,从而导致使用基于磁盘的数据存储区(2)进行单元测试,而不会所有测试运行均以相同的状态开始-无需编写其他测试前初始化代码。

我们可以通过以下方式之一重新初始化数据库来应对这一挑战:

  1. 截断所有表
  2. 每次单元测试之前,删除并重新创建与数据库关联的磁盘文件。

每种方法都可能是合理的,并且确保在每次单元测试之前所有磁盘文件的状态都相同。 但是,这些方法中的每一种都需要额外的代码来实现,并且随着数据库的不断发展,可能需要额外的维护。 如果只有一种更简单的方法,那就是!

通过利用CoreData的容器抽象,我们有了第三种方法(更优雅),根本不需要物理磁盘文件操作。

为了在每次测试开始之前为单元测试提供一个干净,一致的环境,只需要对现有代码库进行少量更改即可。 实际上,如果将下面的架构图与上一个架构图进行比较,则会注意到没有其他代码模块。

编码更改是在单元测试代码中创建一个自定义 NSPersistentContainer-一种继续使用xcdatamodel生成的CoreData模型类,但提供了一个PersistentContainer ,该PersistentContainer配置为使用易失性内存持久存储组件。 这就是CoreData在编程模型和物理存储模型之间的抽象发挥作用的地方。

运行单元测试时,定制的内存支持容器将传递到存储管理器(1),该管理器已配置用于内存数据存储。

相比之下,生产应用程序在传递Container对象的情况下初始化StorageManager。 在这种情况下,StorageManager使用在AppDelegate(2)中配置的容器,该容器使用默认的SQLite容器类型。

CoreData将根据容器配置自动使用SQLite或内存进行数据库访问(3)。

使该策略起作用的关键是根据是从主要App目标还是单元测试目标使用它来不同地初始化StorageManager。 以下是每种情况下初始化的简化版本。

生产应用程序目标访问数据库时,它将始终使用作为AppDelegate的全局属性创建的persistentContainer,如下面的节略代码摘录所示。

请注意,此初始化非常简单,CoreData将使用其默认的SQLite存储配置。

摘录的AppDelegate摘录

 类AppDelegate:UIResponder,UIApplicationDelegate {惰性varpersistentContainer:NSPersistentContainer = { 
让容器= NSPersistentContainer(名称:“ CoreDataUnitTesting”)
container.loadPersistentStores(completionHandler:{(storeDescription,error)在



})
返回容器
}()
}

要使用此默认SQLite CoreData堆栈,应用程序代码仅需要创建一个StorageManager实例并调用其方法。 每当未提供自定义容器时,StorageManager就会使用AppDelegate.persistentContainer。

摘录的ViewController摘录

 类ViewController:UIViewController { 
@IBAction func saveButtonTapped(_ sender:Any){
让mgr = StorageManager()

如果让city = cityField.text,则让country = countryField.text {
mgr.insertPlace(城市:城市,国家/地区)
mgr.save()
}
}
}
}

当单元测试访问数据时,单元测试目标将创建其自己的自定义容器,然后将其传递给StorageManager类初始化程序。

StorageManager不知道持久层将在内存中(并且不在乎)。 它只是将给定的容器传递给CoreData,后者处理基础细节。

以下是单元测试类的简化示例。

CoreDataUnitTestingTests摘录

 类CoreDataUnitTestingTests:XCTestCase { 

//此类使用内存中的数据支持实例化其自己的自定义存储管理器
var customStorageManager:StorageManager?//使用内存容器单元测试需要从主包中加载xcdatamodel
varmanagedObjectModel:NSManagedObjectModel = {
让managedObjectModel = NSManagedObjectModel.mergedModel(来自:[Bundle.main])!
返回managedObjectModel
}()// customStorageManager通过提供一个自定义NSPersistentContainer来指定内存
懒惰的var嘲笑PersistantContainer:NSPersistentContainer = {
让容器= NSPersistentContainer(名称:“ CoreDataUnitTesting”,managedObjectModel:self.managedObjectModel)
让description = NSPersistentStoreDescription()
description.type = NSInMemoryStoreType
description.shouldAddStoreAsynchronously = false

container.persistentStoreDescriptions = [说明]
container.loadPersistentStores {(描述,错误)在



}
返回容器
}()//在每次单元测试之前,都会调用setUp,它会创建一个新的,空的内存数据库供测试使用
覆盖func setUp(){
super.setUp()
customStorageManager = StorageManager(容器:mockPersistantContainer)
} //单元测试如何使用customStorageManager的示例
func testCheckEmpty(){
如果让mgr = self.customStorageManager {
让行= mgr.fetchAll()
XCTAssertEqual(rows.count,0)
}其他{
XCTFail()
}
}
}

请注意前面的代码示例中的以下几点:

  1. 一个主要的区别是NSPersistentContainer定义与AppDelegate版本。 此版本使用可选的内存存储覆盖默认的SQLite存储行为。
  2. 由于用于测试的xcdatamodel是主应用程序捆绑包的一部分,因此有必要通过初始化NSManagedObjectModel明确地引用它。 在AppDelegate中 ,这不是必需的,因为模型和容器存在于同一名称空间中。
  3. StorageManager的初始化包括内存中的容器,而在先前的ViewController代码中,不带参数的StorageManager的便捷初始化程序用于使用默认的SQLite容器初始化CoreData堆栈。

虽然始终有不止一种方法来实现可靠的测试架构,但这并不是唯一的好解决方案,但这种架构方法具有一些明显的优势:

  1. 通过使用内存中(而不是SQLite)进行单元测试,我们可以肯定地知道,测试代码所依据的数据库中绝不会包含先前测试的残余。
  2. 使用内存可以避免在测试运行之前编写和维护用于清除数据对象或删除物理文件的代码。 根据定义,我们为每个单元测试的每次运行都获得了一个全新的新数据库。
  3. 如果我们已经在使用StorageManager模式封装CoreData调用(无论如何,这都是一个好习惯),那么只需将便捷的初始化程序添加到StorageManager对象中,就可以将该模式应用于现有项目!
  4. 使用现成的Xcode和iOS SDK组件可以完全实现这种方法。

我的GitHub帐户中提供了包含上述架构的完整,可运行示例应用程序的代码。 将其用于对该技术的进一步研究,和/或用作您自己的项目的样板。

GitHub CoreDataUnitTesting存储库