跳转至

11: 遗留问题

在一个"遗留"项目上开始TDD与在一个新项目上开始TDD有很大不同。例如,该项目可能有很少(如果有的话)的单元测试,缺乏文档,而且构建速度很慢。本章将向你介绍解决这些问题的策略。

你可能会想,"如果这个项目是用TDD创建的就好了,它就不会这么糟糕"。在增加单元测试的同时,让代码变得更加可测试,是解决这些问题的好方法。不幸的是,并没有一个银弹、万无一失的方法可以在一夜之间解决所有这些问题。

然而,你可以使用一些伟大的策略,随着时间的推移将TDD引入到遗留项目。在这一章中,你将被介绍到遗留代码修改算法,它最初是由Michael Feathers在他的书《有效处理遗留代码》中介绍的。以下是高层次的步骤:

  1. 识别变化点
  2. 寻找测试点
  3. 打破依赖关系
  4. 编写测试
  5. 进行修改和重构

介绍MyBiz

MyBiz是本节的示例应用程序。它是一个非常轻量级的ERP应用,但会说明你在使用遗留应用时可能遇到的各种问题。如果ERP对你来说是一个毫无意义的缩写,请不要担心。它代表的是企业资源计划(Enterprise Resource Planning),是"商业垃圾的厨房水槽"的四元表达。

在我们的TDD世界里,"遗留应用"最重要的是意味着没有足够的(或任何)单元测试的应用。如果"遗留问题"意味着没有任何测试的代码,那么这个应用就是大写的遗留问题。

臃肿、错综复杂的应用程序在大型企业中很常见,例如MyBiz将被使用;然而,这些问题发生在不同规模和成熟度的组织的各种应用程序中。一旦第一个功能被添加到一个没有架构支持的应用中,这些"遗留(反)模式"就开始出现了。在添加功能的同时,在你的传统应用程序中引入TDD是避免这种情况的一个好方法。

img

使用MyBiz的一个挑战是,它没有使用像MVVMVIPER这样的现代架构。相反,很多业务逻辑都存在于单片的视图控制器中。它可以完成工作,但是,正如你将看到的,很难添加新的东西。

设置应用程序和后端

在启动启动程序之前,你应该启动后端。像第3节中的Dogpatch应用一样,这是一个基于Vapor的后端。对于一个ERP应用来说,它是非常简陋的,它通常会与一个由多个服务器和数据库组成的大型多层服务架构对话。然而,这个项目的目标只是拥有一个功能性的应用程序,用于添加功能、测试和重构,所以后端是高水平和抽象的。

按照第8章"RESTful网络"中的安装说明来安装Vapor

Note

注意:要了解更多关于Vapor的信息,你可以在https://vapor.codes/上阅读文档,或者查看我们的书Server-Side Swift With Vapor,你可以在https://www.raywenderlich.com/books/server-side-swift-with-vapor

一旦Vapor安装完毕,通过以下操作启动后台:

  1. 打开一个终端,导航到projects/backend文件夹。
  2. 运行以下命令来创建你的项目文件,并打开Xcode项目。
vapor xcode -y
  1. 如果没有选择该方案,将其设置为Run
  2. 建立并运行。

你应该看到屏幕底部弹出终端,有以下文字:

Server starting on http://localhost:8080

这意味着服务器已经启动并运行。要检查它,请打开你的网络浏览器,访问localhost:8080/hello。你应该看到以下内容:

Welcome to MyBiz!

后台准备好后,打开启动器项目。建立并运行。点击"登录",使用硬编码的凭证:用户名agent@shield.org,密码hailHydra。如果后台设置正确,你会看到有几个标签填写了样本数据。

img

介绍变革任务

为了提高士气,MyBiz的人力资源总监制定了一项承认员工生日的新政策。作为这个过程的一部分,你被指示将生日作为事件添加到公司日历中。为了简单起见,假设每个用户都想看到其他人的生日。

确定一个改变点

要改变一个应用程序,你必须弄清楚把这个改变放在哪里--也就是说,弄清楚哪些类和文件需要被修改。第一步是了解需求,这样你就知道到底要实现什么。

你可以把人力资源总监的要求提炼成以下语句。

在用户日历中填入生日事件,为组织的联系簿中的每个人填一个。

有很多方法可以做到这一点。在本教程中,你将采取以下方法:

  • 为每个员工添加一个生日字段。
  • 为每个雇员在日历上添加一个生日事件。从上面的内容来看,变化点是。
  • Employee.swift: 你将添加一个生日字段。
  • CalendarViewController.swift。你需要把生日添加到事件列表中。

找到一个测试点

测试点是你需要写测试来支持你的变化的位置。测试点不是为了修复错误,而是为了保留现有的应用行为。就像TDD过程不是为了寻找bug,而是为了防止以后引入变化时出现bug

对于遗留代码,你将写出特征化测试。这些测试是基于代码所做的事情来明确代码的当前行为。对于一个大型的遗留应用,特别是在企业中,理解和保留代码的行为是很重要的--有没有听过这样一句话,"这不是一个错误,这是一个特点"?目前的用户希望应用程序有一定的行为,即使这不是产品经理的意图,也不是规范中的内容。

特性化测试是为你计划改变的代码和该改变的更广泛的环境(如它的类或调用者)而写的。如果改变包括移动代码或重构代码,这些测试也应该涵盖这些代码。

有一个类似于TDD的公式来写一个特性化测试。这有点像TDD,只是代码已经写好了:

  1. 在一个测试函数中使用该代码。
  2. 写一个你期望失败的断言。
  3. 让失败成为行为的特征。
  4. 改变测试,使其根据代码的行为通过。

TDD的主要区别在于上面的最后一步。你将改变测试来匹配代码,而不是改变代码来通过测试。

为了更好地理解,你将把它应用于一个具体的例子。

你的测试点将在CalendarViewController中,它目前负责加载事件列表。你需要编写关于日历中事件的加载和显示的特性化测试,这样添加生日就不会破坏应用程序。

在测试中使用代码

首先,你需要一个地方来放置这些特性化测试。要做到这一点,创建一个新的测试目标。

  1. 在项目中添加一个新的iOS单元测试捆绑目标。将其命名为CharacterizationTests

image

当你添加新的代码时,一个单独的单元测试目标将被用于基于TDD的单元测试。没有必要用一个目标将特性化测试与其他测试分开,但是,这样一来,你会清楚地知道这些测试的目标是什么。

  1. 删除CharacterizationTests.swift的存根文件。
  2. 添加一个新的组:Cases
  3. 在该组中,添加一个新的单元测试用例类,命名为CalendarViewControllerTests
  4. 删除testExampletestPerformanceExample

完成后,CalendarViewControllerTests组应该看起来像这样:

img

现在,是时候为你的测试设置这个类了。

首先,在文件的顶部添加app模块的导入:

@testable import MyBiz

接下来,在该类的顶部添加以下内容:

var sut: CalendarViewController!

最后,将setUpWithError()tearDownWithError()改为以下内容:

override func setUpWithError() throws {
    try super.setUpWithError()
    sut = UIStoryboard(name: "Main", bundle: nil)
        .instantiateViewController(withIdentifier: "Calendar") as?
    CalendarViewController
    sut.loadViewIfNeeded()
}

override func tearDownWithError() throws {
    sut = nil
    try super.tearDownWithError()
}

你已经设置了CalendarViewController作为你的被测系统(SUT),并且你已经加载了该视图。现在你准备写一个测试......但它会是什么?

打破依赖关系

一个合乎逻辑的地方是事件被加载到日历的地方。如果你在事件列表中添加生日,你要确保不破坏现有的事件功能。

在类的末尾添加以下测试方法:

func testLoadEvents_getsData() {
}

下一步是让视图控制器加载事件,但如果你看一下CalendarViewController,你会发现这是由viewWillAppear(_:)中的一个调用完成。这个方法很难测试,因为这意味着执行视图生命周期事件和处理未知的副作用。

为了使测试更容易,重构视图控制器,使加载事件不需要调用viewWillAppear(_:)。选择CalendarViewController.swiftviewWillAppear(_:)的最后两行。然后,选择Editor ▸ Refactor ▸ Extract为方法。命名这个新方法为loadEvents。删除fileprivate修改器,这样你的测试就可以访问这个方法。

现在,事件可以在测试类中被加载。打开CalendarViewControllerTests.swift,在testLoadEvents_getsData中添加以下内容:

// when
sut.loadEvents()

这就启动了事件加载,但你还没有准备好确认数据是否加载。

接下来,在测试的最后添加以下内容:

let predicate = NSPredicate { _, _ -> Bool in
    return !self.sut.events.isEmpty
}
let exp = expectation(
    for: predicate,
    evaluatedWith: sut,
    handler: nil)
// then
wait(for: [exp], timeout: 2)
print(sut.events)

这将等待事件的加载,然后将它们打印到控制台。

将特征描述变成一个测试

这还不是一个真正的测试,因为没有断言,但这是表征系统的一个关键步骤。

建立并测试TestLoadEvents_getsData(),然后看一下控制台。你应该看到与下面类似的东西:

[MyBiz.Event(name: "Alien invasion", date: 2021-11-05 12:00:00
+0000, type: MyBiz.EventType.appointment, duration: 3600.0),
MyBiz.Event(name: "Interview with Hydra", date: 2021-11-05
17:30:00 +0000, type: MyBiz.EventType.appointment, duration:
1800.0), MyBiz.Event(name: "Panic attack", date: 2021-11-12
15:00:00 +0000, type: MyBiz.EventType.meeting, duration:
3600.0)]

你可以用这些结果来写期望值。

把你测试中的print()替换成以下内容。更新日期,使之与你在控制台中看到的值相匹配--加上一些额外的格式化。对于从控制台复制的每个日期,将日期和时间之间的空格改为T,并删除时间和时区之间的空格:

let eventJson = """
  [{"name": "Alien invasion", "date": "2021-11-05T12:00:00+0000",
  "type": "Appointment", "duration": 3600.0},
    {"name": "Interview with Hydra", "date": "2021-11-05T17:30:00+0000",
  "type": "Appointment", "duration": 1800.0},
    {"name": "Panic attack", "date": "2021-11-12T15:00:00+0000",
  "type": "Meeting", "duration": 3600.0}]
  """
let data = Data(eventJson.utf8)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let expectedEvents = try? decoder.decode([Event].self, from: data)
XCTAssertEqual(sut.events, expectedEvents)

在这里,你已经硬编码了一个事件的JSON有效载荷并对其进行了解码。断言验证了你的有效载荷与sut.events中的有效载荷相匹配,该有效载荷是由loadEvents()加载的:

Note

实际日期会有所不同,因为样本后台的编码是相对于你的当前日期返回事件。这指出了你在连接"实时"后端时将会遇到的一个实际问题--数据可能会改变,并使你的测试不可靠。幸运的是,你不会在这个区域停留很久。

现在,运行测试,它仍然会通过,但这次有一个实际的断言。

增加一点稳定性

这是一个好的开始,但是,如上所述,这个测试对后端有一个脆性的依赖。只要等待一天,这个测试就不会再通过。

为了解决这种不稳定性,你需要打破依赖关系,直到测试不再依赖实时API调用。"Restful Networking,"涵盖了如何做到这一点的理论和策略。在接下来的步骤中,你将使用一个覆盖生产代码的模拟做一个轻量级版本。这样,你就能在最初的目标上继续前进:添加生日。

通过修改CalendarViewController来支持Mock API类,开始吧。在CalendarViewController.swift中,将var api一行替换为:

var api: API = UIApplication.appDelegate.api

这种从计算变量到存储变量的细微变化将允许你在测试中替换它。你应该重新运行测试,以验证这一变化没有破坏任何特征化的行为。

CharacterizationTests组中,创建一个新的组:Mocks。在里面,创建一个新的Swift文件,名为MockAPI.swift

当你完成后,CharacterizationTests组将看起来像这样:

image

在新文件中加入以下代码:

@testable import MyBiz

class MockAPI: API {
    var mockEvents: [Event] = []
    override func getEvents() {
        DispatchQueue.main.async {
            self.delegate?.eventsLoaded(events: self.mockEvents)
        }
    }
}

MyBiz使用API类来与它的后端进行通信。在这里,你已经创建了一个API子类,重写了getEvents(),用模拟数据调用eventsLoaded(events:),而不是进行服务调用。这是重构网络调用的一个小步骤,以使稳定的测试能够覆盖一系列的情况。

现在,在CalendarViewControllerTests.swift中使用它。添加一个var

var mockAPI: MockAPI!

setUpWithError中的loadViewIfNeeded行之前添加这些内容来创建它:

mockAPI = MockAPI()
sut.api = mockAPI

并对tearDownWithError,在对super的调用上面。

最后,重写testLoadEvents_getsData,如下:

func testLoadEvents_getsData() {
    // given
    let eventJson = """
    [{"name": "Alien invasion", "date":
    "2019-04-10T12:00:00+0000",
    "type": "Appointment", "duration": 3600.0},
      {"name": "Interview with Hydra", "date":
    "2019-04-10T17:30:00+0000",
    "type": "Appointment", "duration": 1800.0},
      {"name": "Panic attack", "date":
    "2019-04-17T14:00:00+0000",
    "type": "Meeting", "duration": 3600.0}]
    """
    let data = Data(eventJson.utf8)
    let decoder = JSONDecoder()
    decoder.dateDecodingStrategy = .iso8601
    let expectedEvents = try! decoder.decode([Event].self, from:
                                                data)
    mockAPI.mockEvents = expectedEvents
    // when
    let predicate = NSPredicate { _, _ -> Bool in
        !self.sut.events.isEmpty
    }
    let exp = expectation(for: predicate, evaluatedWith: sut,
                          handler: nil)
    sut.loadEvents()
    // then
    wait(for: [exp], timeout: 1)
    XCTAssertEqual(sut.events, expectedEvents)
}

它使用从硬编码数据中加载的expectedEvents作为mockAPI的种子。然后,它测试这些值在事件被加载时是否会回来。现在,不用再担心运行测试的日期了。运行测试,你应该看到它通过了,无论你在哪一天运行它。这是因为数据在测试的JSON中被永远冻结。

在接下来的几章中,你将进一步重构API类,使Mock可以实现一个协议而不是覆盖生产代码。然后,最后一步将是把API协议分解成更小的、功能性的协议,这样每个屏幕只需要关注它的那一块。

重要的是要记住,这种特征化测试的目的不是为了确保正确性,而是为了记录代码的实际作用。这样一来,你就能识别出后来的改动何时修改了行为。

如果发现了一些意想不到的东西,也不一定说明是个错误。相反,这是一个澄清预期行为的机会。如果需要修复,现在可以用已经到位的测试来指导工作。

有了这样的测试,就有信心后续的重构会保留应用程序的行为。一般来说,在进行修改之前,你会希望描述比这更多的行为--例如,捕捉错误和边界条件。

编写测试

现在,是时候添加生日功能了。由于这将是新的代码,你将使用TDD来确保有测试的地方,并使用这些测试来指导你的代码。

接下来,你将创建一个新的测试目标。

  1. 在项目中添加一个新的iOS单元测试绑定目标。将其命名为MyBizTests。这个目标将用于涵盖新代码的TDD式测试。
  2. 删除MyBizTests.swift
  3. 添加一个新的组:Cases
  4. 在该组中,添加一个新的单元测试案例类,命名为CalendarModelTests

image

为了提高代码库的可读性、稳定性和可测试性,同时增加新的功能,你将创建一个模型类,将数据逻辑从视图控制器中提取出来;这将通过一个新的类CalendarModel完成。

CalendarModelTests.swift的内容替换为:

import XCTest
@testable import MyBiz

class CalendarModelTests: XCTestCase {
    var sut: CalendarModel!
    override func setUpWithError() throws {
        try super.setUpWithError()
        sut = CalendarModel()
    }
    override func tearDownWithError() throws {
        sut = nil
        try super.tearDownWithError()
    }
}

这使用CalendarModel作为SUT,由于它还不存在,你会得到编译错误。

在项目导航器中,选择CalendarViewController.swiftCalendarCell.swift。通过使用File ▸ New ▸ Group From Selection,创建一个名为Calendar的新组。

在这个组中添加一个名为CalendarModel.swift的新swift文件,并将其内容替换为以下内容:

class CalendarModel {
    init() {} 
}

现在,CalendarModelTests将被编译,即使它还没有做任何事情。

从一个基本的部分开始--从雇员列表中计算生日事件。

CalendarModelTests中添加以下代码:

func mockEmployees() -> [Employee] {
    let employees = [
        Employee(
            id: "Cap",
            givenName: "Steve",
            familyName: "Rogers",
            location: "Brooklyn",
            manager: nil,
            directReports: [],
            birthday: "07-04-1920"),
        Employee(
            id: "Surfer",
            givenName: "Norrin",
            familyName: "Radd",
            location: "Zenn-La",
            manager: nil,
            directReports: [],
            birthday: "03-01-1966"),
        Employee(
            id: "Wasp",
            givenName: "Hope",
            familyName: "van Dyne",
            location: "San Francisco",
            manager: nil,
            directReports: [],
            birthday: "01-02-1979")
    ]
    return employees
}

func mockBirthdayEvents() -> [Event] {
    let dateFormatter = DateFormatter()
    dateFormatter.dateFormat = Employee.birthdayFormat
    return [
        Event(
            name: "Steve Rogers Birthday",
            date: dateFormatter.date(from: "07-04-1920")!.next()!,
            type: .birthday,
            duration: 0),
        Event(
            name: "Norrin Radd Birthday",
            date: dateFormatter.date(from: "03-01-1966")!.next()!,
            type: .birthday,
            duration: 0),
        Event(
            name: "Hope van Dyne Birthday",
            date: dateFormatter.date(from: "01-02-1979")!.next()!,
            type: .birthday,
            duration: 0)
    ]
}

func testModel_whenGivenEmployeeList_generatesBirthdayEvents() {
    // given
    let employees = mockEmployees()
    // when
    let events = sut.convertBirthdays(employees)
    // then
    let expectedEvents = mockBirthdayEvents()
    XCTAssertEqual(events, expectedEvents)
}

mockEmployees()mockBirthdayEvents()是用来创建具有硬编码数据的模拟数据对象的助手。这些方法将在几个测试中使用。新的测试确认了给定一个雇员列表,会生成一组正确的生日事件。

你需要添加代码以使其编译。在Employee.swift中,添加以下内容let directReports: [String]:

let birthday: String?
static let birthdayFormat = "MM-dd-yyyy"

这增加了生日作为一个数据字段,以及对预期日期格式的描述。在这个练习中,你可以安全地假设这个格式是一个铁定的合同。

接下来,在Event.swift中添加生日作为一个事件。

  1. 将以下内容添加到EventType案例列表中:
case birthday = "Birthday"

这需要指定的原始值在小写枚举惯例和大写服务器惯例之间搭建桥梁。

  1. var符号中的开关中加入以下内容:
case .birthday: 
    return "🎂"

这将被用于在日历细节视图中填充生日事件的标题。

最后在CalendarModel.swift中添加这个方法:

func convertBirthdays(_ employees: [Employee]) -> [Event] {
    let dateFormatter = DateFormatter()
    dateFormatter.dateFormat = Employee.birthdayFormat
    return employees.compactMap {
        if let dayString = $0.birthday,
           let day = dateFormatter.date(from: dayString),
           let nextBirthday = day.next() {
            let title = $0.displayName + " Birthday"
            return Event(
                name: title,
                date: nextBirthday,
                type: .birthday,
                duration: 0)
        }
        return nil
    }
}

这个方法接收一个雇员数组,并为他们即将到来的生日返回相应的事件。

现在,运行CalendarModelTests,测试将通过。吁...

在生产中加载生日

你现在能够从雇员的生日创建事件,但你还没有办法在生产代码中加载生日。接下来你会在这方面下功夫的。

还是在CalendarModelTests.swift中,在类的末尾添加以下测试:

func testModel_whenBirthdaysLoaded_getsBirthdayEvents() {
    // given
    let exp = expectation(description: "birthdays loaded")
    // when
    var loadedEvents: [Event]?
    sut.getBirthdays { res in
        loadedEvents = try? res.get()
        exp.fulfill()
    }
    // then
    wait(for: [exp], timeout: 1)
    let expectedEvents = mockBirthdayEvents()
    XCTAssertEqual(loadedEvents, expectedEvents)
}

你调用一个新的方法,getBirthdays(completion:),它接受一个完成的闭包,返回一个事件数组。

为了让测试建立,在CalendarModel.swift中添加以下内容:

func getBirthdays(completion: @escaping (Result<[Event], Error>) -> Void) {
}

但是为了让它通过,你需要构建出一些基于API的功能。

convertBirthdays(_:)上面的CalendarModel中添加以下内容:

let api: API
var birthdayCallback: ((Result<[Event], Error>) -> Void)?

init(api: API) {
    self.api = api
}

同时,删除无参数的init()方法,因为API没有默认值。

这使得注入一个API对象成为可能,它将被用来从服务器上获取数据。还有一个变量用来存储你接下来要使用的回调。

getBirthdays(completion:)中添加以下内容:

birthdayCallback = completion
api.delegate = self
api.getOrgChart()

这将存储该完成块并调用api以获得雇员名单。

接下来,在文件的底部添加以下委托扩展:

extension CalendarModel: APIDelegate {
    func orgLoaded(org: [Employee]) {
        let birthdays = convertBirthdays(org)
        birthdayCallback?(.success(birthdays))
        birthdayCallback = nil
    }
    func orgFailed(error: Error) {
        // TBD - use the callback with an failure result
    }
    func eventsLoaded(events: [Event]) {}
    func eventsFailed(error: Error) {}
    func loginFailed(error: Error) {}
    func loginSucceeded(userId: String) {}
    func announcementsFailed(error: Error) {}
    func announcementsLoaded(announcements: [Announcement]) {}
    func productsLoaded(products: [Product]) {}
    func productsFailed(error: Error) {}
    func purchasesLoaded(purchases: [PurchaseOrder]) {}
    func purchasesFailed(error: Error) {}
    func userLoaded(user: UserInfo) {}
    func userFailed(error: Error) {}
}

orgLoaded(org:)通过convertBirthdays()将员工转换为生日事件,并将其转发completion闭包块。它在网络请求成功完成时被API中的getOrgChart()调用。其余存根出来的方法是APIDelegate所需要的,但不会在这里使用。

你不希望在你的测试中依赖这个网络请求。回到测试中,使用一个mockAPI

打开CalendarModelTests.swift,在上面添加以下内容var sut: CalendarModel!

var mockAPI: MockAPI!

你会看到下面的编译错误:

Use of undeclared type 'MockAPI'

为了解决这个问题,打开MockAPI.swift,并在文件检查器中把它添加到两个测试目标中:

image

setUpWithError()中替换:

sut = CalendarModel()

为以下代码:

mockAPI = MockAPI()
sut = CalendarModel(api: mockAPI)

接下来,在tearDownWithError()中,在调用super之前添加以下内容:

mockAPI = nil

接下来,在MockAPI.swift中为MockAPI添加以下内容:

// MARK: - Org
var mockEmployees: [Employee] = []

override func getOrgChart() {
    DispatchQueue.main.async {
        self.delegate?.orgLoaded(org: self.mockEmployees)
    }
}

现在,你的MockAPI将简单地调用orgLoaded(org:),当getOrgChart被调用时返回mockEmployees。3

最后,打开CalendarModelTests.swift,在testModel_whenBirthdaysLoaded_getsBirthdayEvents()given部分添加以下内容:

mockAPI.mockEmployees = mockEmployees()

这将把你定义的mockEmployees传递给MockAPI,所以它们将被返回。构建和测试,现在测试将被通过!

现在,你已经使用TDD添加了新的代码。这也重用了一些特性化的测试代码,这是用来打破API依赖性的测试的模拟。这就是处理遗留代码的双重重点:重点在添加新代码和特征化及重构现有代码之间转移。

做出改变和重构

添加生日功能的最后一块是重构视图控制器,以使用新的模型并将生日放到日历视图中。

要做到这一点,你需要把事件功能拉到模型类中。从一个测试开始! 添加到CalendarModelTests.swift中:

func testModel_whenEventsLoaded_getsEvents() {
    // given
    let expectedEvents = mockEvents()
    mockAPI.mockEvents = expectedEvents
    let exp = expectation(description: "events loaded")
    // when
    var loadedEvents: [Event]?
    sut.getEvents { res in
        loadedEvents = try? res.get()
        exp.fulfill()
    }
    // then
    wait(for: [exp], timeout: 1)
    XCTAssertEqual(loadedEvents, expectedEvents)
}

这测试了主要事件也被加载到模型中。有几个步骤可以让它发挥作用。

首先,将这个辅助函数添加到MockAPI.swift中,在类之外,这样以后就可以很容易地重复使用。

func mockEvents() -> [Event] {
    let events = [
        Event(
            name: "Event 1",
            date: Date(),
            type: .appointment,
            duration: .hours(1)),
        Event(
            name: "Event 2",
            date: Date(timeIntervalSinceNow: .days(20)),
            type: .meeting,
            duration: .minutes(30)),
        Event(
            name: "Event 3",
            date: Date(timeIntervalSinceNow: -.days(1)),
            type: .domesticHoliday,
            duration: .days(1))
    ]
    return events
}

这是一组静态的测试事件,可以在需要事件的时候使用。

接下来,你需要在CalendarModel中为常规事件的getBirthdays(completion:)做平行工作。

打开CalendarModel.swift并添加:

var eventsCallback: ((Result<[Event], Error>) -> Void)?

func getEvents(completion: @escaping (Result<[Event], Error>) -> Void) {
        eventsCallback = completion
        api.delegate = self
        api.getEvents()
}

这存储了一个回调块,并使用api类来获取事件。

接下来,还是在CalendarModel.swift中,将APIDelegate中的eventsLoaded(events:)更新为以下内容:

func eventsLoaded(events: [Event]) {
    eventsCallback?(.success(events))
    eventsCallback = nil
}

这就把事件从API转发到eventsCallback上。

现在,模型测试将通过,你已经成功了一半。下一步是用新的模型方法更新视图控制器。

更新视图控制器

为了帮助编写更多的测试,把mockBirthdayEventsmockEmployeesCalendarModelTests.swift移到MockAPI.swift(在mockEvents()下面的类之外),这样它们可以在多个文件中重复使用。

接下来在MyBizTests中创建一个新的单元测试案例类,名为CalendarViewControllerTests。这将是视图控制器新功能的单元测试的归宿。

在顶部,添加以下内容:

@testable import MyBiz

接下来,将CalendarViewControllerTests的内容替换为:

var sut: CalendarViewController!
var mockAPI: MockAPI!

override func setUpWithError() throws {
    super.setUp()
    sut = UIStoryboard(name: "Main", bundle: nil)
        .instantiateViewController(withIdentifier: "Calendar")
    as? CalendarViewController
    mockAPI = MockAPI()
    sut.api = mockAPI
    sut.loadViewIfNeeded()
}

override func tearDownWithError() throws {
    mockAPI = nil
    sut = nil
    super.tearDown()
}

func testLoadEvents_getsBirthdays () {
    // given
    mockAPI.mockEmployees = mockEmployees()
    let expectedEvents = mockBirthdayEvents()
    // when
    let predicate = NSPredicate { _, _ -> Bool in
        !self.sut.events.isEmpty
    }
    let exp = expectation(
        for: predicate,
        evaluatedWith: sut,
        handler: nil)
    sut.loadEvents()
    // then
    wait(for: [exp], timeout: 1)
    XCTAssertEqual(sut.events, expectedEvents)
}

这与该控制器的特征化测试类非常相似,只是这有一个加载生日事件的测试案例。

最后,打开CalendarViewController.swift,并添加以下var

var model: CalendarModel!

接下来,在viewDidLoad的末尾添加以下内容:

model = CalendarModel(api: api)

最后,用以下内容替换loadEvents

func loadEvents() {
    events = []
    model.getBirthdays { res in
        if let newEvents = try? res.get() {
            self.events.append(contentsOf: newEvents)
            self.calendarView.reloadData()
        }
    }
    model.getEvents { res in
        if let newEvents = try? res.get() {
            self.events.append(contentsOf: newEvents)
            self.calendarView.reloadData()
        }
    }
}

在这里,你在模型上调用getBirthdays(completion:)getEvents(completion:),并在完成后用新数据更新日历视图。

最后,你可以删除APIDelegate扩展,因为视图控制器不再是API委托。

现在,再次构建和测试,所有的都应该通过。恭喜你!你已经将员工的生日添加到了数据库中。你已经在应用程序的日历中添加了员工的生日,而没有破坏任何东西。人力资源总监会很高兴的。

挑战

接下来的几章将更详细地介绍这些类型的变化,所以这里的挑战是很轻松的。

挑战1:添加错误处理

回去为CalendarViewController添加错误处理。作为提示,你需要一种方法来模拟API错误并在CalendarModel和视图控制器中处理它们。

挑战2:清理代码

清理代码,如果加载事件时只需调用一次模型,而不是两次,就可以使代码更可靠。

关键点

在本章中,你增加了一个"小"功能,即按照代码变更算法为员工生日放置日历事件。以下是关键点。

  • 特性化测试让你发现现有的行为,并确保该行为不会在没有警告的情况下中断。
  • 然后利用测试驱动开发,激光聚焦于需要添加或改变的代码,以纳入新功能。
  • 如果不先写测试,就不要改变更多的代码。
  • 你可以通过代码注入来打破测试的依赖关系。

从这里开始,要去哪里?

本章的概念在Michael FeathersWorking Effectively with Legacy Code中有所阐述,如果你想了解更多的动机理论,可以阅读这本书。

本节其余各章对这些概念进行了扩展,给出了更多关于应用代码变更算法时的症结所在的具体细节。第12章"依赖关系图"涉及依赖关系图,第13章"打破依赖关系"涉及模块化和重构代码架构,第14章"依赖关系模块化"和第15章"为现有的大类添加功能"是专门用来做大改变的。

如果你跳过了关于网络的最后一节,再回头看一下,也是很有帮助的。重构一个像MyBiz这样架构不良、后台重的应用程序,需要测试和移动调用到网络层的代码。