我们不仅希望我们的架构是可测试的,而且我们还希望它非常容易地编写测试,甚至可能是编写测试的乐趣! 现在在编写测试时涉及到一些礼节,因此我们将展示如何将这些细节隐藏在一个良好的符合人体工程学的API后面。
Simplifying testing stateå
让我们从第一个问题开始:断言所有reducer的状态非常冗长。
例如,如果我们看一下我们最简单的counter视图测试:
func testIncrButtonTapped() {
var state = CounterViewState(
alertNthPrime: nil,
count: 2,
favoritePrimes: [3, 5],
isNthPrimeButtonDisabled: false
)
let effects = counterViewReducer(&state, .counter(.incrTapped))
XCTAssertEqual(
state,
CounterViewState(
alertNthPrime: nil,
count: 3,
favoritePrimes: [3, 5],
isNthPrimeButtonDisabled: false
)
)
XCTAssertTrue(effects.isEmpty)
}
我们通过重新创建它的整体来断言更新状态,但我们实际上只关心一行:
count: 3,
count是唯一不同于初始状态的字段,但很难看出是哪个字段,尤其是在没有手动检查和比较每个初始化器调用的情况下的更改。
我们可以使用断言的message来更好地捕获我们的意图。
XCTAssertEqual(
state,
CounterViewState(
alertNthPrime: nil,
count: 3,
favoritePrimes: [3, 5],
isNthPrimeButtonDisabled: false
),
"Expected count to increment to 3"
)
但这使得测试更加冗长,包含的信息可能很快就会从断言实际试图捕获的信息中过时。
//"Expected count to increment to 3"
此外,CounterViewState只包含四个字段。您可以想象在典型的应用程序代码中有更大的类型,因此这种方式的测试将很快变得不可持续。简化我们断言的方式可能会更好,以便我们的测试更直接地描述我们的expectations。
在将原来的状态提供给reducer之前,我们可以创建一个状态的可变副本。
func testIncrButtonTapped() {
var state = CounterViewState(
alertNthPrime: nil,
count: 2,
favoritePrimes: [3, 5],
isNthPrimeButtonDisabled: false
)
var expected = state
let effects = counterViewReducer(&state, .counter(.incrTapped))
这样,我们就可以应用突变来改变我们所期望的。在这种情况下,count:
// XCTAssertEqual(
// state,
// CounterViewState(
// alertNthPrime: nil,
// count: 3,
// favoritePrimes: [3, 5],
// isNthPrimeButtonDisabled: false
// )
// )
expected.count = 3
XCTAssertEqual(state, expected)
XCTAssertTrue(effects.isEmpty)
}
九行变成一行,我们的期望在突变中清晰地表现出来。这比之前的一团状态要好得多。
但是我们测试的很大一部分仍然致力于初始化状态:我们在每个测试的开始调用整个初始化器,这使得事情变得更脆弱:如果CounterViewState结构体改变了,任何构造它的测试将无法编译并需要更新,不管这些改变是否影响任何特定的测试。
我们可以做的一件事是用一些合理的默认值更新CounterViewState的初始化器。
public struct CounterViewState: Equatable {
public var alertNthPrime: PrimeAlert?
public var count: Int
public var favoritePrimes: [Int]
public var isNthPrimeButtonDisabled: Bool
public init(
alertNthPrime: PrimeAlert? = nil,
count: Int = 0,
favoritePrimes: [Int] = [],
isNthPrimeButtonDisabled: Bool = false
) {
Swift编译器现在只允许我们插入特定测试所关心的值。
在testIncrButtonTapped的情况下,我们实际上只关心count,因为它是唯一被mutated的字段。
func testIncrButtonTapped() {
var state = CounterViewState(count: 2)
var expected = state
let effects = counterViewReducer(&state, .counter(.incrTapped))
expected.count = 3
XCTAssertEqual(state, expected)
XCTAssertTrue(effects.isEmpty)
}
这个测试非常简洁。
让我们继续并更新counter递减的测试。
func testDecrButtonTapped() {
var state = CounterViewState(count: 2)
var expected = state
let effects = counterViewReducer(&state, .counter(.decrTapped))
expected.count = 1
XCTAssertEqual(state, expected)
XCTAssertTrue(effects.isEmpty)
}
很简单! 21行变成了9行,专注于他们所关心的细节。
好了,我们的下一个测试更有实质意义。事实上,这是迄今为止我们所编写的最复杂的测试之一。它运行点击第n个质数按钮的整个流程,评估获得第n个质数响应的effect,最后dismiss结果警报。
func testNthPrimeButtonHappyFlow() {
Current.nthPrime = { _ in .sync { 17 } }
var state = CounterViewState(
alertNthPrime: nil,
count: 2,
favoritePrimes: [3, 5],
isNthPrimeButtonDisabled: false
)
var effects = counterViewReducer(&state, .counter(.nthPrimeButtonTapped))
XCTAssertEqual(
state,
CounterViewState(
alertNthPrime: nil,
count: 2,
favoritePrimes: [3, 5],
isNthPrimeButtonDisabled: true
)
)
XCTAssertEqual(effects.count, 1)
var nextAction: CounterViewAction!
let receivedCompletion = self.expectation(description: "receivedCompletion")
let cancellable = effects[0].sink(
receiveCompletion: { _ in
receivedCompletion.fulfill()
},
receiveValue: { action in
XCTAssertEqual(action, .counter(.nthPrimeResponse(17)))
nextAction = action
}
)
self.wait(for: [receivedCompletion], timeout: 0.01)
effects = counterViewReducer(&state, nextAction)
XCTAssertEqual(
state,
CounterViewState(
alertNthPrime: PrimeAlert(prime: 17),
count: 2,
favoritePrimes: [3, 5],
isNthPrimeButtonDisabled: false
)
)
XCTAssertTrue(effects.isEmpty)
effects = counterViewReducer(&state, .counter(.alertDismissButtonTapped))
XCTAssertEqual(
state,
CounterViewState(
alertNthPrime: nil,
count: 2,
favoritePrimes: [3, 5],
isNthPrimeButtonDisabled: false
)
)
XCTAssertTrue(effects.isEmpty)
}
它有多达62行代码。不过,如果我们只关注我们所关心的state,我们就应该能够大幅削减开支。
首先,我们可以简化state的实例化,将注意力集中在测试所关心的内容上:主要prime alert state以及主要按钮是否被禁用。
var state = CounterViewState(alertNthPrime: nil, isNthPrimeButtonDisabled: false)
然后我们可以做一个可变副本来跟踪我们期望的随时间的变化的state。
var expected = state
当点击第n个主按钮时,我们希望该按钮禁用,因此我们可以改变预期的state并针对它进行断言。
var effects = counterViewReducer(&state, .counter(.nthPrimeButtonTapped))
expected.isNthPrimeButtonDisabled = true
XCTAssertEqual(state, expected)
XCTAssertEqual(effects.count, 1)
当我们得到响应时,我们期望设置prime alert state并重新启用按钮。
effects = counterViewReducer(&state, nextAction)
expected.alertNthPrime = PrimeAlert(prime: 17)
expected.isNthPrimeButtonDisabled = false
XCTAssertEqual(state, expected)
XCTAssertTrue(effects.isEmpty)
最后,当关闭警报按钮被点击时,我们期望prime alert state为nil。
effects = counterViewReducer(&state, .counter(.alertDismissButtonTapped))
expected.alertNthPrime = nil
XCTAssertEqual(state, expected)
XCTAssertTrue(effects.isEmpty)
好了,我们开始有进展了。这个测试几乎可以在一个屏幕上完成!
The shape of a test
虽然state里的许多样板已经大幅缩水,但在这个测试中仍有许多可怕的仪式:
-
也许最突出的是我们如何处理effect。effect附加了许多样板:我们需要手动保存它们,使用sink方法手动执行它们,并执行一个XCTestExpectation动作,其中包括创建一个expectation、等待它并在完成块中完成它。我们可能会忘记很多或者所有这些步骤。
-
在测试过程中,我们的测试管理的并提供给reducer的本地的、可变的状态不太明显。将突变引入一个范围,即使是一个局部范围,也会使其更难解释。在这种情况下,我们以expected复制的形式引入了两倍的可变状态,所以我们有两倍的机会以错误的方式意外突变这些值中的任何一个。
这个测试有一个非常明显的形状,但是有很多事情我们必须弄对才能做出这些断言。如果我们退后一步,看看这个形状,我们会注意到我们所编写的每个测试都遵循相同的脚本:我们构建初始状态,启动reducer,然后我们通过一个用户操作脚本,在此过程中维护我们所有的expectations。
如果我们可以创建一个assert helper,就像苹果的XCTAssert功能,除了它可以感知我们的架构。我们可以提供用户执行的一系列操作的描述,并且在这个过程中的每一步我们都能够断言状态是如何改变的。
这样的assert helper看起来像什么? 嗯,XCTAssert只是一个简单的自由函数,所以我们的助手可以是相同的:
func assert(
) {
}
为了让这个助手完成它的工作,它必须知道我们开始的初始值和我们想要测试的reducer。让我们来提供:
func assert<Value, Action>(
initialValue: Value,
reducer: Reducer<Value, Action>
) {
}
然后,有了这些配置信息,我们想给assert helper提供一个用户在与UI交互时执行的操作列表:
func assert<Value, Action>(
initialValue: Value,
reducer: Reducer<Value, Action>,
steps: [Action]
) {
}
然而,在每个步骤执行之后,我们希望进一步断言模型是如何从前一步更改的。所以仅仅提供一个操作数组是不够的,我们还想提供突变函数来描述我们期望值如何改变:
func assert<Value, Action>(
initialValue: Value,
reducer: Reducer<Value, Action>,
steps: [(action: Action, update: (inout Value) -> Void)]
) {
}
如果我们可以实现这样一个函数,那么我们可以将最简单的测试更新为如下所示:
func testIncrButtonTapped() {
assert(
initialValue: CounterViewState(count: 2),
reducer: counterViewReducer
steps: [
(.counter(.incrTapped), { state in state.count = 3 })
]
)
}
这是一个巨大的进步。我们不再重复将可变变量和动作输入reducer。
我们可以简化一下,用$0代替state,现在改变的state就很清楚了:
(.counter(.incrTapped), { $0.count = 3 })
因为维护这个步骤列表非常容易,所以我们可以在这个过程中做一些更简单的断言。
steps: [
(.counter(.incrTapped), { $0.count = 3 }),
(.counter(.incrTapped), { $0.count = 4 }),
(.counter(.decrTapped), { $0.count = 3 })
]
因为编写这个测试脚本非常容易,所以我们更有能力开始测试更复杂、更细微、更微妙的用户流。
我们甚至可以通过使用**variadics(可变参数)**而不是数组来减少一层嵌套:
func assert<Value, Action>(
initialValue: Value,
reducer: Reducer<Value, Action>,
steps: (action: Action, update: (inout Value) -> Void)...
) {
}
assert(
initialValue: CounterViewState(count: 2),
reducer: counterViewReducer
steps:
(.counter(.incrTapped), { $0.count = 3 }),
(.counter(.incrTapped), { $0.count = 4 }),
(.counter(.decrTapped), { $0.count = 3 })
)
这将编译并运行测试,但当然我们还没有测试任何东西,因为assert helper是空的。让我们开始实现它的主体。
我们希望它遍历给定的步骤并运行每个操作,以便在过程中做出断言。
steps.forEach { step in
}
在每个步骤中,我们希望运行reducer与当前步骤的action。
steps.forEach { step in
reducer
}
我们需要一个可变的值来传递给reducer,所以让我们将它复制到循环外,并将它与当前步骤的action一起提供给reducer。
var state = initialValue
steps.forEach { step in
reducer(&state, step.action)
}
⚠️ Result of call to function returning ‘[Effect]’ is unused
让我们保持简单,暂时忽略这些影响,特别是因为我们勾勒出的第一个断言没有任何影响。
现在,我们可以断言,突变按照我们的预期发生,这是我们的测试目前正在以非常手动的方式进行的工作,我们复制当前值,突变它,并断言它们是相等的。但这一次突变在步骤的更新函数中被捕获。
var state = initialValue
steps.forEach { step in
var expected = state
_ reducer(&state, step.action)
step.update(&expected)
XCTAssertEqual(state, expected)
}
🛑 Global function ‘XCTAssertEqual(::_:file:line:)’ requires that ‘Value’ conform to ‘Equatable’
但是我们需要将Value限制为Equatable,以便将它们提供给断言。
func assert<Value: Equatable, Action>(
一切都构建好了,当我们运行测试时…
✅ Executed 1 test, with 0 failures (0 unexpected)
Nice, it passes!
Improving test feedback
为了确保我们实际上得到了我们期望的断言,让我们打破它的期望count。我们可以在发送decrement操作的地方增加预期的count。
assert(
initialValue: CounterViewState(count: 2),
reducer: counterViewReducer
steps:
(.counter(.incrTapped), { $0.count = 3 }),
(.counter(.incrTapped), { $0.count = 4 }),
(.counter(.decrTapped), { $0.count = 5 })
)
当我们运行测试时,它会像预期的那样失败。
❌ Executed 1 test, with 1 failure (0 unexpected)
但不幸的是,这个失败出现在assert helper的内部深处。
XCTAssertEqual(value, expected) // ❌
我们还必须采取一些额外的步骤来让错误消息在Xcode中更好地发挥作用。XCTest利用一些Swift特性内联显示其错误,因此我们可以使用同样的特性增强assert助手。
XCTAssertEqual有一些隐藏的默认参数,它们负责这种行为。
func XCTAssertEqual(_ expression1: @autoclosure () throws -> T, _ expression2: @autoclosure () throws -> T, _ message: @autoclosure () -> String = “”, file: StaticString = #file, line: UInt = #line) where T : Equatable
#file和#line都是特殊的字面值,它们指向显示它们的文件和行。当它们被用作默认参数时,它们指的是调用函数的文件和行。
为了突出显示调用assert helper的失败,我们可以将这些字面值引入到其签名中。
func assert<Value: Equatable, Action>(
initialValue: Value,
reducer: Reducer<Value, Action>,
steps: (action: Action, update: (inout Value) -> Void)...,
file: StaticString = #file,
line: UInt = #line
) {
然后我们可以将它们传递给XCTest断言。
XCTAssertEqual(value, expected, file: file, line: line)
当我们运行测试时,失败现在显示在我们调用assert的地方。
assert( // ❌
这肯定更好,但仍然不理想。我们引入的实际失败是在最后一步。更糟的是,如果我们打破了另一个期望,说第一个:
(.counter(.incrTapped), { $0.count = 2 }),
(.counter(.incrTapped), { $0.count = 4 }),
(.counter(.decrTapped), { $0.count = 5 })
我们得到了两个失败,这是意料之中的,但它们都在同一行上,调用了assert,但与下面的实际问题相差甚远。
如果这些类型的失败以内联方式突出显示,那就更好了。
传递位置的一种方法是使用额外的file and line字段升级steps。
steps: (action: Action, update: (inout Value) -> Void, file: StaticString, line: UInt)...,
在这里使用默认参数也很好,但这是不可能的。
现在我们可以机械地在调用站点传递#file和#line。
assert(
initialValue: CounterViewState(count: 2),
reducer: counterViewReducer,
steps:
(.counter(.incrTapped), { $0.count = 3 }, #file, #line),
(.counter(.incrTapped), { $0.count = 4 }, #file, #line),
(.counter(.decrTapped), { $0.count = 5 }, #file, #line)
)
为了利用这些参数,我们可以将它们传递给XCTest断言。
XCTAssertEqual(value, expected, file: step.file, line: step.line)
当我们进行测试时,我们得到了更好的反馈。失败出现在失败步骤:
(.counter(.incrTapped), { $0.count = 2 }, #file, #line), // ❌
(.counter(.incrTapped), { $0.count = 4 }, #file, #line),
(.counter(.incrTapped), { $0.count = 5 }, #file, #line) // ❌
但是,这种手工工作非常笨拙。我们可以通过使用带有默认实参的函数来自动完成这项工作,实现这一目的的一种方法是将这个元组升级为带有初始化器函数的结构体。
我们可以将结构体称为Step。
它需要在Value和Action之上是通用的,它将存储一个操作、一个突变、一个文件名和一个行号。
struct Step<Value, Action> {
let action: Action
let update: (inout Value) -> Void
let file: StaticString
let line: UInt
}
为了获得这些#file和#line默认值,我们需要一个自定义初始化器。
init(
_ action: Action,
_ update: @escaping (inout Value) -> Void,
file: StaticString = #file,
line: UInt = #line
) {
self.action = action
self.update = update
self.file = file
self.line = line
}
现在我们只需要更新assert来使用Step而不是tuple。
func assert<Value: Equatable, Action>(
initialValue: Value,
reducer: Reducer<Value, Action>,
steps: Step<Value, Action>...,
file: StaticString = #file,
line: UInt = #line
) {
要编译内容,我们需要在断言的每一步前面加上一个大写的Step,并删除尾随的#file和#lines。
steps:
Step(.counter(.incrTapped), { $0.count = 2 }),
Step(.counter(.incrTapped), { $0.count = 4 }),
Step(.counter(.decrTapped), { $0.count = 5 })
好了。一切都构建好了,当我们运行测试时,失败仍然在适当的行上高亮显示。
Step(.counter(.incrTapped), { $0.count = 2 }), // ❌
Step(.counter(.incrTapped), { $0.count = 4 }),
Step(.counter(.decrTapped), { $0.count = 5 }) // ❌
好了,现在我们有了对失败的自动反馈,让我们再次让事情通过。
Step(.counter(.incrTapped), { $0.count = 3 }),
Step(.counter(.incrTapped), { $0.count = 4 }),
Step(.counter(.decrTapped), { $0.count = 3 })
✅ Executed 1 test, with 0 failures (0 unexpected)
一切都还在运转。
Trailing closure ergonomics
因为我们已经使用正确的结构体和初始化器升级了元组step,所以最好利用尾随闭包语法。
Step(.counter(.incrTapped)) { $0.count = 3 }, // 🛑
Step(.counter(.incrTapped)) { $0.count = 4 },
Step(.counter(.decrTapped)) { $0.count = 5 }
🛑 Generic parameter ‘Value’ could not be inferred
不幸的是,这行不通。这个错误消息并不是特别有用,但它不能编译的原因是,尾随闭包语法只对函数的最后一个参数有效,即使后面的参数有默认值,因此可以省略。
_ action: Action,
_ update: @escaping (inout Value) -> Void,
file: StaticString = #file,
line: UInt = #line
但是,如果我们将update作为最后一个参数,那么构建就很好了。
_ action: Action,
file: StaticString = #file,
line: UInt = #line,
_ update: @escaping (inout Value) -> Void
Actions sent and actions received
我们现在有了一种很好的、轻量级的领域特定语言,用于描述在显式发送给reducer的操作过程中对其状态的粒状变化,但是我们还没有重新捕获测试effect的工作。有两种主要的方式将动作反馈给store的reducer:
-
它们通过用户action或
-
它们通过effect的结果反馈到系统中。
这听起来像是我们应该在我们的领域特定语言中分离这些想法,这样我们就可以有一个脚本,声明用户做了什么动作,以及我们希望将什么动作反馈给系统。
让我们尝试用assert helper来描述第n个质数流。它看起来像这样:
Current.nthPrime = { _ in .sync { 17 } }
assert(
initialValue: CounterViewState(
alertNthPrime: nil,
isNthPrimeButtonDisabled: false
),
reducer: counterViewReducer,
steps:
Step(.counter(.nthPrimeButtonTapped)) {
$0.isNthPrimeButtonDisabled = true
},
Step(.counter(.nthPrimeResponse(17))) {
$0.alertNthPrime = PrimeAlert(prime: 17)
$0.isNthPrimeButtonDisabled = false
},
Step(.counter(.alertDismissButtonTapped)) {
$0.alertNthPrime = nil
}
)
这看起来很好,也很简洁! 如果我们运行它,它甚至会通过! 但不幸的是,它将我们带回到一个世界,在那里,我们描述的是我们期望产生effect的行为,断言并不能确保我们得到正确的结果。 理想情况下,这些步骤将记录哪些操作应该发送到store,哪些操作应该由effect接收。
我们可以通过在Step中引入一个新的字段来区分这种区别,该字段描述了Step的类型。我们可以使用enum来描述每种情况:一种情况是我们想通过reducer发送一个动作,另一种情况是我们希望从effect接收一个动作。
enum StepType {
case send
case receive
}
然后我们可以相应地更新Step。
let type: StepType
init(
_ type: StepType,
_ action: Action,
file: StaticString = #file,
line: UInt = #line,
_ update: @escaping (inout Value) -> Void
) {
self.type = type
我们可以用这些新信息来更新我们的测试。首先,testIncrButtonTapped通过显式sends得到更新:
assert(
initialValue: CounterViewState(count: 2),
reducer: counterViewReducer,
steps:
Step(.send, .counter(.incrTapped)) { $0.count = 3 },
Step(.send, .counter(.incrTapped)) { $0.count = 4 },
Step(.send, .counter(.decrTapped)) { $0.count = 3 }
)
接下来,testNthPrimeHappyFlow:
assert(
initialValue: CounterViewState(
alertNthPrime: nil,
isNthPrimeButtonDisabled: false
),
reducer: counterViewReducer,
steps:
Step(.send, .counter(.nthPrimeButtonTapped)) {
$0.isNthPrimeButtonDisabled = true
},
Step(.receive, .counter(.nthPrimeResponse(17))) {
$0.alertNthPrime = PrimeAlert(prime: 17)
$0.isNthPrimeButtonDisabled = false
},
Step(.send, .counter(.alertDismissButtonTapped)) {
$0.alertNthPrime = nil
}
)
这开始变得非常好读,但是当然,assert helper没有使用任何这些信息。
如何使用步骤类型的总体思想是,当一个步骤说要发送一个动作时,我们可以像平常一样调用reducer,但我们也会跟踪它所产生的effects。 然后,当我们遇到一个步骤,说我们正在接收一个动作时,我们将从我们正在跟踪的数组中取出第一个效果,运行它,并验证它产生的动作就是步骤中的动作。
为了处理step类型,很自然地要switch over它。
switch step.type {
case .send:
case .receive:
}
我们可以将显式调用reducer的工作转移到send分支,发送分支告诉我们显式发送动作。
switch step.type {
case .send:
_ = reducer(&state, step.action)
case .receive:
<#code#>
}
我们目前忽略了reducer返回的effects。为了跟踪它们,我们需要引入一个变量,该变量在调用reducer时就会被设置,并且我们需要在forEach之外执行此操作,以便每个步骤都能访问前一步产生的effects。
var effects: [Effect<Action>] = []
steps.forEach { step in
在循环中,我们现在可以将reducer返回的任何effects添加到数组中。
effects.append(contentsOf: reducer(&state, step.action))
好吧。现在,我们已经准备好处理在这个数组中累积的任何effects,当我们期望接收它们时。在接收分支中,我们可以从从数组中取出第一个effect开始。
case .receive:
let effect = effects.removeFirst()
}
然后我们可以引入expectation dance,这样我们就可以运行我们的effects来完成并提取预期的动作。首先为effect返回的操作引入一个隐式未包装的可选操作和一个测试期望。
var action: Action!
let receivedCompletion = self.expectation(description: "receivedCompletion") // 🛑
🛑 Use of unresolved identifier ‘self’
但是,因为我们是在一个自由函数中,而不是在一个XCTestCase中,所以不能使用expectation方法。然而,我们可以通过它的初始化器手动构造一个。
let receivedCompletion = XCTestExpectation(description: "receivedCompletion")
接下来,我们将使用sink运行effect,在完成时满足预期,并在接收到动作时分配操作。
effect.sink(
receiveCompletion: { _ in receivedCompletion.fulfill() },
receiveValue: { action = $0 }
)
在这一点上,我们需要等待这个expectation。我们无法访问XCTestCase上的wait方法,但在XCTWaiter类上有一个类似的静态函数。
XCTWaiter.wait(for: [receivedCompletion], timeout: 0.01)
⚠️ Result of call to ‘wait(for:timeout:)’ is unused
虽然XCTestCase上的wait方法会导致测试失败,但这个静态函数只返回一个XCTWaiter.Result,其中有几个cases描述等待期望的各种结果。如果expectation没有实现和完全,我们就会失败:
if XCTWaiter.wait(for: [receivedCompletion], timeout: 0.01) != .completed {
XCTFail(
"Timed out waiting for the effect to complete",
file: step.file,
line: step.line
)
} else {
否则,现在我们已经有了动作,我们应该检查它是否与我们预期的相同,因此让我们快速编写一个断言。
XCTAssertEqual(action, step.action, file: step.file, line: step.line)
🛑 Global function ‘XCTAssertEqual(::_:file:line:)’ requires that ‘Action’ conform to ‘Equatable’
这意味着我们还必须更新assert来限制Action是equatable。
func assert<Value: Equatable, Action: Equatable>(
最后,我们想再次运行reducer与提取的动作。
reducer(&state, action)
我们想把它们返回的effects附加到我们处理的数组中。
effects.append(contentsOf: reducer(&value, action))
好吧,这是大量的工作! 但这基本上是我们之前手工做的工作。现在我们只需做一次,而不是手动重复,一遍又一遍。
如果我们进行测试,它还是通过了! 但它并没有比之前断言的更多。以前,当我们断言我们received了一个动作时,helper只是相信了我们的话。现在,helper实际上运行这些effects,并在接收动作之前对其进行断言。
为了证明这一点,让我们练习一下helper,以确保它正在测试一些真实的东西。例如,在正确处理assert helper中的effects之前,下面的测试应该已经通过了:
Current.nthPrime = { _ in .sync { 17 } }
assert(
initialValue: CounterViewState(
alertNthPrime: nil,
isNthPrimeButtonDisabled: false
),
reducer: counterViewReducer,
steps:
Step(.send, .counter(.nthPrimeButtonTapped)) {
$0.isNthPrimeButtonDisabled = true
},
Step(.receive, .counter(.nthPrimeResponse(15))) {
$0.alertNthPrime = PrimeAlert(prime: 15)
$0.isNthPrimeButtonDisabled = false
},
Step(.send, .counter(.alertDismissButtonTapped)) {
$0.alertNthPrime = nil
}
)
这是因为这个effect实际上并没有运行,它与Current.nthPrime是什么无关。因为我们正在手动重放我们所期望的effects,如果它运行的话。然而,现在assert实际上正在运行effect,实际上正在使用Current.nthPrime effect,这个测试应该失败,实际上我们得到两个失败:
❌ XCTAssertEqual failed: (“CounterViewState(alertNthPrime: Optional(Counter.PrimeAlert(prime: 17)), count: 0, favoritePrimes: [], isNthPrimeButtonDisabled: false)”) is not equal to (“CounterViewState(alertNthPrime: Optional(Counter.PrimeAlert(prime: 15)), count: 0, favoritePrimes: [], isNthPrimeButtonDisabled: false)”) ❌ XCTAssertEqual failed: (“Optional(Counter.CounterViewAction.counter(Counter.CounterAction.nthPrimeResponse(Optional(17))))”) is not equal to (“Optional(Counter.CounterViewAction.counter(Counter.CounterAction.nthPrimeResponse(Optional(15))))”)
第一个失败是由于state并没有按照我们预期的方式改变。其次是由于action不匹配的事实。
因此,我们对assert函数的更新让我们能够捕获真实的effect行为。当我们断言一个action被发送时,我们不仅断言状态是如何改变的,而且我们还累积从减速机返回的所有effects。然后,当我们收到断言,一个action,我们第一个effect从数组,运行它,并断言行动我们将收到匹配的实际行动释放的effect,我们将这个action反馈给reducer以确保它以我们认为它应该的方式改变状态。
Assertion edge cases
但事情还不太对劲。有很多assert不会捕捉到的边界情况,所以让我们逐个研究它们。
首先,如果我们注释掉中间步骤,我们期望从一个effect中接收一个action:
assert(
initialValue: CounterViewState(
alertNthPrime: nil,
isNthPrimeButtonDisabled: false
),
reducer: counterViewReducer,
steps:
Step(.send, .counter(.nthPrimeButtonTapped)) {
$0.isNthPrimeButtonDisabled = true
},
// Step(.receive, .counter(.nthPrimeResponse(17))) {
// $0.alertNthPrime = PrimeAlert(prime: 17)
// $0.isNthPrimeButtonDisabled = false
// },
Step(.send, .counter(.alertDismissButtonTapped)) {
$0.alertNthPrime = nil
}
)
测试仍然通过,即使有一个effect应该被执行。这是因为我们只在明确期望接收action时才考虑effects。
即使我们没有考虑到一些悬而未决的effects,assert的send分支也将愉快地继续前进。
case .send:
effects.append(contentsOf: reducer(&value, step.action))
我们要做的是确保在为一个已发送的action运行reducer之前没有悬而未决的effects。
case .send:
if !effects.isEmpty {
XCTFail(
"Action sent before handling \(effects.count) pending effect(s)",
file: step.file,
line: step.line
)
}
effects.append(contentsOf: reducer(&value, step.action))
当我们重新运行测试时,它失败了:
// Step(.receive, .counter(.nthPrimeResponse(17))) {
// $0.alertNthPrime = PrimeAlert(prime: 17)
// $0.isNthPrimeButtonDisabled = false
// },
Step(.send, .counter(.alertDismissButtonTapped)) { // ❌
$0.alertNthPrime = nil
}
❌ failed - Action sent before handling 1 pending effect(s)
但是,如果我们注释掉最后一步,事情就继续了:
Step(.send, .counter(.nthPrimeButtonTapped)) {
$0.isNthPrimeButtonDisabled = true
}//,
// Step(.receive, .counter(.nthPrimeResponse(17))) {
// $0.alertNthPrime = PrimeAlert(prime: 17)
// $0.isNthPrimeButtonDisabled = false
// },
// Step(.send, .counter(.alertDismissButtonTapped)) {
// $0.alertNthPrime = nil
// }
✅ Executed 1 test, with 0 failures (0 unexpected)
这不太理想。没有测试nthprimerresponse操作返回的effect。我们想要的是我们的helper为我们抓住这些错误,这样我们就不会忘记断言一个effect。
要解决这个问题,我们需要做一个最后的断言。在循环遍历所有给定的步骤之后,如果effect数组包含任何挂起的effect,则应该失败。
if !effects.isEmpty {
XCTFail(
"Assertion failed to handle \(effects.count) pending effect(s)",
file: file,
line: line
)
}
有了这个添加,我们的测试又失败了:
assert( // ❌
❌ failed - Assertion failed to handle 1 pending effect(s)
但是,如果断言期望在没有effect的情况下收到effect,该怎么办呢? 我们可以注释掉第一步,然后注释第二步,这样做:
steps:
// Step(.send, .counter(.nthPrimeButtonTapped)) {
// $0.isNthPrimeButtonDisabled = true
// },
Step(.receive, .counter(.nthPrimeResponse(17))) {
$0.alertNthPrime = PrimeAlert(prime: 17)
$0.isNthPrimeButtonDisabled = false
},
Step(.send, .counter(.alertDismissButtonTapped)) {
$0.alertNthPrime = nil
}
这一次,当我们运行测试时,我们遇到了崩溃!
case .receive:
let effect = effects.removeFirst() // 🛑
🛑 Fatal error: Can’t remove first element from an empty collection
因为数组上的removeFirst方法返回一个非可选元素,所以如果没有这样的元素存在,它就必须崩溃。虽然我们希望测试在预期有effect而实际没有effect时失败,但我们不希望使整个测试套件崩溃。
所以,让我们主动失败,当我们期望有效果,但实际没有时。
case .receive:
guard !effects.isEmpty else {
XCTFail(
"No pending effects to receive from",
file: step.file,
line: step.line
)
break
}
现在,测试失败得稍微好一些。
steps:
// Step(.send, .counter(.nthPrimeButtonTapped)) {
// $0.isNthPrimeButtonDisabled = true
// },
Step(.receive, .counter(.nthPrimeResponse(17))) { // ❌
$0.alertNthPrime = PrimeAlert(prime: 17)
$0.isNthPrimeButtonDisabled = false
},
❌ failed - No pending effects to receive from
这应该涵盖了大多数常见的边情况。所以让我们把所有东西都注释回来,以获得一个合格的测试:
Step(.send, .counter(.nthPrimeButtonTapped)) {
$0.isNthPrimeButtonDisabled = true
},
Step(.receive, .counter(.nthPrimeResponse(17))) {
$0.alertNthPrime = PrimeAlert(prime: 17)
$0.isNthPrimeButtonDisabled = false
},
Step(.send, .counter(.alertDismissButtonTapped)) {
$0.alertNthPrime = nil
}
✅ Executed 1 test, with 0 failures (0 unexpected)
Conclusion
好了,assert助手看起来真的很好。它让我们准备一些状态来用reducer进行测试,然后我们可以用我们预期的突变和预期的effect反馈给系统的动作来对它进行断言,并提供尽可能多的动作。我们还可以做一些事情来增强这个helpers,比如添加对“fire-and-forget”effect的支持,但它已经为我们做了很多。
让我们清理一下,更好地、更全面地看待我们所取得的成就。
首先,让我们将assert helper及其依赖项移动到它们自己的文件中。不过,理想情况下,它们应该存在于自己的测试helper模块中,可以导入到任何使用可组合架构的测试目标中。
import ComposableArchitecture
import XCTest
enum StepType {
case send
case receive
}
struct Step<Value, Action> {
let type: StepType
let action: Action
let update: (inout Value) -> Void
let file: StaticString
let line: UInt
init(
_ type: StepType,
_ action: Action,
file: StaticString = #file,
line: UInt = #line,
_ update: @escaping (inout Value) -> Void
) {
self.type = type
self.action = action
self.update = update
self.file = file
self.line = line
}
}
func assert<Value: Equatable, Action: Equatable>(
initialValue: Value,
reducer: Reducer<Value, Action>,
steps: Step<Value, Action>...,
file: StaticString = #file,
line: UInt = #line
) {
var state = initialValue
var effects: [Effect<Action>] = []
steps.forEach { step in
var expected = state
switch step.type {
case .send:
if !effects.isEmpty {
XCTFail("Action sent before handling \(effects.count) pending effect(s)", file: step.file, line: step.line)
}
effects.append(contentsOf: reducer(&state, step.action))
case .receive:
guard !effects.isEmpty else {
XCTFail("No pending effects to receive from", file: step.file, line: step.line)
break
}
let effect = effects.removeFirst()
var action: Action!
let receivedCompletion = XCTestExpectation(description: "receivedCompletion")
_ = effect.sink(
receiveCompletion: { _ in
receivedCompletion.fulfill()
},
receiveValue: { action = $0 }
)
if XCTWaiter.wait(for: [receivedCompletion], timeout: 0.01) != .completed {
XCTFail("Timed out waiting for the effect to complete", file: step.file, line: step.line)
}
XCTAssertEqual(action, step.action, file: step.file, line: step.line)
effects.append(contentsOf: reducer(&state, action))
}
step.update(&expected)
XCTAssertEqual(state, expected, file: step.file, line: step.line)
}
if !effects.isEmpty {
XCTFail("Assertion failed to handle \(effects.count) pending effect(s)", file: file, line: line)
}
}
现在,我们可以通过删除一些注释掉的和过时的测试代码来清理计数器测试文件,并使用断言助手,在我们之前手动测试体系结构的地方。
import XCTest
@testable import Counter
class CounterTests: XCTestCase {
override class func setUp() {
super.setUp()
Current = .mock
}
func testIncrDecrButtonTapped() {
assert(
initialValue: CounterViewState(count: 2),
reducer: counterViewReducer,
steps:
Step(.send, .counter(.incrTapped)) { $0.count = 3 },
Step(.send, .counter(.incrTapped)) { $0.count = 4 },
Step(.send, .counter(.decrTapped)) { $0.count = 3 }
)
}
func testNthPrimeButtonHappyFlow() {
Current.nthPrime = { _ in .sync { 17 } }
assert(
initialValue: CounterViewState(
alertNthPrime: nil,
isNthPrimeButtonDisabled: false
),
reducer: counterViewReducer,
steps:
Step(.send, .counter(.nthPrimeButtonTapped)) {
$0.isNthPrimeButtonDisabled = true
},
Step(.receive, .counter(.nthPrimeResponse(17))) {
$0.alertNthPrime = PrimeAlert(prime: 17)
$0.isNthPrimeButtonDisabled = false
},
Step(.send, .counter(.alertDismissButtonTapped)) {
$0.alertNthPrime = nil
}
)
}
func testNthPrimeButtonUnhappyFlow() {
Current.nthPrime = { _ in .sync { nil } }
assert(
initialValue: CounterViewState(
alertNthPrime: nil,
isNthPrimeButtonDisabled: false
),
reducer: counterViewReducer,
steps:
Step(.send, .counter(.nthPrimeButtonTapped)) {
$0.isNthPrimeButtonDisabled = true
},
Step(.receive, .counter(.nthPrimeResponse(nil))) {
$0.isNthPrimeButtonDisabled = false
}
)
}
func testPrimeModal() {
assert(
initialValue: CounterViewState(
count: 2,
favoritePrimes: [3, 5]
),
reducer: counterViewReducer,
steps:
Step(.send, .primeModal(.saveFavoritePrimeTapped)) {
$0.favoritePrimes = [3, 5, 2]
},
Step(.send, .primeModal(.removeFavoritePrimeTapped)) {
$0.favoritePrimes = [3, 5]
}
)
}
}
所有的测试仍然通过了,但是它们现在非常简洁地准确地描述了用户所做的事情以及反馈给系统的影响。
现在整个文件只有80行代码,而以前,我们有几个60行测试,使整个文件的代码超过200行。在使用这个helper的过程中,我们已经隐藏了很多样板和痛苦! 这使得写作和阅读测试成为一种愉快的体验。