Swift中的异步测试—详细指南

687 阅读8分钟

绪论

在《Swift中的单元测试》一文中,我们介绍了如何使用主要为同步行为编写的例子开始进行单元测试。然而,在你作为一个iOS开发者的旅程中,你会面临这样的场景:你的应用程序中的函数必须等待数据被获取。在这篇文章中,我们将看看如何在Swift中使用异步测试来处理这些类型的场景。我们将为那些在可变时间后返回数据的函数编写测试。

在这篇关于异步测试的介绍性文章中,我们将涵盖以下内容:

  • 普通单元测试的问题
  • 如何编写异步测试
  • 使用XCTestExpectation
  • 嘲弄服务
  • 测试异步/await代码

所以让我们开始吧!

普通测试的问题

当我们写一个单元测试时,我们已经定义了输入和输出,在方法返回预期输出后,测试成功通过。然而,在异步代码的情况下,它可能会测试执行完毕返回预期的输出,从而导致测试的不稳定

让我们假设我们正在使用网络请求来获取最高的塔的列表。我们想测试并确保我们返回的列表不是空的:

func testTallestTowersShouldNotBeEmpty() {
  TowersDataManager.tallestTowers { result in
    switch result {
      case .success(let towers):
        XCTAssert(towers.count > 0, "tallestTowers list is empty")
      case .failure(let error):
        XCTFail("Expected towers list, but failed \(error).")
    }
  }
}

完成处理程序的执行发生在测试完成后,因为它是一个异步工作的网络调用。我们不知道我们的断言是否失败,因为测试总是通过。我们可以通过在XCTAssert行上放置一个突破点来验证这一点,该行没有被执行。

异步测试:如何编写异步测试

为了克服上面提到的挑战,我们要等待数据,然后进行测试;或者模拟/模仿方法,作为同步代码工作。对于前者,苹果在XCTest框架中为我们提供了一个名为XCTestExpectation 的类。对于后者,我们可以为我们的网络类创建一个模拟服务。

使用XCTestExpectation

为了测试异步代码,我们使用XCTestExpectation 类并等待预期结果。工作流程是创建一个期望值,然后当异步任务成功完成时,我们履行该期望值。我们将等待一个特定的时间来实现该期望。如果在实现期望之前超过了超时时间,我们会得到一个失败的错误。

要创建一个新的期望,我们需要提供一个描述:

let expectation = XCTestExpectation(description: #function)

在接收到数据后,调用fulfill() 方法,将期望值标记为被满足:

expectation.fulfill()

关闭后,为特定的期望调用wait(for:timeout:) 方法,并设定所需的超时时间(秒):

wait(for: [expectation], timeout: 3.0)

以上面的例子为例,下面是更新后的代码:

func testTallestTowersShouldNotBeEmptyy() {
  let expectation = XCTestExpectation(description: #function)

  var tallestTowers: [Tower] = []

  TowersDataManager.tallestTowers { result in
    switch result {
      case .success(let towers):
        tallestTowers = towers

        expectation.fulfill()

      case .failure(let error):
        XCTFail("Expected towers list, but failed \(error).")
    }
  }

  wait(for: [expectation], timeout: 3.0)

  XCTAssert(tallestTowers.count > 0, "tallestTowers list is empty")
}

tallestTowers 方法使用URLSession的dataTask 方法,它创建了一个后台数据任务。它在一个后台线程中从指定的URL中获取JSON数据。当任务在后台运行时,主线程会等待期望的实现。

在收到一个成功的结果后,我们用从我们的方法中解码的值更新tallestTowers 。然后,我们实现期望,表明任务已经完成。如果任务在指定的超时前完成,测试就会转到断言,并试图断言tallestTowers 的计数大于零。

通过给出一个有意义的函数名称,我们得到一个清晰的失败描述:

超过了3秒的超时时间,有未完成的期望"testTallestTowersShouldNotBeEmpty()"。

这个异步测试可能有三种失败方式:

  1. 如果它在抛出错误的情况下触发XCTFail 方法
  2. 如果在完成处理程序被调用之前,由于网络或其他问题,超时过期
  3. 如果数据被成功返回,但列表是空的

这种方法可以帮助我们覆盖我们在异步测试中需要的情况!

XCTestExpectation 还为我们提供了一些实例属性,以便对我们的测试进行更多的控制。其中之一就是。

**isInverted**:对于你想确保一个给定的情况发生的情况。

例如,想象一下,你正在向你的用户提供高级内容,并希望确保除非用户订阅,否则高级内容数据不会被获取。我们创建一个期望,并将其isInverted 属性设置为true。如果内容在指定时间内被获取,测试将失败。当意外情况发生时,即在用户没有订阅的情况下,在给定的超时时间内没有请求发生时,该期望将被满足。

let expectation = expectation(description: #function)
expectation.isInverted = true

针对不同的使用情况,还有四个子类XCTestExpectation

嘲弄服务

虽然我们可以使用期望值来测试异步代码,但考虑到我们依赖于网络连接,这可能会吞噬我们的整个延迟时间,所以它很耗时和缓慢。将此乘以每天10次的测试,将大大增加你的CI/CD服务的构建时间。

另一种运行异步测试的方法是模拟网络代码,避免实际的网络请求。我们可以模仿异步测试的行为,但有预定的输入和输出。

我们将创建两个版本的请求,一个是实际的,一个是模拟的。为此,我们将单独列出在这两个版本中发现的方法,即执行请求的方法:

protocol TowersNetworkSessionProtocol {
  func execute(url: URL?, completion: @escaping (Result<Data, Error>) -> ())
}

我们创建两个符合TowersNetworkSessionProtocol 的类:

class TowersNetworkSession: TowersNetworkSessionProtocol {
  func execute(url: URL?, completion: @escaping (Result<Data, Error>) -> ()) {
    guard let url = url else {
      completion(.failure(TowerNetworkError.invalidURL))
      return
    }

    URLSession.shared.dataTask(with: url) { data, response, error in
      if let error = error {
        completion(.failure(error))
      }

      if let data = data {
        completion(.success(data))
      } else {
        completion(.failure(TowerNetworkError.missingData))
      }
    }
    .resume()
  }
}

这个网络会话执行的是真正的网络请求。现在,我们将创建另一个,模仿这个行为:

class TowersMockNetworkSession: TowersNetworkSessionProtocol {
  var completion: Result<Data, Error>?

  func execute(url: URL?, completion: @escaping (Result<Data, Error>) -> ()) {
    guard url != nil else {
      completion(.failure(TowerNetworkError.invalidURL))
      return
    }

    self.completion.map(completion)
  }
}

这个模拟类包含一个名为completion 的实例属性,我们可以在测试成功响应和错误时设置该属性。

现在,主类在其初始化器中接受了TowersNetworkSessionProtocol

class TowersDataManager {
  private let session: TowersNetworkSessionProtocol

  init(session: TowersNetworkSessionProtocol) {
    self.session = session
  }

  func tallestTowers(completion: @escaping (Result<[Tower], Error>) -> ()) {
    let url = URL(string: "https://tower.free.beeceptor.com/tallest")

    session.execute(url: url) { result in
      switch result {
        case .success(let data):
          let result = Result(catching: {
            try JSONDecoder().decode([Tower].self, from: data)
          })
          completion(result)
        case .failure(let error):
          completion(.failure(error))
      }
    }
  }
}

我们已经设置了模拟环境,是时候测试一下了!

func testTallestTowersData() throws {
  // 1
  var result: Result<[Tower], Error>?

  // 2
  let tallestTowers: [Tower] = Tower.tallestTowers
  let response = try JSONEncoder().encode(tallestTowers)

  // 3
  let session = TowersMockNetworkSession()
  session.completion = .success(response)

  // 4
  let dataManager = TowersDataManager(session: session)

  // 5
  dataManager.tallestTowers {
    result = $0
  }

  // 6
  XCTAssertEqual(try result?.get(), tallestTowers)
}

在写出我们的代码时,我们:

  1. 声明一个结果类型,我们将用完成处理程序的结果来填充
  2. 使用硬编码数据并将其编码为JSON格式
  3. 创建一个模拟类的对象,并设置完成变量,用期望的响应来注册成功
  4. 实例化一个数据管理器类,将模拟会话作为参数
  5. 调用tallestTowers 方法。注意,这并不影响服务器;它只是传递完成处理程序
  6. 断言检查获得的结果和硬编码的值是否相同。如果JSON被正确解析,我们的测试应该通过

我们还可以测试该方法是否正确地抛出了一个丢失数据的错误,这样我们就可以在应用程序中处理它。让我们创建另一个测试来做到这一点:

func testTallestTowersForMissingData() {
  var result: Result<[Tower], Error>?
  
  let session = TowersMockNetworkSession()
  session.completion = .failure(TowerNetworkError.missingData)
  
  let dataManager = TowersDataManager(session: session)
  
  dataManager.tallestTowers {
    result = $0
  }
  
  XCTAssertThrowsError(try result?.get()) { error in
    XCTAssertEqual(error as? TowerNetworkError, .missingData)
  }
}

我们将会话的完成设置为失败,错误为数据丢失。使用XCTAssertThrowsError ,我们在获取结果的时候得到一个错误,并断言它等于missingData 的错误。

这样,我们就可以测试我们的网络管理器是否像预期的那样工作!

扩展异步测试:测试async/await代码

今年,苹果在 Swift 5.5 中引入了 async/await 模式。它是一种以同步方式编写异步代码的方法。

使用之前的例子,让我们重新写一下我们的测试。首先,在TowersNetworkSessionProtocol 中添加一个方法:

protocol TowersNetworkSessionProtocol {
  func execute(url: URL?, completion: @escaping (Result<Data, Error>) -> ())
  func execute(url: URL?) async throws -> Data
}

然后,在其他两个类中添加需求:

class TowersNetworkSession: TowersNetworkSessionProtocol {
  func execute(url: URL?) async throws -> Data {
    guard let url = url else {
      throw TowerNetworkError.invalidURL
    }

    let (data, _) = try await URLSession.shared.data(from: url)
    return data
  }
}

class TowersMockNetworkSession: TowersNetworkSessionProtocol {
  func execute(url: URL?) async throws -> Data {
    guard url != nil else {
      throw TowerNetworkError.invalidURL
    }

    guard let data = try completion?.get() else {
      throw TowerNetworkError.missingData
    }
    return data
  }
}

新的async/await语法使我们更容易在同步流程中表达异步代码。为了测试它,我们不需要使用期望值:

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
func testTallestTowersShouldNotBeEmptyAsync() async throws {
  let session = TowersNetworkSession()
  let dataManager = TowersDataManager(session: session)

  do {
    let tallestTowers = try await dataManager.tallestTowers()
    XCTAssertTrue(tallestTowers.count > 0, "tallestTowers array is empty")
  } catch {
    XCTFail("Expected towers data, but failed \(error).")
  }
}

你可以看到,测试async/await语法的代码与编写标准单元测试相当,唯一的区别是使用async和await关键字。

前面的测试击中了实际的网络调用,所以我们也要为这个异步方法写一个模拟测试:

func testTallestTowersDataAsync() async throws {
  let tallestTowers = Tower.tallestTowers
  let response = try JSONEncoder().encode(tallestTowers)

  let session = TowersMockNetworkSession()
  session.completion = .success(response)

  let dataManager = TowersDataManager(session: session)

  let result = try await dataManager.tallestTowers()

  XCTAssertEqual(result, tallestTowers)
}

这段代码很容易理解。我们采取硬编码的数据,并将其设置为网络会话的完成。我们异步地调用tallestTowers() 方法,并等待结果。然后,我们将结果与硬编码的数据进行比较。

为了测试缺失的数据,我们可以使用以下方法:

func testTallestTowersForMissingDataAsync() async {
  let session = TowersMockNetworkSession()
  session.completion = .failure(TowerNetworkError.missingData)

  let dataManager = TowersDataManager(session: session)

  do {
    _ = try await dataManager.tallestTowers()
    XCTFail("Expected to throw missing data error, but succeeded.")
  } catch {
    XCTAssertEqual(error as? TowerNetworkError, .missingData)
  }
}

记住,我们希望测试失败并抛出一个缺失数据的错误。在这种情况下,如果测试成功就会失败。

结论

异步测试比编写琐碎的单元测试更难。虽然本文主要关注网络请求的异步测试,但同样的逻辑也可以应用于测试委托方法、文件操作、通知等。

还请阅读: