数据持久化方案解析(十三) —— 基于Unit Testing的Core Data测试(一)

版本记录

版本号 时间
V1.0 2020.08.19 星期三

前言

数据的持久化存储是移动端不可避免的一个问题,很多时候的业务逻辑都需要我们进行本地化存储解决和完成,我们可以采用很多持久化存储方案,比如说plist文件(属性列表)、preference(偏好设置)、NSKeyedArchiver(归档)、SQLite 3CoreData,这里基本上我们都用过。这几种方案各有优缺点,其中,CoreData是苹果极力推荐我们使用的一种方式,我已经将它分离出去一个专题进行说明讲解。这个专题主要就是针对另外几种数据持久化存储方案而设立。
1. 数据持久化方案解析(一) —— 一个简单的基于SQLite持久化方案示例(一)
2. 数据持久化方案解析(二) —— 一个简单的基于SQLite持久化方案示例(二)
3. 数据持久化方案解析(三) —— 基于NSCoding的持久化存储(一)
4. 数据持久化方案解析(四) —— 基于NSCoding的持久化存储(二)
5. 数据持久化方案解析(五) —— 基于Realm的持久化存储(一)
6. 数据持久化方案解析(六) —— 基于Realm的持久化存储(二)
7. 数据持久化方案解析(七) —— 基于Realm的持久化存储(三)
8. 数据持久化方案解析(八) —— UIDocument的数据存储(一)
9. 数据持久化方案解析(九) —— UIDocument的数据存储(二)
10. 数据持久化方案解析(十) —— UIDocument的数据存储(三)
11. 数据持久化方案解析(十一) —— 基于Core Data 和 SwiftUI的数据存储示例(一)
12. 数据持久化方案解析(十二) —— 基于Core Data 和 SwiftUI的数据存储示例(二)

开始

首先看下主要内容:

测试代码是应用程序开发的关键部分,Core Data也不例外。 本教程将教您如何测试Core Data。内容来自翻译。

下面就是写作环境:

Swift 5, iOS 13, Xcode 11

测试代码是应用开发过程中至关重要的一部分。 尽管测试最初需要一段时间才能习惯,但它还是有很多好处,例如:

  • 允许您进行更改,而不必担心应用程序的某些部分会损坏。
  • 加速调试过程。
  • 迫使您考虑如何以更有条理的方式组织代码。

并且在本教程中,您将学习如何将测试的好处应用于Core Data模型。

您将使用PandemicReport,这是一个简单但出色的大流行报告跟踪器。 您将专注于为项目的Core Data模型编写单元测试,并学习:

  • 什么是单元测试及其重要性。
  • 如何编写适合测试的Core Data Stack
  • 如何对Core Data模型进行单元测试。
  • 关于TDD方法论。

注意:本教程假定您了解Core Data的基础知识。 如果您不熟悉Core Data,请首先查看 Getting Started with Core Data Tutorial。

在入门项目中,您会找到PandemicTracker,这是一个显示感染报告列表的应用程序。

您可以添加新报告并编辑现有报告。 该应用程序使用Core Data持久保存会话之间的报告。 构建并运行以签出该应用程序。

该应用程序显示保存在Core Data中的大流行报告列表。 目前,您没有任何报告。 通过单击导航栏中的添加按钮来添加一个。

然后,通过在text field中输入值来添加报告条目。

接下来,点击Save以将您的报告保存到Core Data并关闭此屏幕。

现在,该列表包含您的条目。

在Xcode中,查看要处理的主要文件:

  • CoreDataStack.swift:对象包装器,用于管理应用的Core Data model层。
  • ReportService.swift:管理应用的业务逻辑。
  • ViewController.swift:显示保存在Core Data中的报告列表。 点击+将显示新报告的输入表单。
  • ReportDetailsTableViewController.swift:显示所选报告的详细信息,并允许您编辑现有值。 当您在ViewController中点击+时,这也充当输入形式。

您将探索为什么为Core Data编写单元测试会比后面几节中讲的要棘手。


What is Unit Testing?

单元测试是将项目分解为较小的可测试代码段的任务。 例如,您可以将iPhone上的Messages逻辑分解为较小的功能单元,如下所示:

  • 将一个或多个收件人分配给该邮件。
  • 在文本区域中写入文本。
  • 添加表情符号。
  • 添加图像。
  • 附加GIF
  • 附加Animoji

尽管这似乎是很多额外的工作,但是测试有很多好处:

  • 单元测试可验证您的代码是否按预期工作。
  • 编写测试可以在bug投入生产之前就将它们捕获。
  • 测试还充当其他开发人员的文档。
  • 与手动测试相比,单元测试可以节省时间。
  • 在开发过程中失败的测试使您知道某些问题。

在iOS中,单元测试在与您要测试的应用程序相同的环境中运行。 因此,如果正在运行的测试修改了应用的状态,则可能会导致问题。

注意:如果您要开始在iOS中进行测试,或者想复习一下,请查看iOS Unit Testing and UI Testing。


CoreData Stack for Testing

该项目的Core Data stack当前使用SQLite数据库作为其存储。运行测试时,您不希望测试或虚拟数据干扰应用程序的存储。

要编写好的单元测试,请遵循首字母缩写词FIRST

  • Fast:单元测试运行迅速。
  • Isolated:它们应独立于其他测试运行。
  • Repeatable:每次执行测试都应产生相同的结果。
  • Self-verifying:测试应该通过或失败。您无需检查控制台或日志文件即可确定测试是否成功。
  • Timely:首先编写测试,以便它们可以充当您添加的功能的蓝图。

Core Data将数据写入并保存到模拟器或设备上的数据库文件中。由于一项测试可能会覆盖另一项测试的内容,因此您不能将其视为Isolated

由于数据保存到磁盘,因此数据库中的数据会随着时间增长,并且每次测试运行时环境的状态可能会有所不同。结果,这些测试是不可Repeatable

测试完成后,删除并重新创建数据库内容并不Fast

您可能会想,“好吧,我猜我无法测试Core Data,因为它无法测试”。再想一想。

解决方案是创建一个使用内存存储in-memory store而不是当前SQLite存储的Core Data stack子类。由于内存存储区不会持久存储在磁盘上,因此当测试完成执行时,in-memory store将释放其数据。

您将在下一部分中创建此子类。

1. Adding the TestCoreDataStack

首先,在PandemicReportTests组下创建CoreDataStack的子类,并将其命名为TestCoreDataStack.swift

接着,添加下面到文件中:

import CoreData
import PandemicReport

class TestCoreDataStack: CoreDataStack {
  override init() {
    super.init()

    // 1
    let persistentStoreDescription = NSPersistentStoreDescription()
    persistentStoreDescription.type = NSInMemoryStoreType

    // 2
    let container = NSPersistentContainer(
      name: CoreDataStack.modelName,
      managedObjectModel: CoreDataStack.model)

    // 3
    container.persistentStoreDescriptions = [persistentStoreDescription]

    container.loadPersistentStores { _, error in
      if let error = error as NSError? {
        fatalError("Unresolved error \(error), \(error.userInfo)")
      }
    }

    // 4
    storeContainer = container
  }
}

在这里,代码:

  • 1) 创建一个内存持久存储(persistent store)
  • 2) 创建一个NSPersistentContainer实例,并传入存储在CoreDataStack中的modelNameNSManageObjectModel
  • 3) 将in-memory persistent store分配给容器。
  • 4) 覆盖CoreDataStack中的storeContainer

真好! 有了这个类,您就有了为Core Data Model创建测试的基线

2. Different Stores

在上面,您使用了一个内存存储(in-memory store),但是您可能想知道还有哪些其他选择。 Core Data中有四个永久存储(persistent store)可用:

  • NSSQLiteStoreType:用于Core Data的最常见存储由SQLite数据库支持。 Xcode的Core Data Template默认情况下使用此代码,同时也是项目中使用的store
  • NSXMLStoreType:由XML文件支持。
  • NSBinaryStoreType:由二进制数据文件支持。
  • NSInMemoryStoreType:此存储类型会将数据保存到内存,因此不会持久保存。 这对于单元测试很有用,因为如果应用终止,数据就会消失。

注意:如果您想了解有关Core Data中不同存贮的更多信息,请查看Apple Documentation on Persistent Store Types。

完成此步骤后,就该编写第一个测试了。


Writing Your First Test

PandemicReport中,ReportService是用于处理CRUD逻辑的小类。 CRUDCreate-Read-Update-Delete(持久存储最常见的功能)的首字母缩写。 您将编写单元测试以验证该功能是否正常运行。

要编写测试,您将使用Xcode中的XCTest框架和XCTestCase子类。

首先在PandemicReportTests下创建一个新的Unit Test Case Class,并将其命名为ReportServiceTests.swift

然后,在ReportServiceTests.swift中,在import XCTest下添加以下代码:

@testable import PandemicReport
import CoreData

此代码将应用程序和CoreData框架导入到您的测试用例中。

接下来,将以下两个属性添加到ReportServiceTests的顶部:

var reportService: ReportService!
var coreDataStack: CoreDataStack!

这些属性包含对要测试的ReportServiceCoreDataStack的引用。

返回ReportServiceTests.swift,删除以下内容:

  • 1) setUpWithError()
  • 2) tearDownWithError()
  • 3) testExample()
  • 4) testPerformanceExample()

您将看到为什么接下来不需要它们的原因。

1. The Set Up and Tear Down

您的单元测试应该是孤立且可重复的(isolated and repeatable)XCTestCase有两种方法setUp()tearDown(),用于在每次运行之前设置测试用例并在之后清除所有测试数据。 由于每个测试都从一个干净的表盘开始,因此这些方法有助于使您的测试孤立且可重复。

在声明的属性下添加以下代码:

override func setUp() {
  super.setUp()
  coreDataStack = TestCoreDataStack()
  reportService = ReportService(
    managedObjectContext: coreDataStack.mainContext,
    coreDataStack: coreDataStack)
}

在这里,您将初始化您先前实现的TestCoreDataStack以及ReportService。 如前所述,TestCoreDataStack使用内存中的存储(in-memory store),并在每次执行setUp()时进行初始化。 因此,所有创建的PandemicReport都不会在每次测试之间持久存在。

另一方面,tearDown()会在每次测试运行后重置数据。

返回ReportServiceTests,添加以下内容:

override func tearDown() {
  super.tearDown()
  reportService = nil
  coreDataStack = nil
}

这段代码将属性设置为nil,为下一次测试做准备。

完成set uptear down后,您现在可以专注于测试报告的CRUD

2. Adding a Report

现在,您将通过编写一个简单的测试来检验该应用程序的现有功能,以验证ReportServiceadd(_:numberTested:numberPositive:numberNegative :)功能。

仍在ReportServiceTests中,创建一个新方法:

func testAddReport() {
  // 1
  let report = reportService.add(
    "Death Star", 
    numberTested: 1000,
    numberPositive: 999, 
    numberNegative: 1)

  // 2
  XCTAssertNotNil(report, "Report should not be nil")
  XCTAssertTrue(report.location == "Death Star")
  XCTAssertTrue(report.numberTested == 1000)
  XCTAssertTrue(report.numberPositive == 999)
  XCTAssertTrue(report.numberNegative == 1)
  XCTAssertNotNil(report.id, "id should not be nil")
  XCTAssertNotNil(report.dateReported, "dateReported should not be nil")
}

此测试验证add(_:numberTested:numberPositive:numberNegative :)创建具有指定值的PandemicReport

您添加的代码:

  • 1) 创建一个PandemicReport
  • 2) 断言输入值与创建的PandemicReport相匹配。

要运行此测试,请单击Product > Test或按Command + U作为快捷方式。 或者,您可以打开Test导航器,然后选择PandemicReportsTest并单击play

该项目将构建并运行测试。 您会看到一个绿色的选中标记。

恭喜你! 您已经编写了第一个测试。

接下来,您将学习如何测试异步代码。


Testing Asynchronous Code

保存数据是Core Data最重要的任务。 虽然您的测试很棒,但它不会测试数据是否保存到持久性存储中。 它可以直接运行,因为该应用程序使用一个单独的队列在后台保留数据。

将数据保存在主线程上可能会阻塞UI,使其无响应。 但是,测试异步代码要复杂一些。 具体来说,由于您不知道后台任务何时完成,因此XCTAssert无法测试您的数据是否保存。

您可以通过将add(_:numberTested:numberPositive:numberNegative :)调用包装在perform(_ :)中,在与当前上下文关联的线程上执行工作来解决此问题。 然后,您需要将perform(_ :)expectation配对,以在保存完成时通知测试。

这就是它的样子。

1. Testing Save

仍在ReportServiceTests内,添加:

func testRootContextIsSavedAfterAddingReport() {
  // 1
  let derivedContext = coreDataStack.newDerivedContext()
  reportService = ReportService(
    managedObjectContext: derivedContext,
    coreDataStack: coreDataStack)
    
  // 2
  expectation(
    forNotification: .NSManagedObjectContextDidSave,
    object: coreDataStack.mainContext) { _ in
      return true
  }

  // 3
  derivedContext.perform {
    let report = self.reportService.add(
      "Death Star 2", 
      numberTested: 600,
      numberPositive: 599, 
      numberNegative: 1)

    XCTAssertNotNil(report)
  }

  // 4
  waitForExpectations(timeout: 2.0) { error in
    XCTAssertNil(error, "Save did not occur")
  }
}

这是这样做的:

  • 1) 创建背景上下文和使用该上下文的ReportService的新实例。
  • 2) 创建一个expectation,该期望在Core Data stack发送NSManagedObjectContextDidSave通知事件时将信号发送到测试用例。
  • 3) 它将在perform(_ :)块内添加一个新报告。
  • 4) 测试等待报告保存的信号。 如果等待时间超过两秒,则测试将失败。

期望是测试异步代码时的强大工具,因为期望可以使您暂停代码并等待异步任务完成。

注意:要了解有关以预期方式测试异步操作的更多信息,请查看有关该主题的Apple文档Apple’s Documentation。

现在,运行测试,并在其旁边看到一个绿色的选中标记。

编写测试可以帮助您发现bugs并提供有关函数行为的文档。但是,如果您编写了失败的测试并且始终通过了该怎么办?现在该做一些TDD了!


Test Driven Development (TDD)

Test Driven Development, or TDD 是一个开发过程,您可以在生产代码之前编写测试。通过首先编写测试,您可以确保代码可测试并开发为满足所有要求。

首先,编写最少的代码以使测试通过。然后,您逐步对功能进行微小更改并重复。

TDD的好处之一是您的测试可以充当应用程序工作方式的文档。随着功能集随着时间的推移而扩展,您的测试也将随之扩展,并且据此扩展您的文档。

因此,单元测试是了解应用程序特定部分工作方式的好方法。如果您需要复习或从另一个开发人员那里接管代码库,它们将非常有用。

TDD的其他好处包括:

  • Code Coverage:因为您要在生产代码之前编写测试,所以未经测试的代码的可能性很小。
  • Confidence in refactoring:由于代码覆盖范围广,并且该项目被分解为较小的可测试单元,因此可以更轻松地对代码库进行大型重构。
  • Focused:您编写的代码最少,可以通过测试,因此您的代码库整洁,冗余程度也较低。

The Red, Green, Refactor Cycle

好的单元测试是失败,可重复,运行迅速且易于维护的。 通过使用TDD,可以确保您的测试值得。

开发人员通常将TDD流程描述为red-green-refactor周期:

  • 1) Red:写一个首先失败的测试。
  • 2) Green:编写尽可能少的代码以使其通过。
  • 3) Refactor:修改和优化代码。
  • 4) Repeat:重复这些步骤,直到您认为代码可以正常工作为止。

注意:如果您想了解有关iOS中TDD的更多信息,请查看我们的教程 Test Driven Development Tutorial for iOS: Getting Started。

有了关于TDD的一些理论,现在是时候将TDD付诸实践了。


Fetching Reports

为了熟悉TDD,您将编写一个验证getReports()的测试。 首先,测试将失败,但是您将努力确保测试不会失败。

ReportServiceTests.swift中,添加:

func testGetReports() {
  //1
  let newReport = reportService.add(
    "Endor", 
    numberTested: 30,
    numberPositive: 20, 
    numberNegative: 10)
    
  //2
  let getReports = reportService.getReports()

  //3
  XCTAssertNil(getReports)

  //4
  XCTAssertEqual(getReports?.isEmpty, true)

  //5
  XCTAssertTrue(newReport.id != getReports?.first?.id)
}

这段代码:

  • 1) 添加一个新报告并将其分配给newReport
  • 2) 获取当前存储在Core Data中的所有报告,并将它们分配给getReports
  • 3) 验证getReports的结果是否为nil。 这是一个失败的测试。 getReports()应该返回添加的报告。
  • 4) 断言结果数组为空。
  • 5) 断言newReport.id不等于getReports中的第一个对象。

运行单元测试。 您会看到测试失败。

查看失败测试中的assert表达式的结果:

该测试失败,因为报表服务返回您添加的报表。 断言与getReports返回的条件相反。 如果此测试确实通过了,则说明getReports()无法正常工作,或者单元测试中存在bug

要使测试从红色变为绿色,请使用以下命令替换断言:

XCTAssertNotNil(getReports)

XCTAssertTrue(getReports?.count == 1)

XCTAssertTrue(newReport.id == getReports?.first?.id)

这段代码:

  • 1) 检查getReports是否为nil
  • 2) 验证报告数为1
  • 3) 断言创建的报表的id,并且报表数组中的第一个结果匹配。

接下来,重新运行单元测试。 您会看到一个绿色的对号。

成功! 您已将失败的测试变成绿色,确认代码和测试有效且不是错误。

接着,下一个。


Updating a Report

现在,编写一个测试来验证update(_ :)的行为是否符合预期。 添加以下测试方法:

func testUpdateReport() {
  //1
  let newReport = reportService.add(
    "Snow Planet", 
    numberTested: 0,
    numberPositive: 0, 
    numberNegative: 0)

  //2
  newReport.numberTested = 30
  newReport.numberPositive = 10
  newReport.numberNegative = 20
  newReport.location = "Hoth"

  //3
  let updatedReport = reportService.update(newReport)

  //4
  XCTAssertFalse(newReport.id == updatedReport.id)

  //5
  XCTAssertFalse(updatedReport.numberTested == 30)
  XCTAssertFalse(updatedReport.numberPositive == 10)
  XCTAssertFalse(updatedReport.numberNegative == 20)
  XCTAssertFalse(updatedReport.location == "Hoth")
}

这段代码:

  • 1) 创建一个新报告并将其分配给newReport
  • 2) 将newReport的当前属性更改为新值。
  • 3) 调用update:保存所做的更改,并将更新后的值分配给updatedReport
  • 4) 断言newReport.idupdatedReport.id不匹配。
  • 5) 确保updatedReport属性不等于分配给newReport的值。

运行单元测试。 您会看到测试失败。

查看单元测试,注意五个断言都失败了。

他们失败了,因为newReport属性等于updatedReport的属性。 换句话说,您希望测试在这里失败。

testUpdateReport()中的断言更新为:

XCTAssertTrue(newReport.id == updatedReport.id)
XCTAssertTrue(updatedReport.numberTested == 30)
XCTAssertTrue(updatedReport.numberPositive == 10)
XCTAssertTrue(updatedReport.numberNegative == 20)
XCTAssertTrue(updatedReport.location == "Hoth")

更新的代码测试newReport.id是否等于updatedReport.id,以及updatedReport属性是否等于分配给newReport的属性。

重新运行测试,看看它们现在通过了。

到目前为止,您所做的工作比根本没有测试要好得多,但是您仍然没有真正遵循TDD的做法。 为此,您必须在编写应用程序中的实际功能之前编写测试。


Extending Functionality

到目前为止,您已经添加了测试以支持该应用程序的现有功能。 现在,您将通过扩展服务的功能来将TDD技能提升到一个新的水平。

为此,您将添加删除记录的功能。 由于这次您将遵循真正的TDD练习,因此必须在生产代码之前编写测试。

构建并运行并添加报告。 添加报告后,可以通过在单元格上轻扫并点击Delete来将其删除。

但是您仅从ViewController.swift中的reports实例中删除了该报表。 该报告仍存在于Core Data Store中,并且在您再次构建并运行时将返回。

要进行检查,请重新构建并运行。

该报告仍然存在,因为ReportService.swift没有删除功能。 接下来,您将添加此内容。

但是首先,您必须编写一个测试,该测试具有删除功能所期望的所有功能。

ReportServiceTests.swift中,添加:

func testDeleteReport() {
    //1
    let newReport = reportService.add(
      "Starkiller Base", 
      numberTested: 100,
      numberPositive: 80, 
      numberNegative: 20)

    //2
    var fetchReports = reportService.getReports()
    XCTAssertTrue(fetchReports?.count == 1)
    XCTAssertTrue(newReport.id == fetchReports?.first?.id)

    //3
    reportService.delete(newReport)

    //4    
    fetchReports = reportService.getReports()

    //5
    XCTAssertTrue(fetchReports?.isEmpty ?? false)
  }

这段代码:

  • 1) 添加一个新的报告。
  • 2) 从store中获取包含报告的所有报告。
  • 3) 在reportService上调用delete删除报告。 由于此方法尚不存在,因此将失败。
  • 4) 再次从store获取所有报告。
  • 5) 声明报表数组为空。

添加此代码后,您会看到编译错误。 目前,ReportService.swift没有实现delete(_ :)。 接下来,您将添加它。

1. Deleting a Report

现在,打开ReportService.swift并在类末尾添加以下代码:

public func delete(_ report: PandemicReport) {
  // TODO: Delete record from CoreData
}

在这里,您添加了一个空的声明。 记住,TDD规则之一是编写足够的代码以使测试通过。

您解决了编译错误。 重新运行测试,您将看到一个失败的测试。

现在测试失败了,因为未从store中删除该记录。 当前,ReportService.swift中的delete方法具有空主体,因此实际上没有任何内容被删除。 接下来,您将对其进行修复。

返回ReportService.swift,添加以下实现到delete(_:)

//1
managedObjectContext.delete(report)
//2
coreDataStack.saveContext(managedObjectContext)

代码:

  • 1) 从持久性存储(persistent store)中删除报告。
  • 2) 将更改保存在当前上下文中。

添加该代码后,重新运行单元测试。 您会看到绿色的选中标记。

做得好! 您已使用TDD周期向应用程序添加了删除功能。

完成后,转到ViewController.swift并将tableView(_:commit:forRowAt :)替换为以下内容:

func tableView(
  _ tableView: UITableView,
  commit editingStyle: UITableViewCell.EditingStyle,
  forRowAt indexPath: IndexPath
) {
  guard 
    let report = reports?[indexPath.row], 
    editingStyle == .delete 
    else {
      return
  }
  reports?.remove(at: indexPath.row)
  
  //1
  reportService.delete(report)
  
  tableView.deleteRows(at: [indexPath], with: .automatic)
}

在这里,您将delete(_ :)从报表数组中删除后,将其调用。 现在,如果您滑动并删除,该报告将从SQLite支持的数据库中删除。

构建并运行以进行检查。

很好地将测试引入具有Core Data的项目。

在本教程中,您学习了如何:

  • 使用内存存储(in-memory store)编写可测试的Core Data stack
  • 为现有功能和新功能编写测试。
  • 测试异步代码。
  • 应用Test Driven Development (TDD)方法。

如果遇到挑战,请尝试编写一个或多个测试,以防止为numberTested,numberPositive和numberNegative添加非负数。

如果您喜欢本教程,请查看iOS Test-Driven Development by Tutorials。您将通过先编写测试或将测试添加到已编写的应用中来深入研究如何编写可维护和可持续的应用。您还应该查看 Test Driven Development Tutorial for iOS: Getting Started。

如果您想了解更多有关Core Data的信息,请查看 Getting Started with Core Data Tutorial。

后记

本篇主要讲述了基于Unit TestingCore Data测试,感兴趣的给个赞或者关注~~~

你可能感兴趣的:(数据持久化方案解析(十三) —— 基于Unit Testing的Core Data测试(一))