Swift Testing: el nuevo framework de tests con @Test, #expect, suites y tests parametrizados

XCTest ha sido el framework de tests de Apple durante más de una década. Funciona, pero su sintaxis heredada de Objective-C resulta verbosa para Swift: clases que heredan de XCTestCase, métodos que empiezan por test, y funciones XCTAssertEqual con mensajes de error poco informativos. Swift Testing, disponible desde Swift 6 y Xcode 16, toma un enfoque completamente distinto basado en macros, tipos de valor y diagnósticos precisos.

@Test: el marcador más simple

Con Swift Testing no necesitas una clase. Cualquier función marcada con @Test es un test:

import Testing

// Un test suelto, sin clase
@Test func calcularPrecioConDescuento() {
    let precio = Precio(base: 100, descuento: 0.2)
    #expect(precio.final == 80)
}

// Tests en una suite (struct, enum o actor)
struct PrecioTests {
    @Test func sinDescuento() {
        let precio = Precio(base: 50, descuento: 0)
        #expect(precio.final == 50)
    }

    @Test func descuentoMaximo() {
        let precio = Precio(base: 100, descuento: 1.0)
        #expect(precio.final == 0)
    }
}

#expect: el reemplazo de XCTAssert

#expect evalúa cualquier expresión Bool. Si falla, muestra los valores exactos de cada subexpresión para facilitar el diagnóstico:

let usuarios = [Usuario(nombre: "Ana"), Usuario(nombre: "Luis")]

// Cuando esto falla, el mensaje muestra:
// Expectation failed: (usuarios.count ? 2) == 3
#expect(usuarios.count == 3)

// Para condiciones que deben ser verdad siempre:
#expect(usuarios.first?.nombre == "Ana")

// Para código que debe lanzar un error específico:
#expect(throws: AutenticacionError.credencialesInvalidas) {
    try autenticar(email: "x", password: "y")
}

// Para código que no debe lanzar ningún error:
#expect(throws: Never.self) {
    try parsearJSON(datos: jsonValido)
}

#require: fallo inmediato

#require es como #expect pero detiene el test inmediatamente si falla, similar a XCTUnwrap o guard:

@Test func procesarRespuestaAPI() async throws {
    let respuesta = await api.buscarUsuario(id: 42)

    // Si esto falla, el test se detiene aquí con un error claro
    let usuario = try #require(respuesta.usuario)

    // Esto solo se ejecuta si 'usuario' no es nil
    #expect(usuario.nombre == "Ana García")
    #expect(usuario.activo == true)
}

@Suite: organizar tests

@Suite permite dar nombre descriptivo y añadir etiquetas a grupos de tests:

@Suite("Autenticación de usuarios")
struct AutenticacionTests {
    @Test("Login con credenciales válidas")
    func loginExitoso() async throws {
        let sesion = try await auth.login(email: "[email protected]", password: "secreta")
        #expect(sesion.token.isEmpty == false)
    }

    @Test("Login falla con password incorrecta")
    func loginFallido() async throws {
        #expect(throws: AutenticacionError.self) {
            try await auth.login(email: "[email protected]", password: "incorrecta")
        }
    }
}

Tests parametrizados con arguments

Una de las características más potentes de Swift Testing: un test parametrizado se ejecuta una vez por cada argumento, con diagnósticos independientes para cada caso:

@Test(
    "Validar formatos de email",
    arguments: [
        ("[email protected]", true),
        ("[email protected]", true),
        ("sin-arroba.com", false),
        ("@dominio.com", false),
        ("", false),
        ("dos@@arrobas.com", false),
    ]
)
func validarEmail(email: String, esValido: Bool) {
    let validador = ValidadorEmail()
    #expect(validador.esValido(email) == esValido)
}

// Con dos colecciones (producto cartesiano):
@Test(
    "Conversión de moneda",
    arguments: [.euro, .dolar, .libra],
             [10.0, 100.0, 1000.0]
)
func convertirMoneda(origen: Moneda, cantidad: Double) throws {
    let resultado = try conversor.convertir(cantidad, de: origen, a: .yen)
    #expect(resultado > 0)
}

Tags: filtrar y organizar tests

Los tags permiten categorizar tests y ejecutar subconjuntos desde la línea de comandos:

extension Tag {
    @Tag static var lento: Self
    @Tag static var red: Self
    @Tag static var critico: Self
}

@Test(.tags(.red, .lento))
func testConexionBaseDatos() async throws { ... }

@Test(.tags(.critico))
func testProcesoPago() async throws { ... }

// Ejecutar solo tests críticos:
// swift test --filter .tags/critico

Traits: condicionar la ejecución

Los traits permiten controlar cuándo y cómo se ejecuta un test:

// Deshabilitar un test temporalmente con mensaje
@Test(.disabled("Pendiente de fix del bug #123"))
func testFuncionalidadRota() { ... }

// Solo ejecutar en iOS
@Test(.enabled(if: ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 17))
func testFuncionalidadIOS17() { ... }

// Limitar el tiempo máximo del test
@Test(.timeLimit(.minutes(1)))
func testOperacionLenta() async throws { ... }

Confirmaciones: eventos asíncronos múltiples

Para verificar que algo ocurre exactamente N veces (por ejemplo, que un callback se llama tres veces):

@Test func verificarEventosProgreso() async {
    await confirmation("Progreso emitido 3 veces", expectedCount: 3) { confirmar in
        let descargador = Descargador()
        descargador.onProgreso = { _ in confirmar() }
        await descargador.descargar(URL(string: "https://example.com/archivo")!)
    }
}

Migrar desde XCTest

Puedes mezclar XCTest y Swift Testing en el mismo target. La migración es gradual:

// XCTest (antes)
class CalculadoraTests: XCTestCase {
    func testSuma() {
        let calc = Calculadora()
        XCTAssertEqual(calc.sumar(2, 3), 5, "La suma debería ser 5")
    }
}

// Swift Testing (equivalente)
@Test("Suma de dos números")
func testSuma() {
    let calc = Calculadora()
    #expect(calc.sumar(2, 3) == 5)
}

Puntos a tener en cuenta al migrar:

  • setUp/tearDown ? init/deinit en la suite
  • XCTSkip ? .disabled() trait
  • XCTUnwrap ? try #require()
  • Tests asíncronos: simplemente marca la función como async, sin necesidad de expectation

Resumen

Swift Testing no es solo XCTest con sintaxis nueva. Es un replanteamiento del modelo de tests para un lenguaje que tiene macros, async/await y tipos de valor como ciudadanos de primera clase. Los tests parametrizados, los diagnósticos precisos de #expect, los tags y los traits son diferencias fundamentales que hacen que los tests sean más expresivos y el feedback de fallos más accionable. Para proyectos nuevos con Swift 6 y Xcode 16, es la elección recomendada.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP