Core Data by tutorials 笔记(六)

今天我们来关注一下 CoreData 的单元测试,其实在写程序之前,先写测试,将问题一点点分解,也是 TDD 所倡导的做法,这也是我今年所期望达成的一个目标,新开项目按 TDD 的流程来做,以后也会整理些这方面的东西。如果你对 CoreData 的其他方面感兴趣请查看我之前的笔记或直接购买《Core Data by Tutorials》

Chapter 8: Unit Testing

作者列举了一系列单元测试的好处:帮助你在一开始就组织好项目的结构,可以不用操心 UI 去测试核心功能。单元测试还可以方便重构。还可以更好地拆分UI进行测试。

这章主要焦距在 XCTest 这个框架来测试 Core Data 程序,多数情况下 Core Data 的测试要依赖于真实的 Core Data stack,但又不想将单元测试的 test data 与你手动添加的接口测试弄混,本章也提供了解决方案。

一、Getting started

本章要测试的是一个关于野营管理的 APP,主要管理营地、预订(包括时间表和付款情况)。作者将整个业务流程分解为三块:

  1. campsites(野营地)
  2. campers(野营者)
  3. reservations(预订)

由于 swift 内部的访问控制,app 和其 test 分别属于不同的 targets 和不同的 modules,因此你并不能普通地从 tests 中访问 app 中的 classes,这里有两个解决办法:

  1. 把 App 中的 classes 和 methods 标记为 public,是其对 tests 可见。
  2. 直接在 test target 里添加所需要的 classes。

作者提供的实例中,已经将要测试的类和方法标记为 public 的了,现在就可以对 Core Data 部分进行测试了,作者在测试开始前给了一些建议:

Good unit tests follow the acronym FIRST:

Fast: If your tests take too long to run, you won’t bother running them.

Isolated: Any test should function properly when run on its own or before or after any other test.

Repeatable: You should get the same results every time you run the test against the same codebase.

Self-verifying: The test itself should report success or failure; you shouldn’t have to check the contents of a file or a console log.

Timely: There’s some benefit to writing the tests after you’ve already written the code, particularly if you’re writing a new test to cover a new bug. Ideally, though, the tests come first to act as a specification for the functionality you’re developing.

为了达到上面提到 “FIRST” 目标,我们需要修改 Core Data stack 使用 in-memory store 而不是 SQLite-backed store。具体的做法是为 test target 创建一个 CoreDataStack 的子类来修改 store type

class TestCoreDataStack: CoreDataStack {  
    override init() {
        super.init() 
        self.persistentStoreCoordinator = {
            var psc: NSPersistentStoreCoordinator? = NSPersistentStoreCoordinator(managedObjectModel:
                self.managedObjectModel) 
            var error: NSError? = nil
            var ps = psc!.addPersistentStoreWithType( 
                NSInMemoryStoreType, configuration: nil, 
                URL: nil, options: nil, error: &error)
            if (ps == nil) { 
                abort()
            }
              return psc
          }()
    } 
}

二、Your first test

单元测试需要将 APP 的逻辑拆分出来,我们创建一个类来封装这些逻辑。作者这里创建的第一个测试类为 CamperServiceTestsXCTestCase 的子类,用来测试 APP CamperService 类中的逻辑

import UIKit  
import XCTest  
import CoreData  
import CampgroundManager  
//
class CamperServiceTests: XCTestCase {  
  var coreDataStack: CoreDataStack!
  var camperService: CamperService!
    override func setUp() {
        super.setUp()
        coreDataStack = TestCoreDataStack()
        camperService = CamperService(managedObjectContext: coreDataStack.mainContext!, coreDataStack: coreDataStack)
    }
    override func tearDown() {
// Put teardown code here. This method is called after the invocation of each test method in the class.
        super.tearDown()
        coreDataStack = nil
        camperService = nil
    }
    func testAddCamper() {
        let camper = camperService.addCamper("Bacon Lover", phoneNumber: "910-543-9000")
        XCTAssertNotNil(camper, "Camper should not nil")
        XCTAssertTrue(camper?.fullName == "Bacon Lover")
        XCTAssertTrue(camper?.phoneNumber == "910-543-9000")
    }

setUp 会在每次测试前被调用,这里可以创建一些测试需要用到东西,而且因为使用的是 in-memory store,每次在 setUp 中创建的 context 都是全新的。tearDown 相对于 setUp,是在每次 test 结束后调用,用来清除一些属性。上面的例子主要测试了 addCamper() 方法。

这里注意的就是该测试创建的对象和属性都不会保存在任何store中的。

三、Asynchronous tests

关于异步测试,这里用到了两个 context,一个 root context 运行在后台线程中,另外一个 main context 是 root context 的子类,让 context 分别在正确的线程中执行其实也很简单,主要采用下面两种方法:

  1. performBlockAndWait() 将等待 block 里的内容执行完后才继续
  2. performBlock() 执行到此方法立即返回

测试第二种 performBlock() 方法时可能会需要些技巧,因为数据可能不会立即得到,还好 XCTestCase 提供了一个叫 expectations 的新特性。下面展示了使用 expectation 来完成对异步方法的测试:

let expectation = self.expectationWithDescription("Done!");  
someService.callMethodWithCompletionHandler() {  
    expectation.fulfill()
}
self.waitForExpectationsWithTimeout(2.0, handler: nil)  

该特性的关键是要么是 expectation.fulfill() 被执行,要么触发超时产生一个异常 expectation,这样 test 才能继续。

我们现在来为 CamperServiceTests 继续增加一个新的方法来测试 root context 的保存:

func testRootContextIsSavedAfterAddingCamper() {  
//1 创建了一个针对异步测试的方法,主要是通过观察 save 方法触发的通知,触发通知后具体的 handle 返回一个 true。
    let expectRoot = self.expectationForNotification( 
        NSManagedObjectContextDidSaveNotification, 
        object: coreDataStack.rootContext) {
            notification in 
            return true
    }
//2 增加一个camper
    let camper = camperService.addCamper("Bacon Lover", 
        phoneNumber: "910-543-9000")
//3 等待2秒,如果第1步没有 return true,那么就触发 error
    self.waitForExpectationsWithTimeout(2.0) { 
        error in
        XCTAssertNil(error, "Save did not occur") 
    }
}

四、Tests first

这一节新建了一个 CampSiteServiceTests Class 对 CampSiteService 进行测试,具体 code 形式与上一节类似,添加了测试 testAddCampSite()testRootContextIsSavedAfterAddingCampsite(),作者在这里主要展示了 TDD 的概念。

Test-Driven Development (TDD) is a way of developing an application by writing a test first, then incrementally implementing the feature until the test passes. The code is then refactored for the next feature or improvement.

根据需求又写了一个 testGetCampSiteWithMatchingSiteNumber() 方法用来测试 getCampSite(),因为 campSiteService.addCampSite() 方法在之前的测试方法中已经通过测试了,所以这里可以放心去用,这就是 TDD 的一个精髓吧。

func testGetCampSiteWithMatchingSiteNumber(){  
    campSiteService.addCampSite(1, electricity: true,
        water: true)
let campSite = campSiteService.getCampSite(1)  
    XCTAssertNotNil(campSite, "A campsite should be returned")
}
func testGetCampSiteNoMatchingSiteNumber(){  
    campSiteService.addCampSite(1, electricity: true,
        water: true)
    let campSite = campSiteService.getCampSite(2)
    XCTAssertNil(campSite, "No campsite should be returned") 
}

写完测试方法运行一下 CMD+U,当然通不过啦,我们还没有实现他。现在为 CampSiteService 类添加一个 getCampSite() 方法:

public func getCampSite(siteNumber: NSNumber) -> CampSite? {  
    let fetchRequest = NSFetchRequest(entityName: "CampSite")   fetchRequest.predicate = NSPredicate(
        format: "siteNumber == %@", argumentArray: [siteNumber]) 
    var error: NSError?
    let results = self.managedObjectContext.executeFetchRequest(
        fetchRequest, error: &error)
    if error != nil || results == nil { 
        return nil
    }
    return results!.first as CampSite? 
}

现在重新 CMD+U 一下,就通过了。

五、Validation and refactoring

最后一节主要针对 APP 中的 ReservationService 类进行测试,同样的是创建一个 ReservationServiceTests 测试类,这个 test 类的 setUP 和 tearDown 与第三节类似。只不过多了 campSiteService 与 camperService 的设置。在 testReserveCampSitePositiveNumberOfDays() 方法中对 ReservationService类里的reserveCampSite() 进行测试后,发现没有对 numberOfNights 有效性进行判断,随后进行了修改,这也算是展示了单元测试的另一种能力。作者是这么解释的:不管你对这些要测试的 code 有何了解,你尽肯能地针对这些 API 写一些测试,如果 OK,那么皆大欢喜,如果出问题了,那意味着要么改进 code 要么改进测试代码。


-EOF-

如果感觉此文对你有帮助,请随意打赏支持作者 😘

chengway

认清生活真相之后依然热爱它!

Subscribe to Talk is cheap, Show me the world!

Get the latest posts delivered right to your inbox.

or subscribe via RSS with Feedly!