El framework moderno: la macro que sustituyó a la herencia
Swift Testing como sistema de pruebas nativo construido sobre macros. Cómo `@Test` elimina la convención de nombres y la subclase obligatoria, por qué una sola aserción `#expect` reemplaza a cuarenta funciones XCTAssert, y qué mecanismo de expansión sintáctica permite que un fallo muestre los valores reales en lugar de un booleano falso.
Durante más de una década, escribir una prueba en Swift significó heredar de una clase de Objective-C, obedecer una convención de nombres heredada de SenTestingKit y elegir entre cuarenta funciones globales cuyo nombre codificaba la comparación que querías hacer. Swift Testing rompe con las tres cosas a la vez, y no por gusto estético: cada una de esas decisiones era una consecuencia técnica de descubrir las pruebas en tiempo de ejecución mediante el runtime dinámico. Con macros, el descubrimiento ocurre en tiempo de compilación, la aserción puede leer el árbol sintáctico de tu expresión antes de evaluarla, y el framework deja de necesitar nada que Objective-C tuviera que prestarle. Lo que sigue no es un cambio de sintaxis: es un cambio en qué puede saber el sistema de pruebas sobre el código que prueba.
- Declarar pruebas con
@Testsobre funciones libres y sobre miembros de tipos, sin convenciones de nombre. - Sustituir la familia completa de aserciones de XCTest por la macro única
#expecty sus variantes para errores. - Explicar por qué la expansión de macro produce diagnósticos con valores reales y qué límites tiene esa captura.
- Traducir el ciclo de vida de preparación y limpieza al modelo de instancia por prueba de Swift Testing.
Una macro en lugar de una jerarquía
En XCTest, la unidad mínima era una subclase. El corredor buscaba en el runtime todas las clases que descendieran de XCTestCase, inspeccionaba sus métodos y ejecutaba los que empezaran por la palabra test. Ese mecanismo explica cada rareza del framework: la herencia obligatoria, el prefijo mágico, la imposibilidad de probar desde una función libre, y el hecho de que el nombre visible en el informe fuera un identificador de Swift con las limitaciones de un identificador de Swift.
import XCTest
final class CarritoTests: XCTestCase {
func testSumaLosPreciosDeLosArticulos() {
let carrito = Carrito(articulos: [Articulo(precio: 12), Articulo(precio: 30)])
XCTAssertEqual(carrito.total, 42)
}
}
La versión moderna no hereda de nada y no obedece a ningún prefijo. El atributo @Test es una macro adjunta que genera, en tiempo de compilación, los metadatos que el corredor necesita para encontrar la función.
import Testing
@Test("El carrito suma los precios de sus artículos")
func sumaDePrecios() {
let carrito = Carrito(articulos: [Articulo(precio: 12), Articulo(precio: 30)])
#expect(carrito.total == 42)
}
El nombre visible pasa a ser una cadena arbitraria, con espacios, tildes y la puntuación que quieras, mientras el identificador de Swift queda libre para llamarse como corresponda. Y como el descubrimiento ya no depende del runtime de Objective-C, el mismo código funciona en Linux y en Windows sin trato especial.
Agrupar sigue siendo posible, y ahí aparece la segunda diferencia de fondo: el tipo que agrupa puede ser una struct, y el corredor crea una instancia nueva por cada prueba.
struct CarritoTests {
let carrito: Carrito
init() async throws { // sustituye a setUp
carrito = try await Carrito.cargarDesdeFixture()
}
@Test func empiezaVacio() {
#expect(carrito.articulos.isEmpty)
}
}
El inicializador puede ser async y puede lanzar, dos cosas que setUp solo consiguió a base de sobrecargas incómodas. La limpieza vive en deinit, disponible únicamente en clases y actores; con una struct, la semántica de valor y la instancia fresca hacen que casi nunca haga falta limpiar nada, que es precisamente el argumento a favor de usarla.
Una sola aserción
XCTest ofrecía una función por cada relación imaginable: igualdad, desigualdad, mayor, menor, nulo, no nulo, verdadero, falso, y sus variantes con tolerancia. Cada nombre era una traducción manual de un operador que el lenguaje ya sabía escribir. Swift Testing colapsa todo eso en una macro que acepta cualquier expresión booleana.
#expect(carrito.total == 42)
#expect(carrito.articulos.count > 3)
#expect(usuario.apodo == nil)
#expect(!pedido.estaCerrado)
#expect(inventario.contains { $0.sku == "A-91" })
El último ejemplo es el que mejor explica el cambio: no existe ni podría existir una función XCTAssertContainsWhere, porque el espacio de predicados es infinito. Al aceptar la expresión entera, la macro deja de competir con el lenguaje.
Para los errores hay dos variantes explícitas, porque un lanzamiento no es una expresión booleana:
#expect(throws: ErrorPago.tarjetaCaducada) {
try pasarela.cobrar(tarjetaVencida)
}
#expect(throws: ErrorPago.self) { // basta con el tipo
try pasarela.cobrar(tarjetaSinFondos)
}
#expect(throws: Never.self) { // afirma que NO lanza
try validador.comprobar(pedidoCorrecto)
}
Y cualquier expectativa admite un comentario final que viaja al informe cuando falla, útil para explicar el porqué del valor esperado más que para repetir lo que la expresión ya dice.
El fallo que se explica solo
Aquí está la ganancia técnica que justifica el rediseño. XCTAssertTrue(a.total == b.total) recibía un Bool ya calculado: en el momento del fallo, la información sobre a y b se había perdido irremediablemente, y el informe solo podía decir que algo era falso. Por eso existían XCTAssertEqual y sus parientes: eran la única forma de que el framework tuviera acceso a los dos operandos por separado.
Una macro no recibe un valor, recibe el árbol sintáctico de la expresión. Antes de que nada se evalúe, la expansión puede descomponerla, insertar capturas en cada subexpresión y solo entonces generar el código que ejecuta la comparación. El resultado es un informe que reconstruye la operación completa.
✘ Expectation failed: (carrito.total → 55) == 42
carrito.articulos.count → 3
Esa flecha no la escribiste tú: la puso la macro al registrar el valor intermedio de cada nodo. La consecuencia práctica es que el mensaje de fallo deja de ser una pista y pasa a ser un diagnóstico, y que ya no hay ninguna razón para preferir una función especializada sobre el operador natural.
flowchart TD A[Expresion escrita por ti] --> B[La macro recibe el arbol sintactico] B --> C[Instrumenta cada subexpresion] C --> D[Genera el codigo que evalua y captura] D --> E[Se ejecuta la comparacion] E --> F[Verdadero: la prueba continua] E --> G[Falso: se registra con todos los valores capturados] G --> H[La prueba sigue ejecutando y acumula fallos]
La captura tiene límites que conviene conocer. La macro instrumenta lo que puede leer sintácticamente: llamadas, accesos a propiedad, operadores. Lo que ocurra dentro de una función que llamas no aparece descompuesto, y un valor cuya descripción sea inútil seguirá siendo inútil en el informe salvo que su tipo diga algo mejor de sí mismo. Escribir tipos con descripciones legibles vuelve a rendir aquí.
De ese límite sale una regla de estilo con consecuencias medibles. Extraer una condición a un método auxiliar para que la prueba «se lea mejor» destruye precisamente la información que hacía útil el diagnóstico, porque la macro ya no ve la comparación sino una llamada que devuelve un booleano opaco.
#expect(pedido.esFacturable) // diagnóstico pobre
#expect(pedido.estado == .cerrado && pedido.total > 0) // diagnóstico completo
La segunda versión es más larga y, cuando falla, dice cuál de las dos mitades no se cumplió y con qué valores. La primera solo puede decir que algo no era cierto. Mantener visible la estructura de lo que afirmas no es verbosidad: es la condición para que el framework pueda ayudarte.
Y como #expect no interrumpe la prueba al fallar, varias expectativas seguidas producen un informe con todos los fallos a la vez. Esa acumulación es deliberada y es el motivo de que exista un segundo verbo con la semántica contraria, que es el asunto de la lección siguiente.
Sin herencia
Una prueba es una función anotada. Puede vivir suelta en un fichero, dentro de una struct o de un actor, y no necesita prefijo alguno en su nombre.
Una macro, todos los predicados
#expect acepta cualquier expresión booleana. La familia de aserciones especializadas desaparece porque el lenguaje ya sabía expresar esas relaciones.
El fallo trae pruebas
El informe reconstruye los valores intermedios porque la macro los capturó antes de evaluar, no después de perderlos.
Merece la pena entender por qué XCTest acabó con cuarenta funciones de aserción, porque el motivo no fue la falta de gusto sino una restricción de información perfectamente concreta: en un lenguaje sin metaprogramación, una función solo recibe valores, nunca la forma en que fueron escritos, de modo que XCTAssertTrue recibía un único Bool en el que ya se había destruido todo rastro de cómo se produjo. La única manera de recuperar los operandos era pedirlos por separado, y de ahí nace mecánicamente una función distinta por cada relación que quieras diagnosticar bien: igualdad, orden, nulidad, pertenencia, tolerancia numérica. Cada nombre de aquella familia es, visto así, un intento de recuperar a mano un fragmento de la sintaxis que el compilador ya conocía y había tirado a la basura. Las macros invierten exactamente esa pérdida: operan sobre el árbol sintáctico antes de que exista ningún valor, y por eso pueden hacer algo que ninguna función podría, que es decidir dónde insertar la instrumentación en función de la estructura de lo que escribiste. La consecuencia teórica es más profunda que el ahorro de nombres. Con XCTest, el vocabulario del framework acotaba lo que podías diagnosticar cómodamente, y esa frontera empujaba a escribir aserciones que encajaran en el vocabulario en vez de aserciones que expresaran la propiedad real que te importaba: se comparaban conteos porque comparar conjuntos era incómodo, se comprobaba nulidad porque comprobar la forma de un valor asociado no tenía función propia. Al mover la aserción de la biblioteca al lenguaje, Swift Testing devuelve esa decisión a quien escribe la prueba, y el criterio de calidad pasa a ser otro: ya no se trata de elegir la función correcta, sino de escribir la expresión que enuncia con precisión la propiedad que el sistema debe cumplir. El precio, discreto pero real, es que ahora la calidad del diagnóstico depende de la legibilidad de tus tipos y de la granularidad de tus expresiones; una condición encerrada dentro de un método auxiliar vuelve a ser opaca, porque la macro solo puede instrumentar lo que ve. Escribir buenas pruebas con este framework es, en buena medida, mantener visible la estructura de aquello que afirmas.
- Toma una clase
XCTestCasereal y reescríbela comostructcon@Test, moviendosetUpal inicializador. - Provoca a propósito un fallo con
XCTAssertTruey otro equivalente con#expect, y compara ambos informes palabra por palabra. - Sustituye tres aserciones especializadas por una sola expresión con
contains,allSatisfyo coincidencia de patrones. - Escribe una expectativa cuyo diagnóstico sea inútil por culpa de un tipo mal descrito y arréglala mejorando ese tipo.
- Comprueba con
#expect(throws: Never.self)una ruta que creías infalible y documenta qué aporta frente a no escribir nada.