绪论
在《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()"。
这个异步测试可能有三种失败方式:
- 如果它在抛出错误的情况下触发
XCTFail方法 - 如果在完成处理程序被调用之前,由于网络或其他问题,超时过期
- 如果数据被成功返回,但列表是空的
这种方法可以帮助我们覆盖我们在异步测试中需要的情况!
XCTestExpectation 还为我们提供了一些实例属性,以便对我们的测试进行更多的控制。其中之一就是。
**isInverted**:对于你想确保一个给定的情况不发生的情况。
例如,想象一下,你正在向你的用户提供高级内容,并希望确保除非用户订阅,否则高级内容数据不会被获取。我们创建一个期望,并将其isInverted 属性设置为true。如果内容在指定时间内被获取,测试将失败。当意外情况发生时,即在用户没有订阅的情况下,在给定的超时时间内没有请求发生时,该期望将被满足。
let expectation = expectation(description: #function)
expectation.isInverted = true
针对不同的使用情况,还有四个子类XCTestExpectation :
- XCTNSNotificationExpectation
- XCTDarwinNotificationExpectation
- XCTNSPredicateExpectation
- XCTKVOExpectation
嘲弄服务
虽然我们可以使用期望值来测试异步代码,但考虑到我们依赖于网络连接,这可能会吞噬我们的整个延迟时间,所以它很耗时和缓慢。将此乘以每天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)
}
在写出我们的代码时,我们:
- 声明一个结果类型,我们将用完成处理程序的结果来填充
- 使用硬编码数据并将其编码为JSON格式
- 创建一个模拟类的对象,并设置完成变量,用期望的响应来注册成功
- 实例化一个数据管理器类,将模拟会话作为参数
- 调用
tallestTowers方法。注意,这并不影响服务器;它只是传递完成处理程序 - 断言检查获得的结果和硬编码的值是否相同。如果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)
}
}
记住,我们希望测试失败并抛出一个缺失数据的错误。在这种情况下,如果测试成功就会失败。
结论
异步测试比编写琐碎的单元测试更难。虽然本文主要关注网络请求的异步测试,但同样的逻辑也可以应用于测试委托方法、文件操作、通知等。