1. Introduction

很多、很多星期以前,我们根据第一原则在SwiftUI中构建了一个中等复杂的应用程序,只使用SwiftUI提供的开箱即用的东西(第1、2、3部分)。我们能够以非常快的速度走得很远,但我们也注意到了一些问题。我们将观察到的问题归纳为5个主要问题,我们认为这些问题对任何应用程序架构都至关重要:

  • 体系结构的基本单元应该以简单的值类型表示。
  • 应用程序状态的突变应该以一种单一的、一致的方式进行,并且突变和观察的单位应该以一种可组合的方式表示。
  • 体系结构应该是模块化的,也就是说,你应该能够将应用程序的许多单元放到它们自己的Swift模块中,这样它们就能与其他所有东西完全分离,同时仍然具有粘在一起的能力。
  • 体系结构应该确切地告诉您在哪里以及如何执行副作用。
  • 最后,体系结构应该描述如何测试各种组件,理想情况下,编写这些测试需要最少的设置工作。

SwiftUI这让我们很接近于解出其中的一些问题,但并没有完全解出来。

这激发了我们开始探索一种架构,该架构对应该如何解决这些问题给出了非常强烈的意见,我们已经完全解决了5个问题中的4个。

  • 状态和操作被建模为值类型
  • 突变被表示为reducer函数,它是超级可组合的。
  • 对状态变化的观察是使用store类型来表示的,这也是可组合的,允许我们将应用中的所有屏幕分割成可以独立运行的Swift模块。
  • 最近我们终于展示了副作用是如何在这个架构中建模的。

这就剩下最后一个问题需要解决,也许也是最重要的一个问题:testing。我们声称这个体系结构是超级可测试的。这个体系结构的几乎每个方面都可以测试,并且编写第一个测试只需要很少的设置工作。我们还将能够解锁许多新的测试方法,如果你的应用程序中没有一个内聚的和普遍的架构,这些方法是很难实现的。

所以,让我们先提醒自己,过去几周我们都在做些什么。

2. Recap

我们正在构建的应用程序是一个有一些铃声和哨声的计数应用程序。您可以增加或减少当前计数,询问它是否是质数,如果是,您可以从跨屏幕的收藏素数列表中添加或删除它。您还可以请求“n”质数,它将向计算平台Wolfram Alpha发出API请求,当我们得到响应时,我们将显示一个alert。此外,在收藏质数屏幕中,你可以删除任何不再是你收藏的质数,也可以将你收藏的质数保存到磁盘,并加载一个旧的收藏质数列表。

虽然这个应用程序基本上是一个玩具样例,但它仍然演示了在任何中等复杂的应用程序中必须解决的许多问题:

  • 它在多个屏幕上有共享的状态,当一个屏幕改变了这个状态时,它应该立即反映到其他屏幕上。
  • 它的使用有多种副作用,这增加了应用程序的复杂性。比如对Wolfram Alpha的异步API请求,以及保存和加载数据到磁盘的同步效果。
  • 它实现了一些微妙的逻辑,比如在Wolfram API请求飞行时禁用第n个主要按钮。

所以这个应用程序相当复杂,尽管表面上它看起来像一个玩具。事实上,现在已经到了编写一些测试的时候了。这将帮助我们证明它目前按照我们预期的方式工作,我们将能够在确保不破坏当前功能的同时开发新功能。

iOS社区的测试文化并不像RubyJavaScript等其他社区那么强大。 这部分可能是由于具有类型系统的语言往往需要更少的测试,因为许多基本的错误可以在编译时捕获。这也可能部分是因为我们每天工作的框架是UIKit,它并没有真正考虑到测试。苹果给我们提供了一些工具,比如XCUITest,但它很脆弱,速度很慢,目的是测试我们应用程序的一个非常特定的部分。

这自然促使iOS社区构建基于UIKit的抽象和架构,以便更容易编写测试。我们的社区并不缺少这些架构,我们甚至在过去的许多Point-Free的章节中已经构建了我们自己的架构,但是即使有了这些丰富的知识,我们仍然觉得测试不是我们社区的标准。

根据我们的经验,原因在于编写一个测试所需的工作量和你从测试中获得的深度和广度之间的脱节。通常,测试需要相当多的设置工作,从创建许多需要协调的对象,到创建许多协议的一致性,这些协议在体系结构中用于分离关注点。你从这些测试中得到的覆盖率并不能证明所需的设置数量是合理的。很多时候,测试代码是如此复杂,以至于您最终只是验证测试代码是否正确运行,而没有验证应用程序本身的任何内容。

因此,如果一个测试故事要在一个架构中成功,它必须是直接的,需要很少的附加概念的协调,并且它必须能够捕获我们应用程序中非常深刻的、微妙的功能。
这正是我们在应用程序中要做的。我们要:

  • 验证应用程序的核心逻辑是否正确执行。
  • 验证我们的横切关注点被正确地实现了
  • 验证是否执行了副作用,并将其结果正确地反馈到系统中
  • 确认一切看起来都是正确的。

不仅我们要完成的,但是我们可以做少量的设置工作,我们甚至会有一些意外的事情我们可以测试,乍一看似乎极其困难的测试。

3. Testing the prime modal

让我们开始吧! 毫不奇怪,我们的应用程序中最容易测试的部分是reducers。这是因为它们只是将应用程序从当前状态移动到下一个状态的一个步骤,而且它们只是纯函数。测试它们应该像输入当前状态和用户操作并断言结果状态一样简单。

让我们从最简单的屏幕之一,主模态开始。这个屏幕负责显示显示当前计数值是否为素数的屏幕,如果它是素数,则可以从收藏素数列表中保存或删除它。当我们创建这个模块时,会自动创建一个测试目标,并且它已经有了一个测试文件,其中有一堆存根的东西:

//
//  PrimeModalTests.swift
//  PrimeModalTests
//
// …

让我们清理这个文件,这样我们只剩下:

import XCTest
@testable import PrimeModal

class PrimeModalTests: XCTestCase {
  func testExample() {
  }
}

首先,让我们确保可以通过选择“PrimeModal”目标来运行我们的测试目标,并放入一个虚拟断言:

func testExample() {
  XCTAssertTrue(false)
}

点击⌘+U运行套件。当我们点击⌘+U时,似乎没有发生任何事情,这是因为不管出于什么原因,这个测试目标没有加入到PrimeModal方案中。我们点击“⌘+⇧+”,选择test选项卡,并添加“PrimeModalTests”目标:

现在,当我们点击⌘+U时它会进行一些构建,运行测试,测试失败,正如我们所预期的:

❌ XCTAssertTrue failed

这很好,但是我不知道您是否了解所发生的情况,但是构建花费了相当长的时间来完成,测试也花费了相当长的时间来运行。
它似乎构建了所有其他目标,尽管“PrimeModal”并不依赖于它们,并且它启动了一个模拟器来运行测试,尽管我们只是在一个函数上做一个简单的单元测试。

这是因为现在测试目标被配置为在“test host”中运行,特别是在完整的“PrimeTime”应用程序中运行。这将大大减慢我们的测试反馈周期,我们可以很容易地通过设置主机应用程序为“None”来禁用它。

完成所有这些之后,我们如何编写第一个真正的测试呢?主模态reducer的特征是什么?

primeModalReducer(state: &<#PrimeModalState#>, action: <#PrimeModalAction#>)

我们需要一些可变的主要模态状态来提供给reducer,以及一个动作。对于操作,我们有两种选择:要么将当前计数保存为收藏素数,要么删除它。我们想要测试这两个代码路径。

为了添加喜欢的质数,让我们从一个状态开始,在这个状态中,我们有一些喜欢的质数,而当前计数是在不同的质数上:

var state = (count: 2, favoritePrimes: [3, 5])

然后我们就可以在这个状态下运行reducer来模拟用户点击“save favorite prime””按钮时的情况:

primeModalReducer(state: &state, action: .saveFavoritePrimeTapped)

在状态上运行这个reducer意味着状态在适当的地方发生了变化,因此我们希望断言它更改为什么值。 我们期望数字7被加到最喜欢的质数数组中,所以我们可能会想这样做:

XCTAssertEqual(state, (2, [3, 5, 2]))

Unfortunately this does not work:

🛑 Global function ‘XCTAssertEqual(::_:file:line:)’ requires that ‘(Int, [Int])’ conform to ‘Equatable’

这个错误告诉我们,我们试图断言两个东西是相等的,但它们的类型不符合Equatable。这是因为元组不符合Equatable,或任何相关协议。我们前面已经说过,尽管编写reducer来操作简单的状态元组很好,但它也有缺点,这就是其中之一。

我们可以做的一件事是对状态的每个字段分别断言:

XCTAssertEqual(state.count, 2)
XCTAssertEqual(state.favoritePrimes, [3, 5, 2])

这样就可以通过测试了

然而,我们失去了测试的穷尽性。如果一个字段被添加到状态中,并且该字段以某种方式被用于执行savefavoriteprimetap的逻辑,那么我们将完全无法断言该字段在reducer中发生了什么。理想情况下,向状态中添加一个字段会导致编译器错误,从而迫使我们考虑该字段是如何更改的。所以,这不是正确的方法。

我们可以做的一件事是显式解构存储在元组中的状态。

func testExample() {
  var state = (count: 2, favoritePrimes: [3, 5])

  primeModalReducer(state: &state, action: .saveFavoritePrimeTapped)

  let (count, favoritePrimes) = state
  XCTAssertEqual(count, 2)
  XCTAssertEqual(favoritePrimes, [3, 5, 2])
}

如果主模态状态发生了变化,则编译将失败,迫使我们对测试进行更改。

我们还可以在元组上定义XCTAssertEqual helper,或者将PrimeModalState升级为符合Equatable的结构体。这很简单,但在reducer中使用元组绝对是件好事,因为它们是如此的轻量级,并且使用起来非常简单。

现在,如果我们运行测试:

Executed 1 test, with 0 failures (0 unexpected)

好了!这就是我们所说的运行测试只需要很少的设置。虽然这个reducer现在非常简单,但测试它的复杂性只与状态值有多少字段成比例。我们所要做的就是构造一个可插入的可变值,以及在突变发生后对状态值的断言。不需要其他设置。

然而,在这个测试中有一件奇怪的事情发生了。我们在调用reducer的那一行上有一个警告:

⚠️ Result of call to ‘primeModalReducer(state:action:)’ is unused

这是因为从技术上讲,primeModalReducer函数返回一个effects数组,我们并没有断言effects是什么样的。幸运的是,这个reducer实际上不会返回任何effects,所以我们可以简单地断言effects数组为空:

let effects = primeModalReducer(state: &state, action: .saveFavoritePrimeTapped)

let (count, favoritePrimes) = state
XCTAssertEqual(state.count, 7)
XCTAssertEqual(state.favoritePrimes, [2, 3, 7]))
XCTAssert(effects.isEmpty)

这似乎有点傻,但是有一个好处是,如果在以后我们决定使用一些effects在这reducer,我们将得到一个即时测试失败,使我们明白我们需要更新这个测试正确适应那些新的effects。所以,即使reducer没有任何影响,这样做仍然是一个好主意。

现在完成了这个特定的测试用例,但它的名称不太正确,所以让我们更新它:

func testSaveFavoritePrimesTapped() {

现在让我们来测试一下,当点击“删除收藏”按钮时会发生什么。我们可以复制和粘贴现有的测试,并只更改一些东西:

  • 我们将把测试重命名为teststremovefavoriteprimestapping
  • 我们将从当前计数位于收藏素数数组的状态开始
  • 当我们发送remove动作时,我们将断言质数已从数组中移除
func testRemoveFavoritePrimeTapped() {
  var state = (count: 3, favoritePrimes: [3, 5])

  let effects = primeModalReducer(state: &state, action: .removeFavoritePrimeTapped)

  let (count, favoritePrimes) = state
  XCTAssertEqual(state.count, 3)
  XCTAssertEqual(state.favoritePrimes, [5]))
  XCTAssert(effects.isEmpty)
}

就这样,我们又通过了一次测试。这只需要很少的设置,但它是在测试一个真正的逻辑,这个逻辑不仅支持这个特定的屏幕,也支持显示喜爱的素数列表的“喜爱的素数”屏幕。

4. Testing favorite primes

现在我们已经在主模态屏幕中测试了所有的主逻辑。只有两个测试,而且reducer也很简单。让我们尝试一个更复杂的屏幕。

“收藏素数”屏幕处理显示当前素数列表的功能,允许用户删除一个素数,并允许用户保存当前的收藏或加载以前保存的收藏列表。

在我们可以为这个特性编写任何测试之前,我们需要做一些前期工作,就像我们为主模态做的那样:

  • Add the test target to the scheme

  • Remove the test host

  • Clean up the FavoritePrimesTests.swift file

在编写第一个测试时,我们应该提醒自己要测试的是什么。让我们看看如何调用favoritePrimesReducer:

favoritePrimesReducer(state: &<#[Int]#>, action: <#FavoritePrimesAction#>)

状态只是一个简单的整数数组,操作是4种选择之一。最简单的测试动作是移除一个最受欢迎的质数:

favoritePrimesReducer(state: &<#[Int]#>, action: .deleteFavoritePrimes(<#IndexSet#>))

为了测试这个,我们需要提供一些状态和一个索引来删除:

var state = [2, 3, 5, 7]

favoritePrimesReducer(state: &state, action: .deleteFavoritePrimes([2]))

我们希望这将从收藏质数数组中删除数字5,所以让我们写一个断言:

XCTAssertEqual(state, [2, 3, 7])

这个测试通过了。我们确实再次出现了那个警告,因为我们没有对reducer的结果值进行任何操作,但就像上次一样,我们只想简单地断言effects数组为空:

var state = [2, 3, 5, 7]

let effects = favoritePrimesReducer(state: &state, action: .deleteFavoritePrimes([2]))

XCTAssertEqual(state, [2, 3, 7])
XCTAssert(effects.isEmpty)

这太简单了!让我们重新命名这个测试用例,以更好地反映它的用途:

func testDeleteFavoritePrimes() {

现在让我们尝试其中一个动作。保存按钮的操作似乎足够简单,让我们看看测试它需要什么。我们可以从一个特定的状态开始,调用reducer,并断言状态是如何变化的。但是,我们并不指望这个操作会改变状态,因为它所做的只是将当前收藏素数列表保存到磁盘:

func testSaveButtonTapped() {
  var state = [2, 3, 5, 7]

  let effects = favoritePrimesReducer(state: &state, action: .saveButtonTapped)

  XCTAssertEqual(state, [2, 3, 5, 7])
  XCTAssert(effects.isEmpty) // ?
}

现在,我们应该如何处理这一系列的效果。这一次它不是空的,事实上它只包含一个值:

XCTAssertEqual(effects.count, 1)

这个断言通过了,但它有点傻。我们并没有断言副作用会发生什么或者产生什么样的副作用,只是说一些副作用被退回了。不幸的是,这是我们目前能做的最好的了。我们很快就会找到一个更好的方法来处理这件事,所以现在就让它保持现状吧。

最喜欢的素数屏幕也有加载素数列表的功能,这是通过loadbuttontap操作显示的。让我们复制粘贴保存质数的测试,并做一些小的改变,以表示我们的期望状态保持不变,我们有一个effect,负责从磁盘加载质数。

func testLoadButtonTapped() {
  var state = [2, 3, 5, 7]

  let effects = favoritePrimesReducer(state: &state, action: .loadButtonTapped)

  XCTAssertEqual(state, [2, 3, 5, 7])
  XCTAssertEqual(effects.count, 1)
}

不过,更好的方法可能是测试加载favorite质数的整个流程,它包括第二个动作,loaddfavoritprime动作,在从磁盘加载质数后发送这个动作。

favoritePrimesReducer(state: &state, action: .loadedFavoritePrimes([2, 31]))

如果我们想保留这些额外的effects,我们可以将第一个变量设置为可变的,并重新分配第二次调用reducer的输出。通过这种方式,我们最终可以断言更新的状态,并且这第二个动作不会产生额外的效果。

var effects = favoritePrimesReducer(state: &state, action: .loadButtonTapped)

  XCTAssertEqual(state, [2, 3, 5, 7])
  XCTAssertEqual(effects.count, 1)

  effects = favoritePrimesReducer(state: &state, action: .loadedFavoritePrimes([2, 31]))

  XCTAssertEqual(state, [2, 31])
  XCTAssert(effects.isEmpty)
}

最后,我们也许可以更新测试名称,以更好地描述我们正在测试整个流的事实。

func testLoadFavoritePrimesFlow() {

这测试了核心reducer的逻辑,因为我们正在验证,当我们告诉reducer加载最喜欢的质数时,状态不会立即改变,但一旦我们从磁盘加载质数的效果中得到响应,状态就会改变。

但是同样的,在这个测试中有一些愚蠢的事情发生。首先,我们必须保留一个可变的effects值,以便在第二次调用reducer时覆盖它,这有点麻烦。第二,我们只断言返回了某个效果,而没有断言该效果的内容,这仍然很奇怪。最后,奇怪的是,我们必须明确地将effects的结果反馈给reducer。如果这可以自动完成,那么我们甚至不需要在测试中记住这一点,如果我们重构reducer以释放不同的effects,我们就不需要记住更新测试,这将是最好的。

然而,我们还没有完全准备好解决这些问题。我们很快就会实现这一目标,但在我们开始解决这个更难的问题之前,让我们继续了解如何测试reducers

5. Testing the counter

我们还没有编写测试的另一个屏幕是计数器屏幕,它允许用户增加和减少当前计数,并根据当前计数请求“第n个质数”。

在我们进行测试之前我们要再次准备测试对象

  • Add the test target to the scheme
  • Remove the test host
  • Clean up the CounterTests.swift file

为了确定我们首先应该测试什么,让我们记住,counterviewreducerCounterViewAction和CounterViewState作为输入。
CounterViewState是一个结构体,所以我们可以初始化一个结构体,以提供给reducer

var state = CounterViewState(
  alertNthPrime: nil,
  count: 2,
  favoritePrimes: [3, 5],
  isNthPrimeButtonDisabled: false
)

CounterViewAction有两种情况:一种用于CounterActions,另一种用于PrimeModalActions。后者我们已经测试过了,所以我们主要想专注于CounterActions

CounterAction包含若干cases。让我们从一些简单的开始。例如,要测试当用户点击增量按钮时,计数是否增加1,我们可以简单地做:

counterViewReducer(&state, .counter(.incrTapped))

XCTAssertEqual(
  state,
  CounterViewState(
    alertNthPrime: nil,
    count: 3,
    favoritePrimes: [3, 5],
    isNthPrimeButtonDisabled: false
  )
)

然而,我们立即得到以下编译器错误:

🛑 Global function ‘XCTAssertEqual(::_:file:line:)’ requires that ‘CounterViewState’ conform to ‘Equatable’

结果我们的CounterViewState还不符合Equatable。这很容易解决:

public struct CounterViewState: Equatable {

但这会导致另一个编译器错误,因为PrimeAlert不符合Equatable,但我们也可以修复这个问题:

public struct PrimeAlert: Equatable, Identifiable {

现在我们在构建顺序,测试通过了,这意味着我们断言当点击增量按钮时,计数增加了1,但其他状态没有变化。

当然,我们还应该捕捉到这些影响,并断言我们不期望有任何影响。

let effects = counterViewReducer(&state, .counter(.incrTapped))

XCTAssertEqual(
  state,
  CounterViewState(
    alertNthPrime: nil,
    count: 3,
    favoritePrimes: [3, 5],
    isNthPrimeButtonDisabled: false
  )
)
XCTAssert(effects.isEmpty)

最后,让我们更新测试的名称,使其更具描述性。

func testIncrButtonTapped() {

我们甚至可以复制和粘贴这个测试,并做一些小的改变,以得到一个等价的测试,以验证递减逻辑是否正确工作:

func testDecrButtonTapped() {
  var state = CounterViewState(
    alertNthPrime: nil,
    count: 2,
    favoritePrimes: [3, 5],
    isNthPrimeButtonDisabled: false
  )
  let effects = counterViewReducer(&state, .counter(.decrTapped))

  XCTAssertEqual(
    state,
    CounterViewState(
      alertNthPrime: nil,
      count: 1,
      favoritePrimes: [3, 5],
      isNthPrimeButtonDisabled: false
    )
  )
  XCTAssert(effects.isEmpty)
}

在这个reducer中要测试的唯一其他逻辑是点击“什么是第n个素数”按钮并从Wolfram API获得响应的流程。我们已经看到测试具有effectsreducer并不理想,但让我们看看我们能走多远。

我们可以从实例化一个我们想要开始的状态开始:

func testNthPrimeButtonFlow() {
  var state = CounterViewState(
    alertNthPrime: nil,
    count: 2,
    favoritePrimes: [3, 5],
    isNthPrimeButtonDisabled: false
  )

在这个流程中发生的第一个用户动作是,用户点击“第n个质数”按钮:

var effects = counterViewReducer(&state, .counter(.nthPrimeButtonTapped))

当这种情况发生时,我们预计“第n个prime”按钮将被禁用,因为Wolfram API请求现在正在飞行中,并将发出单个effect(但再次说明,我们现在对这个效果一无所知):

XCTAssertEqual(
  state,
  CounterViewState(
    alertNthPrime: nil,
    count: 2,
    favoritePrimes: [3, 5],
    isNthPrimeButtonDisabled: true
  )
)
XCTAssertEqual(effects.count, 1)

虽然我们不能断言返回的effect,但我们知道在某个点API请求将完成,其结果将被反馈到store中。现在,我们可以通过使用nthPrimeResponse动作再次调用该reducer来手动完成:

effects = counterViewReducer(&state, .counter(.nthPrimeResponse(3)))

XCTAssertEqual(
  state,
  CounterViewState(
    alertNthPrime: PrimeAlert(prime: 3),
    count: 2,
    favoritePrimes: [3, 5],
    isNthPrimeButtonDisabled: false
  )
)
XCTAssert(effects.isEmpty)

这里的状态以两种方式改变:首先,质数警报变为非nil并保持数字3(因为第二个质数是3),现在API请求已经完成,“第n个质数”按钮的禁用状态变为false。

下面是这个流程的进一步步骤:用户可以关闭当前在UI中显示的警报。当这种情况发生时,我们希望alert状态切换回nil:

effects = counterViewReducer(&state, .counter(.alertDismissButtonTapped))
XCTAssertEqual(
  state,
  CounterViewState(
    alertNthPrime: nil,
    count: 2,
    favoritePrimes: [3, 5],
    isNthPrimeButtonDisabled: false
  )
)
XCTAssert(effects.isEmpty)

这就完成了该流的测试,如果我们运行测试,所有内容仍然通过。

值得一提的是,这是多么酷,尽管我们根本没有测试effects。这里我们有一个用户动作脚本:

  • The user taps on a button
  • The API response comes back
  • The user dismisses the alert

我们可以断言每个用户操作后应用程序的状态,包括确保一个按钮是禁用的,然后是启用的,一个alert显示,然后被取消。这已经是一个相当广泛的测试,需要编写的前期工作很少。

此外,公平地说,这个测试实际上是在测试将在UI中发生什么,因为在counter视图中的view属性的实现对这个状态做了最基本的事情:

.disabled(self.store.value.isNthPrimeButtonDisabled)
// …
.alert(item: .constant(self.store.value.alertNthPrime))

关于这些值的view没有逻辑可言。只要reducer产生正确的状态,一切都应该正常工作,现在我们有一个覆盖该逻辑的测试。

此外,这种架构和测试风格非常适合“测试驱动开发”,如果这是您所重视的。在这种类型的测试中,你将设置你的状态结构、动作枚举和没有任何逻辑的reducer的签名,它可能只是返回一个空的effects数组。然后,您将根据您知道特性应该如何工作的情况写出整个测试套件,然后进入并实现reducer,直到测试套件通过。

6. Unhappy paths and integration tests

现在计数器视图已经很好地测试了,我们甚至可以停在这里,我们有一个很好的测试套件,除了处理effects,我们很快就会做。 然而,如果我们愿意,我们可以将这个测试套件带入下一个层次。首先,我们只是在测试点击第n个主要按钮的快乐路径。我们也应该测试不愉快的路径,以防失败。

计数器视图还具有素数模态的所有功能,我们已经在素数模态模块的一些独立测试中介绍过这些功能。但是,也许我们也想在计数器上下文中使用一些基本模态功能。也许我们想要确保主模态reducer没有意外地弄乱任何计数器视图状态。通过编写一个集成风格的测试,我们可以很容易地做到这一点。

我们正在测试的当前流是快乐流,所以让我们在测试名称中反映它。

func testNthPrimeButtonHappyFlow() {

我们也想捕捉不快乐的流,这样我们就可以复制粘贴快乐的流并做一些改变。值得注意的是,我们从API中得到一个nil响应,并且从不显示警报,因此取消操作就消失了。

func testNthPrimeButtonUnhappyFlow() {
  var state = CounterViewState(
    alertNthPrime: nil,
    count: 2,
    favoritePrimes: [3, 5],
    isNthPrimeButtonDisabled: false
  )

  var effects = counterViewReducer(&sate, .counter(.nthPrimeButtonTapped))

  XCTAssertEqual(
    state,
    CounterViewState(
      alertNthPrime: nil,
      count: 2,
      favoritePrimes: [3, 5],
      isNthPrimeButtonDisabled: true
    )
  )
  XCTAssertEqual(effects.count, 1)

  effects = counterViewReducer(&sate, .counter(.nthPrimeResponse(nil))

  XCTAssertEqual(
    state,
    CounterViewState(
      alertNthPrime: nil,
      count: 2,
      favoritePrimes: [3, 5],
      isNthPrimeButtonDisabled: false
    )
  )
  XCTAssert(effects.isEmpty)
}

当我们运行测试时,它通过了! 只需要很少的工作,我们就能够测试这个流的快乐路径和不快乐路径。

我们可以编写的另一个非常酷的测试是一个集成风格的测试,它确保计数器和主模态已经恰当地集成在一起。

func testPrimeModal() {
  var state = CounterViewState(
    alertNthPrime: nil,
    count: 2,
    favoritePrimes: [3, 5],
    isNthPrimeButtonDisabled: false
  )

  var effects = counterViewReducer(&state, .primeModal(.saveFavoritePrimeTapped))

  XCTAssertEqual(
    state,
    CounterViewState(
      alertNthPrime: nil,
      count: 2,
      favoritePrimes: [3, 5, 2],
      isNthPrimeButtonDisabled: false
    )
  )
  XCTAssert(effects.isEmpty)

  effects = counterViewReducer(&state, .primeModal(.removeFavoritePrimeTapped))

  XCTAssertEqual(
    state,
    CounterViewState(
      alertNthPrime: nil,
      count: 2,
      favoritePrimes: [3, 5],
      isNthPrimeButtonDisabled: false
    )
  )
  XCTAssert(effects.isEmpty)
}

在这里,我们验证prime模态在嵌入到counter视图时是否正确运行,并且它的功能不会破坏counter视图中的任何东西。我们甚至可以通过一个完整的用户脚本来完成这样的流程:添加一个喜欢的素数,求第n个素数,然后删除一个喜欢的素数,同时在每一步验证状态是否按我们预期的方式改变。

这基本上是一个reducers的集成测试。我们正在测试多个功能层,了解它们如何相互作用,并断言它们能够很好地配合使用。这是相当大的!同样,这只是一个玩具应用程序,但在一个大型应用程序中,您将能够测试许多微小的、可重用的组件,当它们插在一起时,仍然能够正常工作。这已经很强大了,我们甚至还没有讨论过effects

7. Next time: testing effects

我们已经看到,作为纯函数的reducers非常容易测试。我们首先构造一些描述当前状态的可变应用状态,然后用特定的动作将reducer应用到这个状态,以描述应用的下一个状态,最后断言新状态等于我们所期望的状态。

另一方面,Effects似乎很难测试。当我们调用reducer时,我们可以断言它没有产生任何effects,或者我们可以断言它产生了一些effects,但我们不能断言产生了某种特定的effects。这是因为Effect类型仅仅是一个函数的包装器,所以我们没有将它们等同的概念。为了解决这个问题,我们手动运行了我们预期会产生effects的动作,这样我们就可以验证reducer在这些响应中发挥了作用。然而,这种手工工作是很脆弱的。我们可能会忘记这样做,这意味着我们根本不是在测试效果,或者我们可能会错误地构建这些effects动作,这意味着我们在测试一些不会在现实世界中真正发生的事情。

也许我们可以运行这些effects来测试它们,但effects很少是可测试的,这就是我们将它们与reducer的纯业务逻辑分离的原因之一。例如,我们的“保存”和“加载”效果非常简单,但是测试它们会带来一些复杂的问题。如果我们运行一个“保存”效果,我们将JSON写入磁盘某处,并断言正确的数据被正确地写入,测试需要知道文件的位置,或者我们需要同时测试“加载”效果。如果我们想在它之后进行清理并删除它创建的任何文件,那么测试肯定需要知道文件所在的位置。测试这些effects的另一个问题是它们与文件系统交互。在某些环境中,对磁盘的读写可能会失败,这取决于文件权限或磁盘空间。

根据effects的复杂程度,测试effects会变得更加复杂。点击“第n个主要按钮”产生一个异步效果,向Wolfram Alpha发出请求,这意味着测试它将要求测试机器有一个网络连接,并且Wolfram Alpha按预期运行。这样的测试必须处理effects的异步特性,它必须等待响应,从而使测试套件在过程中变得更慢。 要想通过这样的测试,我们有很多事情要做,我们已经引入了一个不稳定的、缓慢的测试,它偶尔会破坏CI。

那么我们如何在这个体系结构中测试effects呢? 我们能做什么来控制这些不同的effects,以确保某些数据传递给effects,而某些effects返回给reducer?

好吧,在过去我们只讨论过这个!很久以前,就在我们的第16集中,我们展示了一种轻量级依赖注入的方法,我们称之为“Environment”。让我们看看我们是否可以使用我们的“Environment”知识,让效果可测试……!