La documentación de Apple para la migración de XCTest a Swift Testing tiene un apartado que explica cómo validar comportamiento asíncrono. Allí se describe que, en la medida de lo posible, hay que preferir usar la concurrencia de Swift (i.e. async/await) para validar condiciones asíncronas. Por ejemplo, si es necesario determinar el resultado de una función de forma asíncrona, hay que usar await.
Si una función recibe un "completion-handler" (no usa await), se puede usar un continuation para hacer que el llamado sea compatible con async/await.
En el entorno de pruebas, la biblioteca de pruebas de Swift ofrece la funcionalidad llamada confirmation para hacer eventos-de-callback compatibles con concurrencia de Swift.
A continuación presento los ejemplos Antes/Después que muestra la documentación para la migración:
// Before
func testTruckEvents() async {
let soldFood = expectation(description: "…")
FoodTruck.shared.eventHandler = { event in
if case .soldFood = event {
soldFood.fulfill()
}
}
await Customer().buy(.soup)
await fulfillment(of: [soldFood])
// ...
}
// After
@Test func truckEvents() async {
await confirmation("…") { soldFood in
FoodTruck.shared.eventHandler = { event in
if case .soldFood = event {
soldFood()
}
}
await Customer().buy(.soup)
}
// ...
}
Según la documentación, un Confirmation funciona parecido a un XCTestExpectation pero NO BLOQUEAN NI SUSPENDEN AL INVOCADOR MIENTRAS ESPERAN A QUE LA CONDICIÓN SE SATISFAGA.
Esto último es un aspecto crítico en la implementación de pruebas asíncronas puesto que, a pesar de ser una optimización muy útil, si el código de producción necesita esperar un tiempo para responder, el confirmation va a terminar inmediatamente.
Por otro lado, según la firma del método, aparentemente no se puede tener un tiempo límite de espera (i.e. timeout) como sí se puede hacer con wait(for:timeout:).
Ante esta problemática, implementé la siguiente función confirmation(_:expectedCount:isolation:sourceLocation:timeoutMillis:_:):
import Testing
import Combine
import Foundation
enum ConfirmationError: Swift.Error {
case timedout
case invalidResponse
case bodyError(Swift.Error)
}
func confirmation<R>(
_ comment: Comment? = nil,
expectedCount: Int = 1,
isolation: isolated (any Actor)? = #isolation,
sourceLocation: SourceLocation = #_sourceLocation,
timeoutMillis: Int,
_ body: @escaping (@escaping (Result<R, ConfirmationError>) -> Void) throws -> Void
) async throws -> R {
var cancellable: AnyCancellable?
return try await withCheckedThrowingContinuation { continuation in
var result: R?
cancellable = Future { promise in
do {
try body(promise)
} catch {
promise(.failure(.bodyError(error)))
}
}
.timeout(.milliseconds(timeoutMillis), scheduler: DispatchQueue.main, customError: { ConfirmationError.timedout })
.sink(receiveCompletion: { completion in
if case .failure = completion {
continuation.resume(throwing: ConfirmationError.timedout)
} else if let result {
continuation.resume(returning: result)
} else {
continuation.resume(throwing: ConfirmationError.invalidResponse)
}
}, receiveValue: {
result = $0
})
}
}
Esta función se apoya en Combine para usar el operador timeout, envolviendo el closure body en un Future y pasándole la promesa. Por otro lado, también hace uso de withCheckedThrowingContinuation(isolation:function:_:) para poder ser compatible con async/await.
A continuación se muestra un cliente de ejemplo de esta función:
@Test
func timerPublish() async throws {
func normalized(_ ti: TimeInterval) -> TimeInterval {
Double(round(ti * 10) / 10)
}
let now = Date().timeIntervalSinceReferenceDate
let expected = [0.5, 1, 1.5]
let publisher = Timer
.publish(every: 0.5, on: .main, in: .common)
.autoconnect()
.prefix(3)
let results = try await confirmation(timeoutMillis: 2000) { continuation in
var results = [TimeInterval]()
publisher
.sink(receiveCompletion: { _ in
continuation(.success(results))
}, receiveValue: {
results.append(normalized($0.timeIntervalSinceReferenceDate - now))
})
.store(in: &self.subscriptions)
}
#expect(results == expected)
}
En el cliente de ejemplo anterior se tiene un timeout de 2 segundos, pero la promesa del Future se termina en 1.5 segundos, por lo que necesita quedarse esperando los 2 segundos de timeout.
Top comments (0)