wandres.dev
SWIFT TESTING · el framework moderno

Probar código asíncrono, actores y la convivencia con XCTest

Pruebas declaradas `async` que esperan directamente sin cerrojos ni temporizadores, `confirmation` como sustituto de las expectativas con espera, aislamiento de actores dentro de la suite y estrategia de migración incremental conviviendo con XCTest en el mismo objetivo de pruebas.

⏱ 19 min

Probar código concurrente con XCTest exigía un ritual: crear una expectativa, pasarla a un callback, esperar con un tiempo límite inventado y confiar en que la máquina de integración continua no fuera ese día más lenta de lo habitual. El resultado eran suites llenas de esperas de un segundo que nadie se atrevía a bajar y de fallos intermitentes que se achacaban al entorno. Swift Testing elimina la mayor parte de ese ritual por la vía más simple posible: la función de prueba puede ser async, de modo que esperar se escribe con await y el corredor suspende la tarea en lugar de bloquear un hilo. Lo que queda —comprobar que un callback se invocó, y cuántas veces— tiene su propia herramienta, y lo que no encaja todavía puede seguir viviendo en XCTest dentro del mismo objetivo mientras dure la migración.

🎯 Al terminar esta lección sabrás
  • Escribir pruebas async que esperen resultados con await sin temporizadores ni bloqueo de hilos.
  • Verificar la invocación de callbacks con confirmation, incluyendo recuentos exactos y la ausencia de llamada.
  • Controlar el aislamiento de una prueba respecto de actores y del actor principal.
  • Planificar una migración incremental desde XCTest identificando qué debe quedarse en el framework antiguo.

La prueba que espera de verdad

Basta con declarar la función async y usar await donde corresponda. No hay API de espera, no hay tiempo límite obligatorio y no hay hilo bloqueado.

@Test func elClienteDescargaYDecodifica() async throws {
    let cliente = ClienteAPI(sesion: .simulada)
    let catalogo = try await cliente.catalogo()
    #expect(catalogo.articulos.count == 12)
    #expect(catalogo.version == "2024-11")
}

Comparado con el equivalente en XCTest, desaparecen tres cosas a la vez: la expectativa creada a mano, la llamada de espera con su tiempo límite y el reparto de la comprobación entre el cuerpo de la prueba y el interior de un bloque. La aserción vuelve a estar donde está el valor, que es donde el diagnóstico sirve.

El cambio de fondo es que la prueba deja de bloquear un hilo para esperar. Bajo el modelo antiguo, cada espera ocupaba un hilo real durante todo su tiempo límite, lo que limitaba con dureza cuántas pruebas podían estar esperando a la vez; con suspensión, el coste de esperar es el de una tarea suspendida y el corredor puede tener cientos en vuelo.

Con secuencias asíncronas la iteración se escribe igual que en producción, y ahí conviene una precaución específica: recoger un número acotado de elementos en vez de consumir hasta el final, porque una fuente que no termina dejaría la prueba colgada indefinidamente.

@Test(.timeLimit(.minutes(1)))
func elSensorEmiteLecturasCrecientes() async throws {
    var lecturas: [Double] = []
    for await lectura in sensor.lecturas {
        lecturas.append(lectura)
        if lecturas.count == 5 { break }
    }
    #expect(lecturas == lecturas.sorted())
}

El trait de límite de tiempo hace aquí de red de seguridad: no mide rendimiento, solo garantiza que un fallo se manifieste como fallo y no como una suite que nunca termina.

Confirmaciones: cuándo y cuántas veces

Queda un caso que await no cubre: comprobar que cierto callback se invocó, porque ahí no hay valor que esperar sino un efecto que observar. Para eso existe confirmation, que abre un ámbito, entrega un objeto invocable y verifica el recuento al cerrarse.

@Test func notificaAlCerrarLaConexion() async {
    await confirmation("se emite el evento de cierre") { confirmado in
        let conexion = Conexion()
        conexion.alCerrar = { confirmado() }
        await conexion.cerrar()
    }
}

Si al salir del bloque no se ha invocado, la prueba falla con un mensaje que dice exactamente eso. El recuento es configurable, y esa configurabilidad cubre dos escenarios que en XCTest requerían acrobacias:

// exactamente tres veces
await confirmation(expectedCount: 3) { recibido in
    for _ in 0..<3 { await bus.publicar(.ping) }   // el suscriptor llama a recibido
}

// ninguna vez: verificar que algo NO ocurre
await confirmation(expectedCount: 0) { noDeberia in
    cache.alFallar = { noDeberia() }
    _ = await cache.valor(para: "clave-presente")
}

La variante de recuento cero es la más infravalorada. Afirmar que un efecto no ocurre era, con expectativas invertidas y tiempos de espera, uno de los patrones más frágiles de XCTest; aquí es una comprobación determinista sobre el ámbito de la clausura, sin esperas.

Las versiones recientes admiten además un rango en lugar de un número exacto, útil cuando el recuento depende de detalles de planificación que no quieres fijar. Conviene usarlo con moderación: un rango amplio suele indicar que la prueba no ha decidido todavía qué contrato está verificando.

El límite conceptual es claro: una confirmación no espera. Verifica el recuento cuando el bloque termina, así que el efecto debe haber ocurrido dentro de ese ámbito. Si la notificación llega desde una tarea que aún no ha completado, hay que esperarla explícitamente dentro del bloque; ahí es donde el diseño obliga a hacer visible la sincronización que antes escondía un tiempo de espera arbitrario.

Actores, aislamiento y convivencia

Un actor se prueba llamando a sus métodos con await, sin ninguna ceremonia adicional. Lo que sí requiere decisión es el aislamiento de la propia prueba, y el atributo se pone donde se pondría en cualquier otro código:

@MainActor
@Suite("Modelo de vista")
struct ModeloDeVistaTests {
    @Test func actualizaElEstadoAlCargar() async throws {
        let modelo = ModeloDeVista()          // aislado al actor principal
        await modelo.cargar()
        #expect(modelo.estado == .cargado)
    }
}

Aplicado a la suite, el aislamiento cubre el inicializador y todas sus pruebas, que es lo que necesita cualquier tipo de interfaz. Aplicado a una prueba suelta, permite mezclar en el mismo fichero pruebas del actor principal y pruebas sin aislamiento.

Probar un actor tiene además una sutileza que conviene enunciar: cada await sobre él es un punto de reentrada, y entre dos llamadas consecutivas desde la prueba puede colarse trabajo de otra tarea. Comprobar dos propiedades del actor con dos accesos separados no observa un instante único de su estado, sino dos instantes distintos. Cuando la afirmación exige atomicidad, la comprobación debe vivir dentro del propio dominio del actor, por ejemplo devolviendo una instantánea de su estado en una sola llamada.

Con la concurrencia estricta activada aparece la fricción esperable: pasar tipos que no son Sendable a través de una suspensión provoca diagnóstico, igual que en producción. Merece la pena resistir la tentación de silenciarlo, porque casi siempre indica que el propio diseño del tipo bajo prueba tiene un problema de aislamiento que también existe fuera de las pruebas.

flowchart TD
A[Que estas verificando] --> B[Un valor que llega mas tarde]
A --> C[Un efecto o callback]
A --> D[Automatizacion de interfaz o rendimiento]
B --> E[Prueba async con await directo]
C --> F[confirmation con recuento esperado]
D --> G[Sigue en XCTest]
E --> H[Anadir limite de tiempo si la fuente puede colgarse]
F --> H

Una advertencia sobre el reloj merece sitio propio, porque es la causa más frecuente de suites lentas e inestables. Dormir una tarea para «dar tiempo» a que algo ocurra no es sincronización: es una apuesta sobre la velocidad de la máquina que se pierde el día que la integración continua va cargada. Siempre que el código bajo prueba pueda ofrecer un punto de espera real —devolver un valor, cerrar una secuencia, invocar un callback dentro de una confirmación— hay que usarlo, y cuando no pueda ofrecerlo, el problema está en el diseño de ese código y no en la prueba.

La migración no exige un salto. Ambos frameworks conviven en el mismo objetivo de pruebas y en la misma ejecución, con dos reglas prácticas: no mezclar aserciones de uno dentro de pruebas del otro, y mantenerlos en ficheros separados para evitar ambigüedades entre nombres que ambos módulos exportan. Lo que debe quedarse en XCTest es concreto: la automatización de interfaz de usuario y las mediciones de rendimiento, que no tienen equivalente. El resto se traduce fichero a fichero, empezando por las pruebas unitarias puras, que son las que más ganan y las que menos riesgo tienen.

Esperar es await

La prueba async suspende la tarea en lugar de bloquear un hilo, y desaparecen los tiempos límite inventados junto con los fallos intermitentes que producían.

Cero también es un recuento

confirmation con recuento cero verifica de forma determinista que un efecto no ocurre, algo que con expectativas invertidas era estructuralmente frágil.

🤝

Migración por ficheros

Los dos frameworks corren juntos. Se traduce lo unitario primero y se deja en XCTest la interfaz de usuario y el rendimiento.

Lo que una prueba puede y no puede afirmar sobre un programa concurrente

Conviene ser honesto sobre el alcance epistémico de todo esto, porque el confort de escribir await en una prueba invita a un exceso de confianza que el asunto no admite. Una prueba de código concurrente observa un entrelazado entre los muchos que el planificador podría producir, y del hecho de que ese entrelazado se comporte bien no se sigue nada sobre los demás; el espacio de entrelazados posibles crece de forma combinatoria con los puntos de suspensión, y una suite ejecutada mil veces explora una fracción despreciable de él, sesgada además hacia los caminos que la máquina de desarrollo favorece por su número de núcleos y su carga. De ahí la asimetría fundamental: una prueba concurrente que falla demuestra la existencia de un problema, mientras que una que pasa no demuestra su ausencia, y esa asimetría es la razón de que los fallos intermitentes merezcan el trato opuesto al que suelen recibir. Marcarlos como ruido y reintentar equivale a desechar la única evidencia directa de un defecto real que probablemente ya está en producción, esperando la combinación de carga adecuada. La consecuencia metodológica es que la corrección de un programa concurrente no se establece probando sino por construcción: eligiendo un modelo en el que los estados ilegales no sean representables. Es exactamente el argumento del sistema de tipos aplicado a la concurrencia, y explica por qué el aislamiento por actores y las comprobaciones de Sendable del compilador aportan garantías de una naturaleza distinta a la de cualquier suite, por exhaustiva que sea: no muestrean el espacio de entrelazados, lo restringen. Visto así, el papel de las pruebas en este terreno se acota con precisión y se vuelve más útil, no menos. Sirven para verificar la lógica secuencial que vive dentro de cada dominio de aislamiento, para comprobar contratos observables como el orden de los eventos o la idempotencia de una operación, y para fijar como regresión cada carrera que alguna vez se manifestó. Lo que no pueden hacer, y ninguna herramienta de este capítulo cambia eso, es sustituir al razonamiento sobre el modelo; cuando una prueba concurrente resulta imposible de escribir sin temporizadores ni esperas artificiales, el diagnóstico correcto no es que falte una herramienta, sino que el diseño no ha decidido todavía quién posee qué estado.

⚔️ Migra y comprueba los límites
  1. Traduce una prueba de XCTest con expectativa y tiempo de espera a una prueba async con await directo.
  2. Verifica con confirmation que un callback se invoca exactamente tres veces y provoca un fallo cambiando el recuento.
  3. Usa confirmation con recuento cero para afirmar que la caché no notifica fallo cuando el valor está presente.
  4. Prueba un actor desde una suite aislada al actor principal y desde otra sin aislamiento; compara los diagnósticos del compilador.
  5. Consume una secuencia asíncrona infinita sin límite de tiempo, observa el cuelgue y añade después el trait correspondiente.