1. Recap: the environment

简而言之,environment允许我们将应用程序的所有依赖项与可变字段捆绑在一个单一的数据类型中,这使得将mock实现转换为live实现非常容易,反之亦然。这让我们能够在代码中测试所有的边缘情况和不愉快的路径,这不仅对于编写测试非常有用,而且对于在playgrounds中运行代码也非常有用。

我们将尝试environment理念来控制应用程序中的副作用。我们将从收藏素数屏幕开始,它具有最简单的保存和加载收藏素数的effects。我们将引入一个environment结构体,它最终将包含我们这个模块所需的所有副作用依赖项:

struct Environment {
}

为了让我们回忆一下如何将依赖项添加到这个environment中,假设这个模块需要访问当前日期。这肯定是一个副作用,因为每次创建日期时时间都会改变。但是,我们不允许自己直接从代码中调用日期初始化器,而是将它添加到我们的environment中:

struct Environment {
  var date: () -> Date
}

此外,我们还可以使用这些依赖项的默认实时实现来扩展环境。

extension Environment {
  static let live = Environment(
    date: Date.init
  )
}

然后我们在模块中有一个全局environment,所以任何时候我们想要访问日期,我们只访问当前environment中的日期:

var Current = Environment()

这意味着,当我们想要获得一个日期值时,我们应该强制自己通过Current environment,而不是允许自己接触到不受控制的日期初始化器。

Current.date()

因为这是一个可变属性,我们可以以一种更可控的方式交换我们的实现。例如,我们可以创建一个mock版本的环境,其中只有date函数总是返回相同的日期:

extension Environment {
  static let mock = Environment(
    date: { Date(timeIntervalSince1970: 1234567890) }
  )
}

然后在测试中,我们将用mock环境来替换我们的live环境:

Current = .mock

这允许我们在需要的时候以一种轻量级的方式控制这种依赖关系,比如在testsplaygrounds上,而生产应用程序将使用这些依赖关系的live版本。

日期依赖非常简单,但在依赖注入那一集中,我们还展示了一个更复杂的依赖:一个GitHub API客户端:

struct GitHubClient {
  var fetchRepos: (@escaping (Result<[Repo], Error>) -> Void) -> Void

  struct Repo: Decodable {
    var archived: Bool
    var description: String?
    var htmlUrl: URL
    var name: String
    var pushedAt: Date?
  }
}

同样,它被表示为带有一些可变字段的简单结构体,因此我们可以轻松地将实现替换为mock

我们完全理解这些代码可能会让我们的一些观众感到不舒服。首先,我们有一个标识符大写的变量。另一方面我们有一个可变的全局变量。然而,这些依赖项只能在playgrounds and tests中交换。在生产应用程序中,依赖项应该在环境中创建,然后不受影响。您甚至可以创建lint规则,以确保不会在playground or test之外修改环境。您还可以利用Environment是一种值类型这一事实,并强制它在生产中完全不可变:

#if DEBUG
var Current = Environment()
#else
let Current = Environment()
#endif

简而言之,这就是environment!

  • 你创建一个名为Environment的结构体,其中包含一堆描述应用程序依赖关系的可变字段。
  • 您创建了一个指向所有依赖项的live版本的live版本。
  • 您可以创建一个mock版本,使用简单的、受控制的默认值将这些不同的端点存根出来。
  • 然后在您的tests and playgrounds上,您可以使用mock版本并进一步交换其他mock场景,而在您的应用程序中,您将使用live版本。

如果您仍然对此感到不舒服,我们强烈建议您观看关于这个主题的第16集,希望我们能让您相信这种类型的依赖项管理有很多好处,并极大地降低了现有解决方案的样板和复杂性。

现在让我们尝试live环境来控制应用程序的effects

2. Controlling the favorite primes save effect

现在我们已经记住了environment是如何工作的,让我们创建一个environment来控制这个模块中的保存和加载效果。它们目前是小的私有助手,将有效逻辑包装在Effect类型中:

private func saveEffect(favoritePrimes: [Int]) -> Effect<FavoritePrimesAction> {
  return .fireAndForget {
    let data = try! JSONEncoder().encode(favoritePrimes)
    let documentsPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0]
    let documentsUrl = URL(fileURLWithPath: documentsPath)
    let favoritePrimesUrl = documentsUrl.appendingPathComponent("favorite-primes.json")
    try! data.write(to: favoritePrimesUrl)
  }
}

private let loadEffect = Effect<FavoritePrimesAction>.sync {
  let documentsPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0]
  let documentsUrl = URL(fileURLWithPath: documentsPath)
  let favoritePrimesUrl = documentsUrl.appendingPathComponent("favorite-primes.json")
  guard
    let data = try? Data(contentsOf: favoritePrimesUrl),
    let favoritePrimes = try? JSONDecoder().decode([Int].self, from: data)
    else { return nil }
  return .loadedFavoritePrimes(favoritePrimes)
}
.eraseToEffect()

这些effectslive实现会从reducer返回:

case .saveButtonTapped:
  return [saveEffect(favoritePrimes: state)]

case .loadButtonTapped:
  return [loadEffect]

我们想要在一个可以放到环境中的依赖项中捕捉这些effects。让我们为喜爱的素数模块引入一个全新的environment

struct FavoritePrimesEnvironment {

}

这里是什么? 我们可以将save和load效果的字段直接添加到这个结构体中,但因为它们是相关的,所以我们从GitHubClient中获得一些灵感,创建一个FileClient来保存保存和加载的effects:

struct FileClient {
}

每个effect都是这个结构体中的一个字段:

struct FileClient {
//  var load:
//  var save:
}

让我们从load effect开始。在它的核心,所有的加载都涉及到生成一个表示最喜欢的质数的整数数组,所以我们可能会尝试做以下事情:

struct FileClient {
  var load: () -> [Int]?
//  var save:
}

这里有两个问题:

  • 首先,类型没有给出任何会发生影响的迹象。如果我们调用这个,我们知道它将访问磁盘上的数据,我们希望用类型表示。
  • 其次,这种effect非常特定于我们的一个用例,即从名为favorite-prime .json的文件中加载一个整数数组。我们可以通用化,传入一个文件名,然后得到Data,然后我们可以自己解码JSON

解决这些问题很容易。我们可以首先为文件名引入一个String参数,然后泛化可选数据返回的可选整数数组。

struct FileClient {
  var load: (String) -> Effect<Data?>
//  var save:
}

可以以类似的方式处理save字段,但它可以接受要保存的文件的名称以及需要保存的数据。它还需要返回一个effect,但还不清楚什么effect应该是通用的:

var save: (String, Data) -> Effect<???>

记住,这是一种“fire-and-forget”的effect。它只需要将一些数据保存到磁盘,而不需要将任何数据发送回系统。我们可以做的一件事是将FavoritePrimesAction硬编码成这样的effect:

var save: (String, Data) -> Effect<FavoritePrimesAction>

然而,这不必要地将这个文件客户端直接耦合到这个模块。它阻止我们在某一天将这个客户端提取到它自己的模块中,并在其他屏幕或应用程序中使用它,而这些屏幕或应用程序并不关心这个最喜欢的prime屏幕。

我们可以通过使FileClient泛型来从FileClient中解耦effect的类型。这将允许任何人使用文件客户端自带自己的类型:

struct FileClient<Action> {
  // …
  var save: (String, Data) -> Effect<A>
}

但这似乎很重要,我们甚至不想让save effect能够产生反馈到系统的动作。

我们甚至可能会尝试使用Void作为效果的类型,因为它代表了一段没有语义或意义的数据:

struct FileClient {
  // …
  var save: (String, Data) -> Effect<Void>
}

这仍然是不对的,因为这将允许save effect发送一个空值回系统。

我们正在探索的所有这些错误的开始都指向了一些非常重要的东西。我们想在这种effect的类型中表现一些非常具体的东西。我们想要一种effect,它能正常工作,但不能产生值。它应该没有能力将操作发送回store

有一种类型可以让我们做到这一点:Never

var save: (String, Data) -> Effect<Never>

从不是所谓的“无人居住”类型。它是一个在Swift标准库中定义的zero casesenum,。

public enum Never {
}

构造Never类型的值是不可能的。 因为不可能构造一个值,所以这个effect发布者也不可能产生一个Never类型的排放。这是对我们想要此effect满足的属性的编译时验证。

这也为我们的api提供了非常好的文档。如果您曾经遇到过**Effect**类型,您甚至不用查看实现就知道它永远不会产生值,因此它一定是一种“fire-and-forgeteffect

现在我们已经将依赖项描述为一个简单的数据类型,我们可以创建它的活动版本用于生产。我们可以将它定义为FileClient类型的静态变量:

extension FileClient {
  static let live = Self(
    load: <#(String) -> Effect<Data?>#>,
    save: <#(String, Data) -> Effect<Never>#>
  )
}

我们基本上可以将当前的effect复制粘贴到这些闭包中,只做最小的更改:

extension FileClient {
  static let live = Self(
    load: { fileName in
      .sync {
        let documentsPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0]
        let documentsUrl = URL(fileURLWithPath: documentsPath)
        let favoritePrimesUrl = documentsUrl.appendingPathComponent(fileName)
        return try? Data(contentsOf: favoritePrimesUrl)
      }
  },
    save: { fileName, data in
      .fireAndForget {
        let documentsPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0]
        let documentsUrl = URL(fileURLWithPath: documentsPath)
        let favoritePrimesUrl = documentsUrl.appendingPathComponent(fileName)
        try! data.write(to: favoritePrimesUrl)
      }
  })
}

现在我们可以将这个依赖添加到我们的environment中:

struct FavoritePrimesEnvironment {
  var fileClient: FileClient
}

现在我们可以定义一个环境的live版本,它使用文件客户端的live实现:

extension FavoritePrimesEnvironment {
  static let live = FavoritePrimesEnvironment(fileClient: .live)
}

最后,我们希望使用大写的Current实例化环境的一个live实例:

var Current = FavoritePrimesEnvironment.live

环境就绪后,我们只需要重构我们的reducer,使其不再直接构造其effects,而只使用Current环境。

让我们从save effect开始:

case .saveButtonTapped:
  return [saveEffect(favoritePrimes: state)]

我们可以在我们的环境中使用新的effect,而不是调用这个局部的私有effect:

Current.fileClient.save

文件名和我们一直使用的文件名是一样的

Current.fileClient
  .save("favorite-primes.json", ???)

要给出这个函数数据,我们必须在reducer中正确地执行JSON编码。这是一个非常好的做法,因为JSON编码是一个纯操作:

Current.fileClient.save("favorite-primes.json", try! JSONEncoder().encode(state))

现在这个还不能编译,因为它返回一个Effect,但我们需要返回一个Effect

🛑 Cannot convert value of type ‘Effect’ to expected element type ‘Effect’

我们刚才讨论了返回Effect来表示fire-and-forget****effect是如何正确的,但现在它似乎给我们带来了问题。 我们究竟如何将Never值转换为FavoritePrimesAction值呢?

当然,这是完全可能的,我们在Point-Free的第9集讨论Swift的类型系统和代数之间的关系时讨论过。使用代数作为我们的指路明灯,我们能够发现下面的签名实际上有一个实现:

// (Never) -> A

We called it absurd, because it seems pretty absurd:

func absurd<A>(_ never: Never) -> A {
  switch never {}
}

这是因为我们的switch已经详尽地处理了Never中的每个case,这是完全正确的,因为Never没有case

这是我们在本集的实现,但从那以后Swift变得更聪明了,我们现在甚至可以省略这个函数体:

func absurd<A>(_ never: Never) -> A {}

这正是我们可以使用的函数,将我们的“fire-and-forget effect”从“Nevers”的世界提升到“reducer”行动的世界:

Current.fileClient.save("favorite-primes.json", try! JSONEncoder().encode(state))
  .map(absurd)

现在这无法编译,因为我们要确保将publisher类型擦除回effect类型

Current.fileClient.save("favorite-primes.json", try! JSONEncoder().encode(state))
  .map(absurd)
  .eraseToEffect()

现在可以编译了。我们甚至可以将这个小小的absurd dance捆绑在一个自定义操作符中,这样我们就可以为它取一个好听的名字:

extension Publisher where Output == Never, Failure == Never {
  func fireAndForget<A>() -> Effect<A> {
    return self.map(absurd).eraseToEffect()
  }
}

And now we can simply do:

Current.fileClient
  .save("favorite-primes.json", try! JSONEncoder().encode(state))
  .fireAndForget()

这读起来很不错。它清楚地说明了这是一种fire-and-forget effect,它允许我们向上投射Never类型到我们需要从这个reducer返回的任何类型。

现在我们可以删除之前的saveEffect了:

//private func saveEffect(favoritePrimes: [Int]) -> Effect<FavoritePrimesAction> {
//  return .fireAndForget {
//    let data = try! JSONEncoder().encode(favoritePrimes)
//    let documentsPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0]
//    let documentsUrl = URL(fileURLWithPath: documentsPath)
//    let favoritePrimesUrl = documentsUrl.appendingPathComponent("favorite-primes.json")
//    try! data.write(to: favoritePrimesUrl)
//  }
//}

3. Controlling the favorite primes load effect

一个effect消失了,还有一个。我们能够将我们的effect提取到一个环境中,以便它们的live实现可以被替换为mock实现,但我们只处理了一个“fire-and-forget”的effect

接下来,我们可以改变load effect来使用环境:

case .loadButtonTapped:
  return [
    loadEffect
      .compactMap { $0 }
      .eraseToEffect()
  ]

我们可以从调用环境上的端点开始

Current.fileClient.load("favorite-primes.json")

这就产生了Data?effect。我们想要从JSON中解码这些内容,幸运的是,在Combine publishers上有一个帮手来做这件事:

Current.fileClient.load("favorite-primes.json")
  .decode(type: [Int].self, decoder: JSONDecoder())

然而,这并不工作,因为decode期望一个诚实的数据的发布者,而我们给它可选的数据。

Current.fileClient
  .load("favorite-primes.json")
  .compactMap { $0 }
  .decode(type: [Int].self, decoder: JSONDecoder())

从技术上讲,这给了我们一个可能会失败的发行商,因为从JSON解码的过程可能会失败。但是我们的Effect publisher是不允许失败的,因为我们必须直接在我们的reducer中显式地处理任何失败。

我们可以使用publishers上的catch方法来解决这个问题,它允许您拦截publishers产生的任何错误,并将其映射到一个全新的publisher

Current.fileClient
  .load("favorite-primes.json")
  .compactMap { $0 }
  .decode(type: [Int].self, decoder: JSONDecoder())
  .catch { _ in Empty(completeImmediately: true) }

现在我们有了一个简单的整数数组的publisher,所以我们要做的就是将它映射到FavoritePrimesAction中,并将publisher擦除到我们的effect类型:

Current.fileClient
  .load("favorite-primes.json")
  .compactMap { $0 }
  .decode(type: [Int].self, decoder: JSONDecoder())
  .catch { _ in Empty(completeImmediately: true) }
  .map(FavoritePrimesAction.loadedFavoritePrimes)
  .eraseToEffect()

现在这个模块正在编译,就像刚才那样,我们已经控制了这个屏幕中的所有effects。我们甚至可以注释掉旧的、无法控制的load effect。任何可以执行副作用的东西都被填充到这个Environment类型中,它保存了生产应用程序的live实现,但也让我们在需要时方便地切换实现。

为了看到这一点,让我们创建一个mock版本与我们的live版本共存:

extension FavoritePrimesEnvironment {
  static let mock = FavoritePrimesEnvironment(
    fileClient: FileClient(
      load: { _ in Effect<Data?>.sync { try! JSONEncoder().encode([2, 31])) } },
      save: { _, _ in .fireAndForget {} }
    )
  )
}

我们甚至可以在调试检查中保护这段代码,以便它只与应用的调试版本一起发布:

#if DEBUG
// …
#endif

现在,我们有了一种超级简单的方法来将环境的live版本替换为mock版本。为了展示它的强大功能,让我们更新playground,使用mock环境,而不是实际访问文件系统:

@testable import FavoritePrimes
// …

Current = .mock

PlaygroundPage.current.liveView = UIHostingController(
  rootView: NavigationView {
    FavoritePrimesView(
      store: Store<[Int], FavoritePrimesAction>(
        initialValue: [2, 3],
        reducer: favoritePrimesReducer
      )
    )
  }
)

如果我们运行playground并点击load,我们将看到质数2和31弹出,即使我们没有点击save。这是因为 mock load effect硬编码了要加载的启动数。我们甚至可以对这个应用程序进行压力测试,看看它是如何加载超大的质数列表的:

Current = .mock
Current.fileClient.load = { _ in
  Effect.sync { try! JSONEncoder().encode(Array(1...100)) }
}

就像我们看到的,当我们试图加载100个数字到这个界面时,会发生什么。当然,它们不是质数,但这对我们现在要测试的东西并不重要。为了更清楚地说明这一点,让我们增大这个数组的大小,以容纳1000个整数:

Current.fileClient.load = { _ in
  Effect.sync { try! JSONEncoder().encode(Array(1...1000)) }
}

现在,当我们在界面中点击加载时,我们看到它会冻结几秒钟,然后将更改动画到位。对于SwiftUI和非常大的列表来说,这似乎是一个问题,但是我们可以通过模拟effects轻松地对界面进行压力测试,而不需要找到将一个大的整数JSON文件放到磁盘上的方法,这是非常棒的。

4. Testing the favorite primes save effect

现在我们已经具备了开始为effects编写一些测试所需的一切。让我们看看我们已经为favoritePrimesReducer编写了哪些测试:

func testSaveButtonTapped() {
  var state = [2, 3, 5, 7]
  let effects = favoritePrimesReducer(state: &state, action: .saveButtonTapped)
  XCTAssertEqual(state, [2, 3, 5, 7])
  XCTAssertEqual(effects.count, 1)
}

这是最简单的测试,即当我们点击保存按钮时,我们不会改变状态,但我们会发出一种effect。为了测试这个effect,我们可以运行它:

_ = effects[0].sink { }

记住,默认情况下,我们使用的是这个effect的实时实现,这意味着要理解这个effect的作用,我们需要找到磁盘上的文件并断言保存了什么。正如我们之前提到的,这是一件非常脆弱的事情,因为我们甚至可能没有对这个磁盘的读写权限。我们希望完全消除处理实际磁盘存储的各种怪异操作,这可以通过使用mock环境来实现:

Current = .mock

这样就不会碰到磁盘了。然而,这仍然不能帮助我们测试保存effect。我们真正想测试的是是否调用了保存effect。 我们必须相信,只要传递了正确的信息,实时保存effect就会做正确的事情。为了在模拟effect中捕捉到它,我们只需在周围保留一个可变的布尔值,指示是否执行了save效果,然后在effect中翻转它:

var didSave = false
Current.fileClient.save = { _, _ in .fireAndForget { didSave = true } }

然后我们只想确保这个布尔值在执行effect后被翻转为true:

_ = effects[0].sink { _ in }

XCTAssert(didSave)

如果我们运行测试,它通过了,这意味着我们已经断言reducer使用了我们期望的effect! 这意味着只要我们相信保存effect是正确的,这是一个非常简单的effect,所以我们也许可以相信它是正确的,那么我们至少可以得到一些关于这个effect的报道。

我们可以更进一步,确保这个effect的回调函数永远不会被调用,因为这个effect应该是“fire-and-forget”的:

_ = effects[0].sink { _ in XCTFail() }

这很好,因为它保证我们已经捕获了这个effect的整个周期,也就是说,这个effect不会产生任何我们需要通过reducer来验证其行为是否符合预期的操作。

我们刚刚在这里取得的成就值得反思。只需要很少的设置,我们便能够以一种非常直接的方式确认当用户点击保存按钮时我们不会改变状态,并且会执行一个调用我们的save依赖项的副作用,并且不会发出任何其他动作发送到store中。这是非常广泛的覆盖,很少的工作。

然而,重要的是要澄清,我们获得这种能力的一个主要原因是我们的依赖性是如何建立的。当依赖关系尽可能简单时,这种类型的测试效果最好。简单到你可以相信他们会做正确的事情只要你给他们正确的数据。它们非常简单,本身几乎没有逻辑。例如,保存和加载效果只完成将数据存入磁盘和从磁盘取出所需的最低限度的工作。它不做任何数据转换,比如JSON解码,它把这些工作留给依赖项的用户。我们的测试将测试这些数据转换,而将与磁盘交互的混乱留给依赖项。

为了证明这个测试确实捕捉了我们reducer的一些行为,让我们假设我们犯了一个非常糟糕的copy-pasta错误,我们不小心在保存操作中使用了加载效果:

case .saveButtonTapped:
  return [
    Current.fileClient
      .load("favorite-primes.json")
      .compactMap { $0 }
      .decode(type: [Int].self, decoder: JSONDecoder())
      .catch { _ in Empty(completeImmediately: true) }
      .map(FavoritePrimesAction.loadedFavoritePrimes)
      .eraseToEffect()
  ]

现在,当我们运行测试时,我们得到两个失败:

  • The effect at effects[0] emitted a value, which we know it should not.
  • The didSave flag was not flipped to true, which means the save effect was not invoked.

还有其他方法可以加强这个测试。例如,我们可以提取发送到保存端点的数据,并验证它编码的整数数组是否正确。这使得我们可以用很少的工作来覆盖更多的内容,但是我们将把它留给观众作为练习。

这变得非常酷,我们看到了控制我们的effects的意义的开端:只要我们将依赖关系描述为带有可变字段的简单结构,只要我们强制reducer在该结构中使用这些依赖关系,我们能够将活动实现替换为模拟实现,并实际执行这些效果,以断言它们产生了正确的值来反馈给系统,或者根本没有产生任何东西。

因为effects应该包含很少的逻辑,并且专注于它们需要做的最小数量的工作,我们不需要担心测试effects本身的细节。如果一个effect仅仅调用苹果的api从磁盘加载数据,我们应该能够希望它能正常工作。我们关心的是捕获是否调用了特定的effect,并捕获它反馈给reducer的数据。

5. Testing the favorite primes load effect

现在我们已经测试了保存effect,让我们测试加载effect,这有点不同,因为它需要将数据反馈到reducer
接下来,让我们看看之前编写的下一个测试:

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

  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)
}

这将测试如果我们点击加载按钮,状态不会改变,但会发出一个effect。我们不知道是哪个effect,但我们假设它是loaddfavoriteprimes effect,因此我们在reducer中运行该动作,并断言状态已更改,没有发出进一步的effect

我们想要一些测试覆盖我们忽略的effect,但如果我们只是天真地运行它,我们不会得到任何超级有用的东西:

_ = effects[0].sink { action in
  print(action)
}

我们不想为了运行这个测试而依赖于磁盘的状态。幸运的是,我们已经控制了这种影响,所以我们可以完全绕过磁盘,直接提供数据:

Current = .mock
Current.fileClient.load = { _ in .sync { try! JSONEncoder().encode([2, 31]) } }

现在,当我们运行测试时,打印状态执行,我们看到我们得到的动作是:

loadedFavoritePrimes([2, 31])

这是我们真正关心的数据,因为它表明effect做了一些工作,并返回这个动作反馈到系统中。然而,如果我们试图直接断言,我们就有一个问题:

_ = effects[0].sink { action in
  XCTAssertEqual(action, .loadedFavoritePrimes([2, 31]))
}

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

我们不能断言这个动作等于什么,因为它不是Equatable。这很容易解决:

public enum FavoritePrimesAction: Equatable {

现在,测试被编译了,它通过了,因为effect产生的动作符合我们的期望。但我们不想在这里停止,我们接下来要采取这个行动,并把它馈回reducer。我们可以通过在reducer外部获取这个动作的引用,然后在之后使用它来实现:

var nextAction: FavoritePrimesAction!
_ = effects[0].sink { action in
  XCTAssertEqual(action, .loadedFavoritePrimes([2, 31]))
  nextAction = action
}

effects = favoritePrimesReducer(state: &state, action: nextAction)

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

现在这很酷。我们并不是手动构造我们认为会产生effect的动作,只是为了将其反馈给store。相反,我们运行这个effect,断言它产生了我们期望的动作,将它反馈给reducer,然后断言这个新动作如何改变了我们的状态。

我们甚至可以更进一步,断言这个effect不仅产生了我们预期的动作,而且它完成了,因此不会产生另一个effect。我们可以通过设置一个测试期望,在sink之后等待它,然后在completion block中实现这个期望:

var nextAction: FavoritePrimesAction!
let receivedCompletion = self.expectation(description: "receivedCompletion")
_ = effects[0].sink(
  receiveCompletion: { _ in receivedCompletion.fulfill() },
  receiveValue: { action in
    nextAction = action
    XCTAssertEqual(action, .loadedFavoritePrimes([2, 31]))
})
self.wait(for: [receivedCompletion], timeout: 0)

这让我们的测试更加有力。如果我们修改reducerload effect为永不完成,我们将会得到一个失败:

return [
  Current.fileClient
    .load("favorite-primes.json")
    .decode(type: [Int].self, decoder: JSONDecoder())
    .catch { _ in Empty(completeImmediately: true) }
    .map(FavoritePrimesAction.loadedFavoritePrimes)
    .merge(with: Empty(completeImmediately: false))
    .eraseToEffect()
]

🛑 Asynchronous wait failed: Exceeded timeout of 0 seconds, with unfulfilled expectations: “receivedCompletion”.

我们捕捉到的事实是惊人的。这意味着我们正在证明,未来不存在我们可能会意外忘记的行为。

从技术上讲,我们甚至可以使它更强,因为现在我们并没有断言这种effect只会产生一次。它可能已经发射了很多次,但这个测试仍然可以通过。例如,如果我们在reducerload effect中偷偷加入这个:

.merge(with: Just(FavoritePrimesAction.loadedFavoritePrimes([2, 31])))

一切都过去了,但显然这是非常不同的行为。所以我们仍然没有捕捉到全部的effect,但我们得到了很多。我们很快就能捕获更多,但在此之前,让我们完成控制器并在应用程序中测试其余的副作用。但在此之前,让我们通过在测试的setUp方法中设置默认的模拟环境来稍微清理一下这个测试套件,这样每个测试用例都将从一个新的模拟环境开始,他们可以按照自己的意愿来定制:

override func setUp() {
  super.setUp()
  Current = .mock
}

6. Controlling the counter effect

到目前为止,这已经很有启发性了。尽管保存和加载效果看起来并不复杂,但我们能够对它们进行大量测试。我们不仅可以测试保存effect是否有效,还可以测试它是否不会产生另一个动作。我们不仅可以测试load effect是否起作用,我们还可以断言它是否起到了我们预期的作用并将正确的动作反馈给系统。我们用非常少的工作获得了大量的测试覆盖。

既然所有的副作用都在FavoritePrimes模块中得到了控制和测试,现在让我们把注意力转向Counter模块。它只有一个效果,即访问Wolfram Alpha API的网络请求。在此之前,这对我们来说是最难处理的,因为它是异步的,要理解它如何适合我们的体系结构需要相当多的工作。 然而,控制它就相当简单了。

我们目前使用的effect是这个函数,它计算给定特定n的“第n个素数”:

func nthPrime(_ n: Int) -> Effect<Int?> {

}

这就是我们想要控制的effect,所以让我们创建一个环境结构体并将其添加到环境中:

struct CounterEnvironment {
  var nthPrime: (Int) -> Effect<Int?>
}

我们也可以通过调用当前的nthPrime效果来创建这个环境的live实现:

extension CounterEnvironment {
  static let live = Self(nthPrime: Counter.nthPrime)
}

我们可以默认当前的环境是live

var Current = CounterEnvironment.live

在这里,我们也可以创建mock版本:

#if DEBUG
extension CounterEnvironment {
  static let mock = CounterEnvironment(nthPrime: { _ in .sync { 17 } })
}
#endif

环境就位后,现在我们要做的就是使用环境的nthPrime effect,而不是在这个模块中使用live:

//      nthPrime(state.count)
      Current.nthPrime(state.count)
        .map(CounterAction.nthPrimeResponse)
        .receive(on: DispatchQueue.main)
        .eraseToEffect()

这就是完全控制模块副作用所需要的一切。 比FavoritePrimes模块简单多了。我们可以通过在playground中运行计数器屏幕而获得益处:

@testable import Counter
// …

Current = .mock

PlaygroundPage.current.liveView = UIHostingController(
  rootView: CounterView(
    store: Store<CounterViewState, CounterViewAction>(
      initialValue: CounterViewState(
        alertNthPrime: nil,
        count: 0,
        favoritePrimes: [],
        isNthPrimeButtonDisabled: false
      ),
      reducer: logging(counterViewReducer)
    )
  )
)

现在当我们运行操场,点击“第n个质数是多少?”,我们会立即得到一个响应,说质数是2。这当然是错误的,但令人惊讶的是,我们可以在不依赖于Wolfram Alpha服务的情况下测试这个功能。这很好,因为我们可能在没有互联网接入的飞机上,或者可能有一天Wolfram API会宕机。当您能够正确地控制应用程序中的副作用时,这些都不重要。

7. Testing the counter effects

现在我们控制了counter effects,让我们来测试它们。我们可以从测试套件的设置中模拟环境开始,这样我们就可以确保我们永远不会使用依赖项的实时实现:

override func setUp() {
  super.setUp()
  Current = .mock
}

前几个测试没有任何效果,所以没有什么可做的:

func testIncrTapped() {
  // …
  XCTAssert(effects.isEmpty)
}

func testDecrTapped() {
  // …
  XCTAssert(effects.isEmpty)
}

下一个测试,testNthPrimeButtonHappyFlow,在此屏幕中测试一个完整的用户流:

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

  var effects = counterViewReducer(&state, .counter(.nthPrimeButtonTapped))
  XCTAssertEqual(
    state,
    CounterViewState(
      alertNthPrime: nil,
      count: 7,
      favoritePrimes: [2, 3],
      isNthPrimeButtonDisabled: true
    )
  )
  XCTAssertEqual(effects.count, 1)

  effects = counterViewReducer(&state, .counter(.nthPrimeResponse(17)))
  XCTAssertEqual(
    state,
    CounterViewState(
      alertNthPrime: PrimeAlert(prime: 17),
      count: 7,
      favoritePrimes: [2, 3],
      isNthPrimeButtonDisabled: false
    )
  )
  XCTAssert(effects.isEmpty)

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

当用户点击“第n个素数”按钮时,某些状态会发生改变,特别是isNthPrimeButtonDisabled字段会被切换为true,并返回一个effect,尽管我们目前还不知道这个effect是什么。

然后,我们通过向reducer中输入另一个动作来模拟来自API的响应,该
是我们期望从effect中得到的,但我们仍然缺少对effect的覆盖。让我们重复我们对最喜欢的primeseffect所做的,通过运行这个效应,看看我们能发现什么。我们可以在effect上调用sink来获得它的完成和值事件:

_ = effects[0].sink(
  receiveCompletion: { _ in },
  receiveValue: { action in }
)

我们可以断言,这种effect产生的动作是我们所期望的nthprimerresponse:

_ = effects[0].sink(
  receiveCompletion: { _ in },
  receiveValue: { action in
    XCTAssertEqual(action, .counter(.nthPrimeResponse(3)))
})

我们只需要确保我们的actions是公平的。

enum CounterViewAction: Equatable {
// …
enum CounterAction: Equatable {
// …
enum PrimeModalAction: Equatable {

但我们期望的数字是多少?这取决于我们在模拟中使用了什么。我碰巧记得,我们使用17为这个模拟效果,但为什么要依赖于它,当我们可以用我们自己的mock覆盖端点,这是本地的测试:

Current.nthPrime = { _ in .sync { 17 } }
// …
effects[0].sink(
  receiveCompletion: { _ in },
  receiveValue: { action in
    XCTAssertEqual(action, .counter(.nthPrimeResponse(17)))
})

但是,尽管这个测试看起来不错,它可能会更好。在处理最喜欢的素数effect时,我们还测试了它们是否像我们预期的那样完成,我们甚至捕获了该effect所发出的动作,以便我们能够将其反馈给reducer。让我们试一试:

var nextAction: CounterViewAction!
let receivedCompletion = self.expectation(description: "receiveCompletion")
_ = effects[0].sink(
  receiveCompletion: { _ in receivedCompletion.fulfill() },
  receiveValue: { action in
    nextAction = action
    XCTAssertEqual(action, .counter(.nthPrimeResponse(17)))
})
self.wait(for: [receivedCompletion], timeout: 0.1)

effects = counterViewReducer(&state, nextAction)
  • Created an implicitly unwrapped CounterViewAction so that we could capture the action produced by the effect
  • Created an expectation
  • Fulfilled it when we received a completion event from the effect
  • Capture the action when we receive a value
  • Waited for the expectation to be fulfilled
  • And finally sent the action back into the reducer

If we run this, we get a crash:

🛑 Fatal error: Unexpectedly found nil while implicitly unwrapping an Optional value

看起来nextAction永远不会被设置。为了了解原因,让我们跳到reducer

Current.nthPrime(state.count)
  .map(CounterAction.nthPrimeResponse)
  .receive(on: DispatchQueue.main)
  .eraseToEffect()

这个effect正在做以前的effect都没有做过的事情:在另一个队列上进行工作,这需要比我们当前等待的0秒更多的时间。

self.wait(for: [receivedCompletion], timeout: 0.01)

测试通过了,现在我们正在测试effect的覆盖率。这已经很不可思议了。我们实际上是在测试reducer是否产生了这样一种effect,即当运行时产生了我们所期望的动作。然后,当把这个动作反馈给reducer时,我们可以断言应用程序的状态是我们所期望的。

但我们还可以更进一步。现在我们控制了这种影响,我们可以开始测试一些边缘情况和不愉快的路径。例如,我们可以模拟当Wolfram Alpha API出现问题时会发生什么,这意味着effect返回nil:

func testNthPrimeButtonUnhappyFlow() {
  Current.nthPrime = { _ in .sync { nil } }

这就是模拟有错误的API所需要的一切。在这之后,我们基本上可以复制粘贴之前的测试,只做一些小的改变:

func testNthPrimeButtonUnhappyFlow() {
  Current.nthPrime = { _ in Effect(value: nil) }
  var state = CounterViewState(
    alertNthPrime: nil,
    count: 7,
    favoritePrimes: [2, 3],
    isNthPrimeButtonDisabled: false
  )

  var effects = counterViewReducer(&state, .counter(.nthPrimeButtonTapped))
  XCTAssertEqual(
    state,
    CounterViewState(
      alertNthPrime: nil,
      count: 7,
      favoritePrimes: [2, 3],
      isNthPrimeButtonDisabled: true
    )
  )
  XCTAssertEqual(effects.count, 1)

  let receivedCompletion = self.expectation(description: "receivedCompletion")
  var nextAction: CounterViewAction!
  _ = effects[0].sink(
    receiveCompletion: { _ in
      receivedCompletion.fulfill()
  },
    receiveValue: { action in
      nextAction = action
      XCTAssertEqual(action, .counter(.nthPrimeResponse(nil)))
  })
  self.wait(for: [receivedCompletion], timeout: 0.1)

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

现在我们已经测试了整个用户流,当API失败时,用户试图请求“n个素数”。特别地,我们要确保alert不显示,并且isNthPrimeButtonDisabled正确地翻回false,以便我们可以与它交互。

8. Next time: test ergonomics

现在我们已经编写了一些真正强大的测试。我们不仅测试当用户在UI中做各种事情时应用程序的状态如何演变,而且还通过断言执行了正确的effect并返回了正确的操作来对effect进行端到端测试。

我们想要提及的是,我们现在构建环境的方式并不是100%理想的。它完成了这个应用程序的工作,但是一旦我们想要在许多独立模块之间共享一个依赖关系,就会遇到问题,比如我们的PrimeModal模块想要访问FileClient。我们别无选择,只能为那个模块创建一个新的FileClient实例,这意味着应用程序有两个FileClient。幸运的是,解决这个问题很简单,我们很快会在未来的一集里做这个。

我们的测试的另一个不好的地方是它们非常笨重。我们最近编写的一些测试超过了60行!因此,如果我们只编写10个测试,这个文件就已经超过600行了。

现在我们的测试有很多仪式。我们必须:

  • create expectations
  • run the effects
  • wait for expectations
  • fulfill expectations
  • capture the next action
  • assert what action we got and feed it back into the reducer.

对于我们所测试的每个效果来说,这是一种非常强烈的重复,就像我们所提到的那样,它甚至不能捕捉到所有的effect,因为可能会有一些额外的effect出现。

也许我们可以关注最基本的要素:我们需要做些什么来确定对我们架构的期望。它似乎可以归结为提供一些初始状态,提供我们想要测试的reducer,然后在过程中提供一系列的动作和期望,理想情况下以带有少量样板的声明式方式……下次吧!