6: 依赖性注入和Mocks¶
到目前为止,你已经构建并测试了相当数量的应用程序。有一个巨大的漏洞你可能已经注意到了......这个"计算步骤的应用程序"还没有计算任何步骤!
在本章中,你将学习如何使用mocks
来测试依赖于系统或外部服务的代码,而不需要调用服务--这些服务可能是不可用的、可用的或可靠的。这些技术允许你测试错误条件,如保存失败,并将逻辑与SDK
隔离,如Core Motion
和HealthKit
。
手边没有iPhone
?别担心,你将使用simulator
处理mock
数据来进行功能测试。
fakes、mocks和stubs是怎么回事?¶
当编写测试时,重要的是将SUT
与代码的其他部分隔离开来,这样你的测试就会有很高的信心,他们会按照描述的那样测试系统。专注于边缘案例或错误条件的测试可能非常难写,因为它们经常涉及到SUT外部的特定状态。也很难诊断和调试由于SUT
之外的间歇性或不一致的问题而失败的测试。
隔离SUT
并规避这些问题的方法是使用测试替身:代表真实代码的对象。有几种测试替身的变体:
Stub
:Stub
代表原始对象并提供预制的响应。这通常用于实现一个协议的一个方法,并为其他方法提供空或无返回的实现。Fake
:Fake
通常有逻辑,但它们不提供真实或生产数据,而是提供测试数据。例如,一个假的网络管理器可能从本地JSON
文件中读/写,而不是通过网络连接。Mock
:Mock
是用来验证行为的,也就是说,它们应该有一个预期,即mock
的某个方法被调用,或者它的状态被设置为一个预期值。一般来说,mock
会被期望提供测试值或行为。Partial mock
:普通mock
是对生产对象的完全替代,而partial mock
则使用生产代码,只覆盖其中的一部分来测试期望值。部分mock
通常是一个子类或提供一个生产对象的代理。
了解CMPedometer¶
有几种收集用户活动数据的方法,但Core Motion
中的CMPedometer API
是迄今为止最简单的。
使用CMPedometer
很简单,因为:
- 检查计步器是否可用,用户是否授予权限。
- 开始监听更新。
- 收集步数和距离的更新,直到用户暂停,完成目标或输给
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.
。
构建和测试,可能会失败,这取决于你是在设备上还是在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
对象,你首先需要把计步器的接口和它的实现分开。
为了做到这一点,你将使用两种经典模式:Facade
和Bridge
。
首先,在应用程序中创建一个新的组,命名为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
,将AppModel
与CMPedometer
的具体实现解耦:
- 将计步器的声明改为:
let pedometer: Pedometer
。 - 删除
pedometerStarted
属性。 - 添加以下初始化程序:
init(pedometer: Pedometer = CMPedometer()) {
self.pedometer = pedometer
}
- 将
startPedometer
改为:
func startPedometer() {
pedometer.start()
}
可选的init
参数是你能够用mock
对象替换默认的CMPedometer
的地方。startPedometer
中代码的减少是使用Facade
的好处。你可以把CMPedometer
的具体复杂性隐藏在一个简化的接口后面。
现在,是时候创建mock
对象了!
在FitNessTests
的Mocks
组中创建一个新的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
。 - 用户可能拒绝允许在设备上进行运动记录。
处理没有计步器的情况¶
为了处理第一种情况,你必须添加功能来检测计步器是否可用并通知用户。
首先,在AppModelTests
的Pedometer
标记下添加这个测试:
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
}
}
这个巨大的代码块实现了Pedometer
和PedometerData
协议。它设置了一个定时器对象,一旦开始被调用,每秒增加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
,你会看到警报通知的到来!
为追逐视图布线¶
现在看这个应用程序,中间的那个白框有点令人失望。这就是追逐视图(它展示了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
,它需要计算Nessie
和runner
的位置,然后在视图中更新它们。将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
的距离,计算出完成百分比,并将其呈现给追逐视图,这样就可以相应地放置头像。
构建和测试,看测试通过! 构建并运行,看看视图的运行情况:
时间的依赖性¶
最后缺少的主要部分是Nessie
。她应该在应用程序进行时追赶用户。她的进展将以一个恒定的速度被测量。随着时间的推移测量什么?听起来像是一个计时器的答案。
计时器是出了名的难测试。它们需要使用期望值和潜在的长时间等待。有几个常见的解决方案。
- 在测试中,使用一个非常短的定时器(例如,一毫秒而不是一秒钟)。
- 将定时器换成一个
mock
,立即执行回调。 - 直接使用回调,并为应用程序或用户接受度测试保存计时。
其中任何一个都是合理的解决方案,但你要选择第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》。