跳转至

6: 依赖性注入和Mocks

到目前为止,你已经构建并测试了相当数量的应用程序。有一个巨大的漏洞你可能已经注意到了......这个"计算步骤的应用程序"还没有计算任何步骤!

在本章中,你将学习如何使用mocks来测试依赖于系统或外部服务的代码,而不需要调用服务--这些服务可能是不可用的、可用的或可靠的。这些技术允许你测试错误条件,如保存失败,并将逻辑与SDK隔离,如Core MotionHealthKit

手边没有iPhone?别担心,你将使用simulator处理mock数据来进行功能测试。

fakes、mocks和stubs是怎么回事?

当编写测试时,重要的是将SUT与代码的其他部分隔离开来,这样你的测试就会有很高的信心,他们会按照描述的那样测试系统。专注于边缘案例或错误条件的测试可能非常难写,因为它们经常涉及到SUT外部的特定状态。也很难诊断和调试由于SUT之外的间歇性或不一致的问题而失败的测试。

隔离SUT并规避这些问题的方法是使用测试替身:代表真实代码的对象。有几种测试替身的变体:

  • StubStub代表原始对象并提供预制的响应。这通常用于实现一个协议的一个方法,并为其他方法提供空或无返回的实现。
  • FakeFake通常有逻辑,但它们不提供真实或生产数据,而是提供测试数据。例如,一个假的网络管理器可能从本地JSON文件中读/写,而不是通过网络连接。
  • MockMock是用来验证行为的,也就是说,它们应该有一个预期,即mock的某个方法被调用,或者它的状态被设置为一个预期值。一般来说,mock会被期望提供测试值或行为。
  • Partial mock:普通mock是对生产对象的完全替代,而partial mock则使用生产代码,只覆盖其中的一部分来测试期望值。部分mock通常是一个子类或提供一个生产对象的代理。

了解CMPedometer

有几种收集用户活动数据的方法,但Core Motion中的CMPedometer API是迄今为止最简单的。

使用CMPedometer很简单,因为:

  1. 检查计步器是否可用,用户是否授予权限。
  2. 开始监听更新。
  3. 收集步数和距离的更新,直到用户暂停,完成目标或输给Nessie

计步器对象提供了一个CMPedometerHandler,它有一个回调,接收CMPedometerData(或一个错误)。这个数据对象有步数和行走的距离。

问题是...你在使用TDD,所以使用CMPedometer是很棘手的,即使你的主机应用运行在一个物理设备上。CMPedometer依赖于设备状态,而设备状态对于一致的单元测试来说变化太大。

试一试吧。首先,导航到并打开启动项目。接下来,打开PedometerTests.swift,它已经被添加到数据模型测试案例组。接下来在tearDownWithError()下面添加以下内容:

func testCMPedometer_whenQueries_loadsHistoricalData() {
    // given
    var error: Error?
    var data: CMPedometerData?
    let exp = expectation(description: "pedometer query returns")
    // when
    let now = Date()
    let then = now.addingTimeInterval(-1000)
    sut.queryPedometerData(
        from: then,
        to: now) { pedometerData, pedometerError in
            error = pedometerError
            data = pedometerData
            exp.fulfill()
        }
    // then
    wait(for: [exp], timeout: 1)
    XCTAssertNil(error)
    XCTAssertNotNil(data)
    if let steps = data?.numberOfSteps {
        XCTAssertGreaterThan(steps.intValue, 0)
    } else {
        XCTFail("no step data")
    }
}

这个测试为返回的计步器查询创建了一个期望,调用queryPedometerData(from:to:)来查询数据并实现期望。然后它断言数据至少包含一个步骤。

虽然这个测试可以编译,但在启动时却崩溃了。苹果要求使用Core Motion的许可。对在测试中使用真正的CMPedometer对象的第一击。为了请求许可,需要提供使用说明。打开应用程序的Info.plist

添加一个新的行,使用密钥Privacy - Motion Usage Description,并将值设置为Pedometer access is required to gather step and distance information.

image

构建和测试,可能会失败,这取决于你是在设备上还是在simulator上运行应用,以及你是否接受了弹出的权限。由于缺乏对CMPedometer的控制而导致的不可预测性使得这个测试相当糟糕。这听起来像是一个mock的工作!

删除PedometerTests.swift测试文件;你将会做得更好。

Mocking

重述问题

打开AppModelTests.swift,在下面添加以下测试// MARK: - Pedometer:

func testAppModel_whenStarted_startsPedometer() {
    //given
    givenGoalSet()
    let predicate = NSPredicate { model, _ -> Bool in
        (model as? AppModel)?.pedometerStarted ?? false
    }
    let exp = expectation(
        for: predicate,
        evaluatedWith: sut,
        handler: nil)
    // when
    try! sut.start()
    // then
    wait(for: [exp], timeout: 1)
    XCTAssertTrue(sut.pedometerStarted)
}

这个测试的目的是验证启动应用模型也会启动计步器。如果你读过前一章,你会认识到用于等待状态改变的难以捉摸的XCTNSPredicateExpectation

这个测试与之前的测试有细微的不同。它没有直接测试计步器对象。相反,该测试通过测量对计步器的影响来验证SUT的行为(通过pedometerStarted暴露)。

为了编译这个程序,你需要修改AppModel。打开AppModel.swift,添加以下两个变量:

let pedometer = CMPedometer()
private(set) var pedometerStarted = false

这样就增加了一个小的状态来记录计步器的情况。

接下来,在start()的末尾添加以下内容:

startPedometer()

最后,在文件的底部添加以下扩展名:

// MARK: - Pedometer
extension AppModel {
    func startPedometer() {
        pedometer.startEventUpdates { _, error in
            if error == nil {
                self.pedometerStarted = true
            }
        }
    }
}

这是用计步器事件处理程序的回调来确定计步器是否已经启动。对于CMPedometer,你不能写一个简单的测试来检查它是否已经启动,因为这个状态没有在API中公开。然而,这个回调会在启动事件更新后很快被调用。如果步数计算是可用的,那么就不会有错误,你就知道它已经开始了。

构建和测试,如果你在设备上运行并授予了运动数据的权限,这将通过。如果你在simulator或设备上运行而没有授予这个权限,它就会失败。

Mocking计步器

为了突破这个僵局,现在是时候创建一个mock的计步器了。为了把CMPedometer换成它的mock对象,你首先需要把计步器的接口和它的实现分开。

为了做到这一点,你将使用两种经典模式:FacadeBridge

首先,在应用程序中创建一个新的组,命名为Pedometer。在这个组中,创建一个新的Swift文件,Pedometer.swift

现在,只需添加以下代码:

protocol Pedometer {
    func start()
}

这是桥梁协议的开始,它将允许你用任何计步器的实现来替代真正的计步器。

为了做到这一点,你必须为CMPedometer声明一致性。在该组中创建另一个Swift文件:CMPedometer+Pedometer.swift,并将其内容替换为以下内容:

import CoreMotion

extension CMPedometer: Pedometer {
    func start() {
        startEventUpdates { _, _ in
            // do nothing here for now
        }
    }
}

这宣告了与新协议的一致性,并迁移了你在startPedometer中实现的启动行为。它还没有做什么,但很快就会做。

接下来,打开AppModel.swift,将AppModelCMPedometer的具体实现解耦:

  1. 将计步器的声明改为:let pedometer: Pedometer
  2. 删除pedometerStarted属性。
  3. 添加以下初始化程序:
init(pedometer: Pedometer = CMPedometer()) {
    self.pedometer = pedometer
}
  1. startPedometer改为:
func startPedometer() {
    pedometer.start()
}

可选的init参数是你能够用mock对象替换默认的CMPedometer的地方。startPedometer中代码的减少是使用Facade的好处。你可以把CMPedometer的具体复杂性隐藏在一个简化的接口后面。

现在,是时候创建mock对象了!

FitNessTestsMocks组中创建一个新的Swift文件,名为MockPedometer.swift,并将其内容替换为以下内容:

import CoreMotion
@testable import FitNess

class MockPedometer: Pedometer {

    private(set) var started: Bool = false

    func start() {
        started = true
    }
}

这创造了一个非常不同的计步器的实现。它的start方法没有调用CoreMotion,而只是设置了一个Bool,可以在测试中检查。这是嘲弄的另一个价值--你可以监视或检查嘲弄,以检查正确的方法是否被调用或其状态是否被适当地设置。

现在,回到AppModelTests.swift,在上面添加以下属性并更新setUpWithError

var mockPedometer: MockPedometer!

override func setUpWithError() throws {
    try super.setUpWithError()
    mockPedometer = MockPedometer()
    sut = AppModel(pedometer: mockPedometer)
}

这将创建一个mock的计步器并在创建sut时使用它。

现在,回到testAppModel_whenStarted_startsPedometer,用下面的内容替换它:

func testAppModel_whenStarted_startsPedometer() {
    //given
    givenGoalSet()
    // when
    try! sut.start()
    // then
    XCTAssertTrue(mockPedometer.started)
}

这个简化的测试现在测试mock对象上的启动的副作用。除了是一个更简单的测试外,无论设备状态如何,它都能保证通过。建立和测试,你会看到它通过了。

处理错误条件

mock对象使得测试错误条件变得容易。如果你到目前为止一直在使用Simulator和设备,你可能已经遇到了这些错误状态中的一个或两个:

  • 步数计算在设备上是不可用的,比如simulator
  • 用户可能拒绝允许在设备上进行运动记录。

处理没有计步器的情况

为了处理第一种情况,你必须添加功能来检测计步器是否可用并通知用户。

首先,在AppModelTestsPedometer标记下添加这个测试:

func testPedometerNotAvailable_whenStarted_doesNotStart() {
    // given
    givenGoalSet()
    mockPedometer.pedometerAvailable = false
    // when
    try! sut.start()
    // then
    XCTAssertEqual(sut.appState, .notStarted)
}

这个简单的检查只是为了确保当计步器不可用时,应用程序的状态不会进行到inProgress

接下来,打开Pedometer.swift,在协议定义中添加以下内容:

var pedometerAvailable: Bool { get }

这将创建一个var来读取可用性状态。

接下来,打开MockPedometer.swift,通过添加以下内容更新MockPedometer

var pedometerAvailable: Bool = true

而真正的实现--被你的应用程序代码使用--打开CMPedometer+Pedometer.swift并添加以下内容:

var pedometerAvailable: Bool {
    return CMPedometer.isStepCountingAvailable() &&
    CMPedometer.isDistanceAvailable() &&
    CMPedometer.authorizationStatus() != .restricted
}

你可以看到,"真正的"实现要有趣得多,但并不可控。

现在,测试已经编译完毕,是时候让它通过了。

打开AppModel.swift,找到start(),在appState = .inProgress前添加以下内容:

guard pedometer.pedometerAvailable else {
    AlertCenter.instance.postAlert(alert: .noPedometer)
    return
}

与其他的guard语句不同,这个条件不会引发异常;相反,它使用新的AlertCenter方式与用户进行交流。由此产生的错误处理,在start()被调用的地方,会有一些不同,重构它不在本章的范围内。

构建和测试,现在就可以通过了,因为新的防护措施可以防止appState在计步器不可用时进展到inProgress。请注意,如果你运行整个套件,其他一些测试现在会失败--你一会儿会回到这些测试。

这也是一个测试警报的好主意。

打开AppModelTests.swift,在testPedometerNotAvailable_whenStarted_doesNotStart()下面添加以下内容:

func testPedometerNotAvailable_whenStarted_generatesAlert() {
    // given
    givenGoalSet()
    mockPedometer.pedometerAvailable = false
    let exp = expectation(
        forNotification: AlertNotification.name,
        object: nil,
        handler: alertHandler(.noPedometer))
    // when
    try! sut.start()
    // then
    wait(for: [exp], timeout: 1)
}

这就把pedometerAvailable设置为false,并等待相应的警报。由于之前在AppModel中加入了显示该警报的代码,该测试将在门外通过。

注入依赖性

重新运行所有的测试,你会看到StepCountControllerTests的失败。这是因为AppModel中这个新的pedometerAvailable卫士在其他测试中仍然依赖于生产的CMPedometer

解决这个问题的一个方法是把计步器变成一个变量,这样它就可以在测试中被修改。

打开AppModel.swift,把let改成var

var pedometer: Pedometer

接下来,打开ViewControllers.swift,在getRootViewController()的顶部添加以下内容:

AppModel.instance.pedometer = MockPedometer()

Root视图控制器被取来测试时,这就设置了mock计步器,这意味着任何视图控制器测试都会得到一个mock计步器。建立并运行所有的测试,现在它们将通过。

处理没有权限的问题

另一个需要处理的错误状态是当用户拒绝接受弹出的权限。

打开AppModelTests.swift,在类的末尾添加以下内容:

func testPedometerNotAuthorized_whenStarted_doesNotStart() {
    // given
    givenGoalSet()
    mockPedometer.permissionDeclined = true
    // when
    try! sut.start()
    // then
    XCTAssertEqual(sut.appState, .notStarted)
}

func testPedometerNotAuthorized_whenStarted_generatesAlert() {
    // given
    givenGoalSet()
    mockPedometer.permissionDeclined = true
    let exp = expectation(
        forNotification: AlertNotification.name,
        object: nil,
        handler: alertHandler(.notAuthorized))
    // when
    try! sut.start()
    // then
    wait(for: [exp], timeout: 1)
}

这些测试是对permissionDeclined错误的处理。第一个测试是检查应用程序的状态是否保持在.notStarted,第二个测试是检查用户警报。

为了让它们工作,你需要在几个地方添加permissionDeclined

首先,打开Pedometer.swift,在协议定义中添加以下内容:

var permissionDeclined: Bool { get }

接下来,打开MockPedometer.swift,在mock实现中添加以下内容:

var permissionDeclined: Bool = false

接下来,打开CMPedometer+Pedometer.swift,在真正的实现中添加以下内容:

var permissionDeclined: Bool {
    return CMPedometer.authorizationStatus() == .denied
}

最后,打开AppModel.swift,再添加一条guard语句在start

guard !pedometer.permissionDeclined else {
    AlertCenter.instance.postAlert(alert: .notAuthorized)
    return
}

在处理了permissionDeclined后,现在测试将通过。

Mocking一个回调

还有一个重要的错误情况需要处理。这种情况发生在用户第一次在具有计步器功能的设备上点击开始时。在这种情况下,启动流程继续进行,但用户可以在弹出的权限窗口中拒绝。如果用户拒绝了,在eventUpdates的回调中就会出现错误。

让我们测试一下这个条件。打开AppModelTests.swift,在类定义的末尾添加以下内容:

func testAppModel_whenDeniedAuthAfterStart_generatesAlert() {
    // given
    givenGoalSet()
    mockPedometer.error = MockPedometer.notAuthorizedError
    let exp = expectation(
        forNotification: AlertNotification.name,
        object: nil,
        handler: alertHandler(.notAuthorized))
    // when
    try! sut.start()
    // then
    wait(for: [exp], timeout: 1)
}

与之前的测试不同,这个测试没有明确设置权限拒绝,所以模型可以尝试启动计步器。相反,这个测试依赖于在计步器启动时将一个错误传递给mock来产生警报。

下一步是建立一个方法,把这个错误传回给SUT

打开Pedometer.swift,将start()的定义改为如下:

func start(completion: @escaping (Error?) -> Void)

这样就可以为错误处理提供一个完成的回调。

接下来,更新CMPedometer+Pedometer.swift,将start替换为:

func start(completion: @escaping (Error?) -> Void) {
    startEventUpdates { _, error in
        completion(error)
    }
}

接下来在AppModel.swift中添加错误处理,用以下内容替换startPedometer

func startPedometer() {
    pedometer.start { error in
        if let error = error {
            let alert = error.is(CMErrorMotionActivityNotAuthorized)
            ? .notAuthorized
            : Alert(error.localizedDescription)
            AlertCenter.instance.postAlert(alert: alert)
        }
    }
}

这个闭包检查启动计步器时是否有错误返回。如果是CMErrorMotionActivityNotAuthorized,则发布一个notAuthorized警报;否则,发布一个带有错误信息的通用警报。

这样就解决了生产代码的问题,但你还需要更新MockPedometer

打开MockPedometer.swift,将start()改为以下内容:

var error: Error?

func start(completion: @escaping (Error?) -> Void) {
    started = true
    DispatchQueue.global(qos: .default).async {
        completion(self.error)
    }
}

static let notAuthorizedError = NSError(
    domain: CMErrorDomain,
    code: Int(CMErrorMotionActivityNotAuthorized.rawValue),
    userInfo: nil)

这个更新将调用完成,传递其错误属性。为了方便起见,静态的notAuthorizedError会创建一个错误对象,与Core Motion在未授权时返回的内容相匹配。这就是你在testAppModel_whenDeniedAuthAfterStart_generatesAlert中使用的东西。

建立并再次测试,你的测试应该通过。

获取实际数据

现在是处理数据更新的时候了。传入的数据是应用程序中最重要的部分,对它进行适当的mock至关重要。实际的步数和距离计数是由CMPedometer通过恰当的CMPedometerData对象提供的。这个对象也应该在应用程序和Core Motion之间被抽象出来。

打开Pedometer.swift,添加以下协议:

protocol PedometerData {
    var steps: Int { get }
    var distanceTravelled: Double { get }
}

这在CMPedometerData周围增加了一个抽象,这样就可以mock步骤和距离数据。通过在测试目标的Mocks组中创建一个新的.swift文件来实现。MockData.swift并将其内容替换为以下内容:

@testable import FitNess

struct MockData: PedometerData {
    let steps: Int
    let distanceTravelled: Double
}

有了这些,打开AppModelTests.swift,在类定义的最后添加以下测试:

func testModel_whenPedometerUpdates_updatesDataModel() {
    // given
    givenInProgress()
    let data = MockData(steps: 100, distanceTravelled: 10)
    // when
    mockPedometer.sendData(data)
    // then
    XCTAssertEqual(sut.dataModel.steps, 100)
    XCTAssertEqual(sut.dataModel.distance, 10)
}

该测试验证了所提供的数据被应用到数据模型中。这需要对MockPedometer进行更新以传递数据。首先,考虑一下这些数据最终将如何传递给AppModel

打开Pedometer.swift。在Pedometer协议中,把start(completion:)的签名改成如下:

func start(dataUpdates: @escaping (PedometerData?, Error?) -> Void,
           eventUpdates: @escaping (Error?) -> Void)

dataUpdates块将提供一种从计步器返回PedometerData的方法。 eventUpdates将返回事件,就像以前的完成块那样。

MockPedometer中,创建两个新的变量来存放这些回调块:

var updateBlock: ((Error?) -> Void)?
var dataBlock: ((PedometerData?, Error?) -> Void)?

接下来,用以下内容替换start(completion:)

func start(dataUpdates: @escaping (PedometerData?, Error?) -> Void,
           eventUpdates: @escaping (Error?) -> Void) {
    started = true
    updateBlock = eventUpdates
    dataBlock = dataUpdates

    DispatchQueue.global(qos: .default).async {
        self.updateBlock?(self.error)
    }
}

func sendData(_ data: PedometerData?) {
    dataBlock?(data, error)
}

这两个块被保存起来供以后使用,但是updateBlock仍然作为这个方法的一部分被调用,就像以前的完成一样。你不需要为这个测试更新任何以前的测试,因为行为是一样的。还添加了sendData(_:),它被测试用来调用带有mock数据的dataBlock

你也需要为这个新的逻辑更新CMPedometer扩展。打开CMPedometer+Pedometer.swift,将start(completion:)改为以下内容:

func start(dataUpdates: @escaping (PedometerData?, Error?) -> Void,
           eventUpdates: @escaping (Error?) -> Void) {
    startEventUpdates { _, error in
        eventUpdates(error)
    }

    startUpdates(from: Date()) { data, error in
        dataUpdates(data, error)
    }
}

这保留了之前的startEventUpdates行为,另外增加了对startUpdates的新调用来转发数据更新。

你还需要用新的PedometerData协议包住CMPedometerData。在文件的底部添加以下扩展:

extension CMPedometerData: PedometerData {
    var steps: Int {
        return numberOfSteps.intValue
    }

    var distanceTravelled: Double {
        return distance?.doubleValue ?? 0
    }
}

这样就把CMPedometerData的值转为PedometerData变量。

最后,打开AppModel.swift,将startPedometer()改为以下内容:

func startPedometer() {
    pedometer.start(
        dataUpdates: handleData,
        eventUpdates: handleEvents)
}

func handleData(data: PedometerData?, error: Error?) {
    if let data = data {
        dataModel.steps += data.steps
        dataModel.distance += data.distanceTravelled
    }
}

func handleEvents(error: Error?) {
    if let error = error {
        let alert = error.is(CMErrorMotionActivityNotAuthorized)
        ? .notAuthorized
        : Alert(error.localizedDescription)
        AlertCenter.instance.postAlert(alert: alert)
    }
}

这就把之前的事件处理移到了自己的方法中,并创建了一个新的方法来在有新数据时更新dataModel。你会注意到,这里没有处理数据更新错误。这是在本章结束后留给你的一个挑战!

构建和测试,看那绿色的生长!

制作一个功能性的Fake

在这一点上,如果能看到应用程序的运行,那肯定是件好事。单元测试在验证逻辑方面很有用,但在验证你是否建立了良好的用户体验方面却很糟糕。一种方法是在设备上构建和运行,但这需要你走动来完成目标。这非常耗费时间和卡路里。一定有一个更好的方法!

进入假的计步器。你已经完成了从真正的CMPedometer中抽象出应用程序的工作,所以建立一个假的计步器是很简单的,它可以加快时间或弥补运动。

在计步器组中创建一个新的.swift文件:SimulatorPedometer.swift。把它的内容替换成以下内容:

import Foundation

class SimulatorPedometer: Pedometer {

    struct Data: PedometerData {
        let steps: Int
        let distanceTravelled: Double
    }

    var pedometerAvailable: Bool = true
    var permissionDeclined: Bool = false
    var timer: Timer?
    var distance = 0.0
    var updateBlock: ((Error?) -> Void)?
    var dataBlock: ((PedometerData?, Error?) -> Void)?

    func start(dataUpdates: @escaping (PedometerData?, Error?) -> Void,
               eventUpdates: @escaping (Error?) -> Void){
        updateBlock = eventUpdates
        dataBlock = dataUpdates
        timer = Timer(
            timeInterval: 1,
            repeats: true
        ) { _ in
            self.distance += 1
            print("updated distance: \(self.distance)")
            let data = Data(
                steps: 10,
                distanceTravelled: self.distance)
            self.dataBlock?(data, nil)
        }
        RunLoop.main.add(timer!, forMode: RunLoop.Mode.default)
        updateBlock?(nil)
    }

    func stop() {
        timer?.invalidate()
        updateBlock?(nil)
        updateBlock = nil
        dataBlock = nil
    }
}

这个巨大的代码块实现了PedometerPedometerData协议。它设置了一个定时器对象,一旦开始被调用,每秒增加10步。每次更新时,它用新的数据调用dataBlock

你还添加了一个停止方法,用来停止定时器并进行清理。这将在你添加通过点击暂停按钮来暂停计步器的功能时使用。

为了在应用程序中使用mock的计步器,打开AppModel.swift,并添加以下static var

static var pedometerFactory: (() -> Pedometer) = {
    #if targetEnvironment(simulator)
    return SimulatorPedometer()
    #else
    return CMPedometer()
    #endif
}

这个方法根据应用程序的目标环境,创建一个SimulatorPedometer()CMPedometer()

接下来,用以下内容替换init

init(pedometer: Pedometer = pedometerFactory()) {
    self.pedometer = pedometer
}

现在在simulator中建立并运行。点右下角的设置齿轮,输入100步的目标。

点选Start,你会看到警报通知的到来!

img

为追逐视图布线

现在看这个应用程序,中间的那个白框有点令人失望。这就是追逐视图(它展示了Nessie对用户的追逐),还没有被连接起来。

为了测试它是否能准确反映用户的状态,你可以使用部分mock。通过部分mock追逐视图,你可以在不中断其主要逻辑的情况下增加一点额外的测试功能。这代替了完整的mock,它取代了所有的功能。

Mocks组中创建一个名为ChaseViewPartialMock.swift的新文件,并将其内容改为以下内容:

@testable import FitNess

class ChaseViewPartialMock: ChaseView {
    var updateStateCalled = false
    var lastRunner: Double?
    var lastNessie: Double?

    override func updateState(runner: Double, nessie: Double) {
        updateStateCalled = true
        lastRunner = runner
        lastNessie = nessie
        super.updateState(runner: runner, nessie: nessie)
    }
}

这个部分mock重写了updateState(runner:nessie:),这样就可以在测试中记录和验证发送到它的值。 updateStateCalled可以被测试用来跟踪该方法已经被调用--这是一个常见的mock验证。

这个类被StepCountController使用。

首先打开StepCountControllerTests.swift并添加以下变量:

var mockChaseView: ChaseViewPartialMock!

接下来,在setUpWithError的底部添加以下几行:

mockChaseView = ChaseViewPartialMock()
sut.chaseView = mockChaseView

最后,添加一个测试,验证视图是否被更新:

func testChaseView_whenDataSent_isUpdated() {
    // given
    givenInProgress()
    // when
    let data = MockData(steps: 500, distanceTravelled: 10)
    (AppModel.instance.pedometer as! MockPedometer).sendData(data)
    // then
    XCTAssertTrue(mockChaseView.updateStateCalled)
    XCTAssertEqual(mockChaseView.lastRunner, 0.5)
}

这使用mock的计步器来发送数据,并在部分mock的追逐视图上验证状态。因为Nessie的代码还不是项目的一部分,所以没有检查Nessie的位置值。

构建和测试,你会看到这两个断言都没有通过,因为追逐视图还没有被更新。

打开StepCountController.swift,在viewDidLoad()中添加以下内容来启动这个更新:

NotificationCenter.default
    .addObserver(
        forName: DataModel.UpdateNotification,
        object: nil,
        queue: nil) { _ in
            self.updateUI()
        }

它监听数据模型的更新,并在有数据更新时调用updateUI

updateUI调用updateChaseView,它需要计算Nessierunner的位置,然后在视图中更新它们。将updateChaseView替换为以下内容:

private func updateChaseView() {
    chaseView.state = AppModel.instance.appState

    let dataModel = AppModel.instance.dataModel
    let runner = Double(dataModel.steps) / Double(dataModel.goal ?? 10_000)
    let nessie = dataModel.nessie.distance > 0 ?

    dataModel.distance / dataModel.nessie.distance : 0
    chaseView.updateState(runner: runner, nessie: nessie)
}

这是从数据模型中收集用户和Nessie的距离,计算出完成百分比,并将其呈现给追逐视图,这样就可以相应地放置头像。

构建和测试,看测试通过! 构建并运行,看看视图的运行情况:

img

时间的依赖性

最后缺少的主要部分是Nessie。她应该在应用程序进行时追赶用户。她的进展将以一个恒定的速度被测量。随着时间的推移测量什么?听起来像是一个计时器的答案。

计时器是出了名的难测试。它们需要使用期望值和潜在的长时间等待。有几个常见的解决方案。

  1. 在测试中,使用一个非常短的定时器(例如,一毫秒而不是一秒钟)。
  2. 将定时器换成一个mock,立即执行回调。
  3. 直接使用回调,并为应用程序或用户接受度测试保存计时。

其中任何一个都是合理的解决方案,但你要选择第3个方案。在NessieTests.swift中,添加这个测试:

func testNessie_whenUpdated_incrementsDistance() {
    // when
    sut.incrementDistance()
    // then
    XCTAssertEqual(sut.distance, sut.velocity)
}

这直接调用incrementDistance,就像Nessie类中的Timer回调那样。它断言在距离递增后,它等于速度。

这个测试还没有通过,因为incrementDistance是存根的。打开Nessie.swift,在incrementDistance()中添加以下一行:

distance += velocity

现在距离增加了,测试将通过。

挑战

你已经到了本章的结尾,但还没有到应用程序的结尾。你应该能够利用你学到的测试工具来完成这个应用程序。你的挑战是添加以下测试和功能来完成这个应用程序。

  • 完成暂停功能,能够暂停和恢复计步器的运行。
  • Nessie与应用程序的状态连线,使其能够适当地启动、暂停和重置。由于用户和Nessie都将从0开始,所以你也必须给用户一个小的开始。
  • 完成对来自计步器的数据错误的处理(使用警报中心)。

关键点

  • 双重测试让你在与其他系统隔离的情况下测试代码,特别是那些系统SDK的一部分,依赖网络或定时器。
  • Mock让你在一个类的测试实现中进行交换,而partial mock让你只是替换一个类的一部分。
  • Fake让你为测试或在simulator中使用提供数据。

从这里走到哪里?

这就是了。在过去的几章中,你已经按照TDD原则从头开始构建了一个应用程序。

这一章涵盖了使用mock将测试对象与外部代码和事件分开。这只是划出了可能的表面。下一节将是关于使用网络请求等外部服务的全部内容。

如果你想了解更多关于doubles的使用和历史,请阅读Martin Fowler的这篇优秀文章《Mocks Aren't Stubs》