1. Introduction

因此,我们现在已经证明了,我们开发的可组合架构不仅是超级可测试的,而且它还可以测试应用程序的深层方面,而且可以通过最小的设置和仪式来完成。如果要激发人们编写测试,这是关键。在编写测试时应该尽可能少地遇到摩擦,并且我们应该确信我们正在测试应用程序的一些现实世界方面。

那么,有必要做所有这些工作来获得这种水平的可测试性吗?我们能不能不用普通的SwiftUI来做这种测试呢?

不幸的是,我们确实认为在SwiftUI应用程序中做一些工作来获得可测试性是必要的。你不一定需要使用我们构建的可组合架构,但如果你想测试你的SwiftUI应用程序,你将不可避免地要在SwiftUI上引入一些层来实现这一点。

为了了解这一点,让我们看看我们很久以前编写的普通的SwiftUI应用程序,这是我们对SwiftUI的介绍,也是我们开始这一系列架构章节的全部原因。


2. A tour of the vanilla SwiftUI code base

它从一个保存应用程序状态的类开始:

class AppState: ObservableObject {
  @Published var count = 0
  @Published var favoritePrimes: [Int] = []
  @Published var loggedInUser: User? = nil
  @Published var activityFeed: [Activity] = []

  struct Activity {
    let timestamp: Date
    let type: ActivityType

    enum ActivityType {
      case addedFavoritePrime(Int)
      case removedFavoritePrime(Int)
    }
  }

  struct User {
    let id: Int
    let name: String
    let bio: String
  }
}

struct PrimeAlert: Identifiable {
  let prime: Int
  var id: Int { self.prime }
}

这个类符合ObservableObject协议,因此视图可以被自动通知发生了更改,视图需要重新渲染。我们还让所有应该参与这个变更通知过程的字段使用@Published,这多亏了一些Swift运行时的魔力。

countfavoritePrimes的数组是我们希望在应用程序中跨屏幕持久化的核心数据。我们后来添加了一些额外的状态,只是为了探索架构中需要解决的其他类型的问题。所以我们添加了一个登录user和activity feed,尽管我们实际上并不使用这些信息。

这个类就像是可组合架构的Store的一个不那么固执己见的、更特别的版本,它也符合ObservableObject,并且对于它的整个状态只有一个@Published字段。

接下来是ContentView,它是应用程序的根视图,它简单地显示了在应用程序中可以做的两件事:

struct ContentView: View {
  @ObservedObject var state: AppState

  var body: some View {
    NavigationView {
      List {
        NavigationLink(destination: CounterView(state: self.state)) {
          Text("Counter demo")
        }
        NavigationLink(
          destination: FavoritePrimesView(
            favoritePrimes: self.$state.favoritePrimes,
            activityFeed: self.$state.activityFeed
          )
        ) {
          Text("Favorite primes")
        }
      }
      .navigationBarTitle("State management")
    }
  }
}

CounterView或者FavoritePrimesView。这些视图被包装在一个NavigationView中,这样我们就可以向下深入子屏幕。在导航视图的内部是一个列表,以便我们可以很容易地显示几个堆叠在一起的按钮。然后在列表中是一些NavigationLinks,它允许我们深入到子屏幕。

让我们从这两个屏幕中比较简单的FavoritePrimesView开始。首先注意我们如何创建这个视图:

FavoritePrimesView(
  favoritePrimes: self.$state.favoritePrimes,
  activityFeed: self.$state.activityFeed
)

这个奇怪的self.$state语法允许我们获取应用状态的底层可观察对象,然后进一步链接favoritePrimes,让我们可以从observable object获得读写绑定。通过向下传递绑定到FavoritePrimesView,我们允许该视图对这些值进行更改,并让这些变化往回传播。

如果我们只传递原始值…

FavoritePrimesView(
  favoritePrimes: self.state.favoritePrimes,
  activityFeed: self.state.activityFeed
)

然后FavoritePrimesView就不能改变这些值,也不能让其他人看到这些变化。

往下滚动一点,我们会发现FavoritePrimesView的实现:

struct FavoritePrimesView: View {
  @Binding var favoritePrimes: [Int]
  @Binding var activityFeed: [AppState.Activity]

  var body: some View {
    List {
      ForEach(self.favoritePrimes, id: \.self) { prime in
        Text("\(prime)")
      }
      .onDelete { indexSet in
        for index in indexSet {
          let prime = self.favoritePrimes[index]
          self.favoritePrimes.remove(at: index)
          self.activityFeed.append(
            .init(timestamp: Date(), type: .removedFavoritePrime(prime))
          )
        }
      }
    }
    .navigationBarTitle(Text("Favorite Primes"))
    .navigationBarItems(
      trailing: HStack {
        Button("Save", action: self.saveFavoritePrimes)
        Button("Load", action: self.loadFavoritePrimes)
      }
    )
  }

  func saveFavoritePrimes() {
    let data = try! JSONEncoder().encode(self.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)
  }

  func loadFavoritePrimes() {
    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 }
    self.favoritePrimes = favoritePrimes
  }
}

注意,这个结构需要两个绑定才能初始化,但我们使用的是@Binding属性包装器。这允许我们将这些字段视为正常值,而实际上,它实际上是使用绑定机制,以便在值更改时重新渲染UI。

body是一个List,其中嵌套了一个ForEach,它允许我们为集合中的每个项呈现一行。我们还有这个onDelete操作,它允许我们在一行发生删除操作时执行一些代码。此外,我们还添加了一些导航栏项目来保存“Save”和“Load”按钮,它们各自的操作调用了我们在这个视图上拥有的一些副作用方法。

接下来,让我们向上滚动一点来查看CounterView:

struct CounterView: View {
  @ObservedObject var state: AppState
  @State var isPrimeModalShown: Bool = false
  @State var alertNthPrime: PrimeAlert?
  @State var isNthPrimeButtonDisabled = false

  var body: some View {
    VStack {
      HStack {
        Button(action: { self.state.count -= 1 }) {
          Text("-")
        }
        Text("\(self.state.count)")
        Button(action: { self.state.count += 1 }) {
          Text("+")
        }
      }
      Button(action: { self.isPrimeModalShown = true }) {
        Text("Is this prime?")
      }
      Button(action: self.nthPrimeButtonAction) {
        Text("What is the \(ordinal(self.state.count)) prime?")
      }
      .disabled(self.isNthPrimeButtonDisabled)
    }
    .font(.title)
    .navigationBarTitle("Counter demo")
    .sheet(isPresented: self.$isPrimeModalShown) {
      IsPrimeModalView(
        activityFeed: self.$state.activityFeed,
        count: self.state.count,
        favoritePrimes: self.$state.favoritePrimes
      )
    }
    .alert(item: self.$alertNthPrime) { alert in
      Alert(
        title: Text("The \(ordinal(self.state.count)) prime is \(alert.prime)"),
        dismissButton: .default(Text("Ok"))
      )
    }
  }

  func nthPrimeButtonAction() {
    self.isNthPrimeButtonDisabled = true
    nthPrime(self.state.count) { prime in
      self.alertNthPrime = prime.map(PrimeAlert.init(prime:))
      self.isNthPrimeButtonDisabled = false
    }
  }
}

绝对是我们应用程序中最大也是最复杂的视图。首先,请注意,它将所有的应用程序状态作为一个观察对象:

@ObservedObject var state: AppState

这是因为它需要访问几乎所有的状态来完成它的工作。

它也有所有这些额外的字段:

@State var isPrimeModalShown: Bool = false
@State var alertNthPrime: PrimeAlert?
@State var isNthPrimeButtonDisabled = false

这是只有这个视图关心的局部状态。在创建这个视图时,这些值不需要向下传递,它有一个合理的默认值,可以从它开始。

视图本身的body有很多内容。我们使用HStack让视图的主要部分相互堆叠,这包括计数器UI,“Is this prime?”按钮,以及“What is the nth prime?””按钮。我们还提供了显示alertsmodals的逻辑,它们都使用$语法来访问为相应的@State字段提供动力的Binding

最后我们有IsPrimeModalView:

struct IsPrimeModalView: View {
  @Binding var activityFeed: [AppState.Activity]
  let count: Int
  @Binding var favoritePrimes: [Int]

  var body: some View {
    VStack {
      if isPrime(self.count) {
        Text("\(self.count) is prime 🎉")
        if self.favoritePrimes.contains(self.count) {
          Button(action: {
            self.favoritePrimes.removeAll(where: { $0 == self.count })
            self.activityFeed.append(
              .init(timestamp: Date(), type: .removedFavoritePrime(self.count))
            )
          }) {
            Text("Remove from favorite primes")
          }
        } else {
          Button(action: {
            self.favoritePrimes.append(self.count)
            self.activityFeed.append(
              .init(timestamp: Date(), type: .addedFavoritePrime(self.count))
            )
          }) {
            Text("Save to favorite primes")
          }
        }
      } else {
        Text("\(self.count) is not prime :(")
      }
    }
  }
}

这个视图接受两个@Bindings,因为这表示这个视图希望能够突变,并让更改传播到父视图,它接受一个不可变值,因为这是在这个视图中不会更改的数据。

这个视图中的其他一切都是相当标准的,尽管它确实包含了相当多的逻辑和细微差别。


3. Testing vanilla SwiftUI

这就是我们上次构建的应用程序的基础知识。它的设计和架构完全基于苹果提供给我们的文档(包括在线和WWDC视频),它非常简单。

相比之下,我们在过去的几个星期里构建的架构有很多关于如何构建事物的观点,这是SwiftUI单独做不到的。它要求我们不再在我们的state中散布突变。相反,我们将用户可以接受的所有操作描述为enum,并创建一个reducer来描述给定用户操作的状态应该如何变化。然后,在视图中,我们只允许将操作发送到store,而不允许执行突变。最重要的是,这些操作非常简单地描述了用户做了什么,而不是我们期望在操作发生后发生什么。它们被描述为saveButtonTapped或incrButtonTapped,而不是fetchNthPrime或incrementCount

正如我们在前3集中看到的,这种设置使编写测试变得非常容易。我们花了一点时间来准备架构,但一旦架构就绪,编写测试就变得很简单了,它们允许我们测试应用程序逻辑的非常深入的方面。

所以问题是:测试一个普通的SwiftUI会是什么样子? 通过使用普通的SwiftUI,而不是在上面添加额外的架构层,我们显然节省了很多工作,但是我们还有能力进行测试吗?

不幸的是,如果我们保持SwiftUI的使用尽可能简单的话,我们并没有太多可以直接测试的东西。让我们试着编写一些测试来了解原因。


4. Testing the prime modal

让我们从为IsPrimeModalView编写一些测试开始,它具有允许我们从收藏列表中保存和删除质数的基本功能。让我们跳转到我们的测试文件,并添加一个测试:

import XCTest
@testable import VanillaPrimeTime

class VanillaPrimeTimeTests: XCTestCase {
  func testIsPrimeModalView() {
  }
}

那么,测试视图需要什么呢? 好吧,我们只有实际的视图在我们的支配下,没有其他辅助的物体与我们交互。让我们试着创建一个:

let view = IsPrimeModalView(
  activityFeed: <#Binding<[AppState.Activity]>#>,
  count: <#Int#>,
  favoritePrimes: <#Binding<[Int]>#>
)

看起来我们需要提供两个绑定,一个用于活动提要,一个用于最喜欢的质数,以及一个整数。当我们在SwiftUI视图的上下文中创建这个视图时,派生这些绑定真的很容易,因为我们有一个可观察对象,我们可以这样做:

IsPrimeModalView(
  activityFeed: self.$state.activityFeed,
  count: self.state.count,
  favoritePrimes: self.$state.favoritePrimes
)

然而,我们在XCTest中没有任何SwiftUI机制可以使用,所以我们必须自己重新创建它。绑定上唯一有用的初始化器是这个:

Binding(
  get: <#() -> _#>,
  set: <#(_) -> Void#>
)

这允许我们提供自己的getter和setter

我们如何使用它来创建活动提要绑定呢?

let activityFeed = Binding(
  get: {  },
  set: { newValue in }
)

在这些闭包中我们要获取和设置什么? 我们需要在外部保留一些额外的可变状态以便在内部使用:

var _activityFeed: [AppState.Activity] = []
let activityFeed = Binding(
  get: { _activityFeed },
  set: { newValue in _activityFeed = newValue }
)

这个dance可能只是@Binding属性包装器的简化版本,但不幸的是,我们不能在这个范围内使用属性包装器:

@Binding var _activityFeed: [AppState.Activity] = []

🛑 Property wrappers are not yet supported on local properties

而且,我们甚至不能将它用作测试用例的实例变量:

class FavoritePrimesTests: XCTestCase {
  @Binding var _activityFeed: [AppState.Activity] = []

🛑 Argument labels ‘(wrappedValue:)’ do not match any available overloads

这是因为@Binding属性包装器不像@State那样允许使用底层值进行初始化。我们也不能去掉初始值:

class FavoritePrimesTests: XCTestCase {
  @Binding var _activityFeed: [AppState.Activity]

🛑 Class ‘FavoritePrimesTests’ has no initializers

因为我们需要提供一个初始化器,而且我们不控制XCTestCase对象的初始化。这是XCTest框架和Xcode为我们处理的。

所以看起来我们别无选择,只能直接创建绑定,而不是使用任何SwiftUI的华丽属性包装器。幸运的是,我们可以做一件小事来清理当前创建绑定的两步流程。我们可以提供自己的初始化器来隐藏这个局部可变值:

extension Binding {
  init(initialValue: Value) {
    var value = initialValue
    self.init(get: { value }, set: { value = $0 })
  }
}

现在我们可以简单地做:

let activityFeed = Binding<[AppState.Activity]>(initialValue: [])

我们甚至可以内联它:

let view = IsPrimeModalView(
  activityFeed: Binding<[AppState.Activity]>(initialValue: []),
  count: 2,
  favoritePrimes: Binding<[Int]>(initialValue: [2, 3, 5])
)

这就好多了,如果我们想要从视图中获取值,我们可以简单地这样做:

view.activityFeed
view.favoritePrimes

唷,好吧,我们仍然没有编写任何测试!我们只探讨了在测试用例中创建一个接受绑定的SwiftUI视图的意义。

那么,需要测试什么呢?好吧,在这个视图中,关于当前计数是否为质数,以及这个质数是否在我们喜欢的范围内,有大量的逻辑:

var body: some View {
  VStack {
    if isPrime(self.count) {
      Text("\(self.count) is prime 🎉")
      if self.favoritePrimes.contains(self.count) {
        Button(action: { … }) {
          Text("Remove from favorite primes")
        }
      } else {
        Button(action: { … }) {
          Text("Save to favorite primes")
        }
      }
    } else {
      Text("\(self.count) is not prime :(")
    }
  }
}

然而,所有这些逻辑都被困在我们的body属性中,没有任何特定领域:

view.body.

我们只看到SwiftUI api在那里修改这个视图。无法访问这个视图中的子视图这样我们就无法断言发生了什么。本质上,所有发生在这些body属性内部的事情都应该被认为是一个黑盒。

所以,如果我们要测试这里的逻辑,我们需要把它从其他地方提取出来。我们可以做的一件事是,将保存和删除最喜欢的质数的逻辑移动到视图的方法:

Button(action: self.removeFavoritePrime) {
}
// …
Button(action: self.saveFavoritePrime) {
}
// …
func removeFavoritePrime() {
  self.favoritePrimes.removeAll(where: { $0 == self.count })
  self.activityFeed.append(.init(timestamp: Date(), type: .removedFavoritePrime(self.count)))
}

func saveFavoritePrime() {
  self.favoritePrimes.append(self.count)
  self.activityFeed.append(.init(timestamp: Date(), type: .addedFavoritePrime(self.count)))
}

现在我们终于准备好写我们的第一个断言:

view.removeFavoritePrime()

XCTAssertEqual(view.favoritePrimes, [3, 5])

view.saveFavoritePrime()

XCTAssertEqual(view.favoritePrimes, [3, 5, 2])

这些断言都通过了!我们可以简单地调用视图上的几个方法来改变状态,然后断言状态以我们期望的方式改变了。

为了确保这些测试确实在运行,让我们演示一个失败:

view.saveFavoritePrime()
XCTAssertEqual(favoritePrimes.wrappedValue, [3, 5])

🛑 XCTAssertEqual failed: (“[3, 5, 2]”) is not equal to (“[3, 5]”)

我们在这个视图中测试一些逻辑。然而,与我们测试架构的方式相比,我们失去了一些东西。当我们在前几集首次测试这一功能时,我们能够全面检查状态中的每个领域:

func testRemoveFavoritesPrimesTapped() {
  var state = (count: 3, favoritePrimes: [3, 5])
  let effects = primeModalReducer(state: &state, action: .removeFavoritePrimeTapped)

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

尤其是这一行…

let (count, favoritePrimes) = state

如果我们在这个状态中添加更多的字段,将会失败。这对于确保我们继续断言整个state来说是非常好的,这样我们就不会不小心错过正在发生的事情。 例如,如果我在savefaviteprime方法中做了一些愚蠢的事情,像这样:

func removeFavoritePrime() {
  self.favoritePrimes.removeAll(where: { $0 == self.count })
  self.activityFeed.append(.init(timestamp: Current.date(), type: .removedFavoritePrime(self.count)))
  self.activityFeed = []
}

我们的测试还是会通过的。如果我们只测试我们认为会改变的东西,那么我们就会错过意外改变的不相关的状态。

我们可以做一件事来重新获得详尽的断言,但它带有一些样板。我们需要引入一个新的结构体,它只保存我们所关心的AppState的状态,并将其用于绑定:

struct IsPrimeModalView: View {

  struct State {
    var activityFeed: [AppState.Activity]
    let count: Int
    var favoritePrimes: [Int]
  }
  @Binding var state: State

//  @Binding var activityFeed: [AppState.Activity]
//  let count: Int
//  @Binding var favoritePrimes: [Int]

然后我们需要在AppState上创建一个getter/setter属性来派生这个子状态:

extension AppState {
  var isPrimeModalViewState: IsPrimeModalView.State {
    get {
      IsPrimeModalView.State(
        activityFeed: self.activityFeed,
        count: self.count,
        favoritePrimes: self.favoritePrimes
      )
    }
    set {
      (
        self.activityFeed,
        self.count,
        self.favoritePrimes
      ) = (
        newValue.activityFeed,
        newValue.count,
        newValue.favoritePrimes
      )
    }
  }
}

这将允许我们像这样创建主模态视图:

IsPrimeModalView(
  state: self.$state.isPrimeModalViewState
)

值得一提的是,我们必须编写的这一小段粘合代码基本上与我们使用我们的架构所需要编写的代码相同:

extension AppState {
  var isPrimeModalViewState: IsPrimeModalView.State {
    get { … }
    set { … }
  }
}

我们需要在我们的架构中做几次这样的事情。我们这样做是为了写简化程序只处理局部状态和动作然后把它们拉回处理全局状态和动作。因此,我们在这里看到的是,即使我们想以最简单、最直接的方式使用SwiftUI,有时我们也无法避开编写一些额外的模板。在这里,如果我们想在SwiftUI中挤出一点额外的可测试性,我们就不得不写这些额外的代码。

让我们退出这个重构。我们只是想展示获得所有可能的路径,而不想更新所有测试。


5. Testing the favorite primes view

虽然原始模态视图不是超可测试的开箱即用,但我们能够通过一些辅助方法获得可测试性。虽然接受绑定的视图是可测试的,但我们知道彻底测试需要将视图的状态捆绑在一个单一的、可测试的绑定中,这需要大量额外的工作。

让我们看看测试其他视图需要什么。

我们可以从快速查看FavoritePrimesView开始。它类似于IsPrimeModal,它只需要几个绑定值就可以完成它的工作:

struct FavoritePrimesView: View {
  @Binding var favoritePrimes: [Int]
  @Binding var activityFeed: [AppState.Activity]

因此,基于我们对prime modal的工作,我们应该能够在测试中非常容易地实例化其中一个视图。如果我们环顾四周,看看哪些是可测试的,我们会看到在这个onDelete闭包中填充了一些逻辑:

.onDelete { indexSet in
  for index in indexSet {
    let prime = self.favoritePrimes[index]
    self.favoritePrimes.remove(at: index)
    self.activityFeed.append(
      .init(timestamp: Date(), type: .removedFavoritePrime(prime))
    )
  }
}

如果我们希望这个逻辑是可测试的,我们必须将它提取到一个方法中,以便可以直接调用它。

我们在视图上也有这些保存和加载方法:

func saveFavoritePrimes() {
  // …
}

func loadFavoritePrimes() {
  // …
}

它们也可以用同样的方法进行测试,假设我们能控制在这里发生的副作用。

我们不打算为这个视图编写任何测试因为测试它应该和测试FavoritePrimesView差不多。

然而,我们有必要重复我们所学到的经验教训。首先,在视图体中所做的一切都是不可测试的。我们应该把它当成一个我们根本无法进入的黑盒。所以我们必须做额外的工作,试图把工作从body转移到可以被测试的方法中。

其次,如果我们想要加强我们的测试,使它们完全覆盖视图的领域模型,那么我们似乎别无选择,只能引入中间结构,以便我们可以一次断言所有的结构。


6. Testing the counter view: @ObservedObject

现在让我们看看为我们的CounterView编写测试需要什么。这里我们遇到了一些我们在前两个视图中没有看到的东西:

struct CounterView: View {
  @ObservedObject var state: AppState
  @State var isPrimeModalShown: Bool = false
  @State var alertNthPrime: PrimeAlert?
  @State var isNthPrimeButtonDisabled = false

该视图的一些状态表示为@ObservedObject,其他状态表示为@State。我们还没有为这两种状态编写测试。@ObservedObject是最容易测试的部分,它甚至比测试@Bindings更容易。然而,为了让任何东西都是可测试的,我们必须确保将状态突变移出视图body,并转移到专用的方法中。让我们用递增和递减按钮来做:

struct CounterView: View {
  // …

  func incrementCount() {
    self.state.count += 1
  }

  func decrementCount() {
    self.state.count -= 1
  }

  var body: some View {
    // …
    Button(action: self.decrementCount) {
      Text("-")
    }
    // …
    Button(action: self.incrementCount) {
      Text("+")
    }
    // …
  }
}

然后为了测试这个逻辑,我们可以构造一个视图,调用那些端点,并断言状态以我们期望的方式改变了。除了不构造bindings,我们可以直接传递应用程序状态:

func testCounterView() {
  let view = CounterView(state: AppState())

  view.incrementCount()

  XCTAssertEqual(view.state, AppState(count: 1))
}

🛑 Argument passed to call that takes no arguments

不幸的是,我们不能这样做。作为ObservableObjectAppState必须是一个类,而且类没有一个默认的成员类初始化器,我们可以调用它。 我们可以创建自己的初始化器来访问这些helpers,但我们甚至不能让Xcode为我们生成一个成员逐一初始化器,因为它们不能很好地与默认属性配合。

我们可以做的一件事是创造一个新的价值,并使其符合我们的期望。

func testCounterView() {
  let view = CounterView(state: AppState())

  view.incrementCount()

  let expected = AppState()
  expected.count = 1
  XCTAssertEqual(view.state, expected)
}

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

甚至这也不能工作,因为AppState不符合Equatable。不幸的是,我们甚至不能在AppState上自动合成均衡性,因为它是一个类,这意味着我们必须维护我们自己的自定义一致性,这将打破,我们需要记得在我们的状态添加或删除字段时更新它。

所以这些都不对,真的。唯一简单的步骤是取出状态计数并直接测试它。

func testCounterView() {
  let view = CounterView(state: AppState())

  view.incrementCount()

  XCTAssertEqual(view.state.count, 1)
}

它通过了,但请记住,我们在测试中已经失去了强大的穷举性。 如果incrementCount开始对AppState做其他事情,我们就没有覆盖来控制它。

无论如何,让我们通过更多地练习它的方法来充实这个测试。

func testCounterView() {
  let view = CounterView(state: AppState())

  view.incrementCount()

  XCTAssertEqual(view.state.count, 1)

  view.incrementCount()

  XCTAssertEqual(view.state.count, 2)

  view.decrementCount()

  XCTAssertEqual(view.state.count, 1)
}

所以这个测试写起来更容易一些因为可观察对象比绑定更容易创建,但不幸的是,我们遇到了其他麻烦,比如没有简单的方法来创建基于成员的初始化器,也没有简单的方法使可观察对象相等,这意味着我们被迫测试应用状态的部分而不是全部。


7. Testing the counter view: @State

我们还希望能够测试这些@State字段,因为有一些细微的逻辑指导它们的行为。例如,当你点击“第n个质数是多少?”按钮,我们禁用第n个主要按钮,然后只有当我们从API得到响应时,我们才重新启用它。当我们从API获得成功响应时,我们也只显示警报:

func nthPrimeButtonAction() {
  self.isNthPrimeButtonDisabled = true
  nthPrime(self.state.count) { prime in
    self.alertNthPrime = prime.map(PrimeAlert.init(prime:))
    self.isNthPrimeButtonDisabled = false
  }
}

w们应该能够编写一些断言,即第n个按钮开始是启用的,然后当第n个按钮被按下时切换到禁用。

XCTAssertEqual(view.isNthPrimeButtonDisabled, false)

view.nthPrimeButtonAction()

XCTAssertEqual(view.isNthPrimeButtonDisabled, true)

And then we can run our test:

🛑 XCTAssertEqual failed: (“false”) is not equal to (“true”)

那似乎不太对。实际上,nthPrimeButtonAction方法做的第一件事就是将这个布尔值翻转为true。让我们通过在状态发生变化之前和之后添加一些print语句来尝试了解这个方法内部发生了什么:

func nthPrimeButtonAction() {
  print(self.isNthPrimeButtonDisabled)
  self.isNthPrimeButtonDisabled = true
  print(self.isNthPrimeButtonDisabled)

当我们运行这个测试时,我们会看到:

false
false

这似乎很奇怪。我们直接在一行上改变这个值,然后下一行就好像什么都没发生一样。

虽然我们不知道为什么会发生这种情况,但几乎可以肯定的是,这个值存储在@State字段中,这就是SwiftUI在任何值发生变化时自动重新呈现该视图的能力。 然而,似乎无论机制的力量是什么,除非它在正确的上下文中运行,比如一个UIHostingController,否则它根本无法工作。

据我们所知,这是无法回避的。基本上,使用@State属性包装器建模的任何状态都是不可测试的。也许您并不关心测试这个逻辑,但是如果您关心,那么您别无选择,只能将它移到您的应用程序状态。

让我们快速做一下。我们可以将这些字段添加到AppState:

class AppState: ObservableObject {
  // …
  @Published var alertNthPrime: PrimeAlert? = nil
  @Published var isNthPrimeButtonDisabled = false

然后从我们的视图中删除这些字段,同时修复对这些字段的引用:

struct CounterView: View {
  // …
//  @State var alertNthPrime: PrimeAlert?
//  @State var isNthPrimeButtonDisabled = false

我们只需要在访问状态时通过state属性修复一些编译器错误。

一旦我们这么做了,我们的测试就会通过:

XCTAssertEqual(view.state.isNthPrimeButtonDisabled, false)

view.nthPrimeButtonAction()

XCTAssertEqual(view.state.isNthPrimeButtonDisabled, true)

因此,尽管@State字段不能直接测试,但我们至少可以将它们提取到应用程序状态中,使其可测试。

然而,即使这样做了,我们仍然没有恢复与我们的架构相同的测试能力。当我们用可组合架构为这个屏幕编写测试时,我们看到我们可以很容易地添加一个集成测试,也就是说,一个测试可以同时运行应用程序的多个独立部分。我们能够为嵌入在计数器逻辑中的素模态逻辑编写一个测试,只是为了确保这两个特性能够很好地结合在一起。

这是目前不可能做到的。由于主模态是在counter视图主体中呈现的,所以我们在测试中无法访问它。当它们从计数器屏幕上显示时如果用户与主模式交互会发生什么,我们不能调用前面创建的方法来模拟。

我们可能会恢复一些集成测试的外表,但这将意味着再次将逻辑移到更易于测试的地方。之前我们把逻辑从视图主体移到视图方法中,但现在这还不够,我们可能需要把逻辑直接移到应用程序状态中。但这似乎也很困难,因为我们甚至没有在主模态视图中使用app状态,我们只向下传递绑定,而不是完整的可观察对象。


8. Conclusion

我认为我们在这里看到的是,真的没有测试一个普通的SwiftUI应用程序这样的事情。似乎您总是需要做一些前期工作来解锁可测试性。

  • 至少,你需要尽可能多地将你的逻辑移出视图的body属性,要么把它放在视图的方法中,要么把它放在状态的方法中。这至少允许您调用这些方法,并断言状态已按您期望的方式更改。但是,这与我们在可组合架构中所做的非常相似。我们决定不直接在视图中执行突变,而是通过枚举描述突变,并编写reducers来实际执行突变。

  • 如果你想更进一步,你还应该考虑你使用哪些SwiftUI特性来建模你的状态。将大的状态集合中的几个字段投射到绑定中是很方便的,但是如果这样做,就失去了全面断言测试中状态如何变化的能力。如果你想恢复穷尽性你必须把这些字段捆绑到自己的结构中并在你的应用状态上创建一个计算属性来派生那个子状态。但是,这与我们在可组合架构中所做的非常相似。我们创建了小的状态结构来保存特定于视图的状态,并创建了将其插入到全局状态所需的可组合性工具,因为我们这样做了,所以我们免费获得了详尽的测试。

  • 在视图中使用@State来建模本地状态也很方便。但这是以无法验证为代价的。在调用视图上的各种方法时,我们似乎无法改变这些值。获得可测试性的唯一方法是将该状态从本地@State绑定转移到应用程序状态,这意味着转换为@Binding或@ObservedObject。再说一次,这正是我们在可组合架构中所做的。我们需要将一些@State字段移出视图,进入全局应用状态,比如警报状态和按钮禁用状态。当时我们这么做是因为控制这种状态的逻辑很微妙,我们想把它移动到我们的reducers。但后来我们显示它给我们写一些很神奇的测试的能力,包括能够发挥出一个完整的脚本的用户操作(例如开发一个按钮,运行效果,触发一个警告,并解雇警报),并确保状态改变我们如何期待。

还有很多问题等着我们去回答,还有很多事情等着我们去探索。比如:

  • 在可组合架构如何正确处理alert,模态和弹窗?
  • 我们是否可以将这个体系结构用于那些仍然在纯UIKit中构建的视图?
  • 这个体系结构的性能如何?有什么需要我们注意的吗?
  • 我们能改善建筑的人体工程学吗?我们已经做过几次了,但还有更多的事情要做。
  • 在这个体系结构中处理依赖关系的最好方法是什么?我们在我们的环境中做了一点这方面的工作,但它能得到改善吗?