← Todos los articulos

Swift Testing: el framework que reemplaza a XCTest, y lo que se queda en XCTest

XCTest, el framework de pruebas de Apple desde 2013, está siendo reemplazado por Swift Testing. Apple aún no dice “obsoleto” (XCTest sigue distribuyéndose, sigue funcionando, sigue alojando la automatización de UI y las pruebas de rendimiento), pero cada sesión de Apple, cada hilo del Swift Forum y cada nuevo proyecto de ejemplo desde la WWDC 2024 dirigen el código Swift hacia Swift Testing. El framework viene con Xcode 16+ y Swift 61, se ejecuta en macOS, iOS, watchOS, tvOS, visionOS y Linux2, y presenta un modelo mental diferente: las pruebas son funciones, las suites son tipos, la configuración son traits y el paralelismo es el valor por defecto.

La migración es incremental. La propia documentación de Apple admite explícitamente ejecutar ambos frameworks en paralelo en el mismo target3, lo que significa que la jugada correcta para una base de código no trivial no es “reescribir todo” sino “escribe pruebas nuevas en Swift Testing y migra las viejas a medida que las toques”. El artículo recorre la superficie del API, nombra la frontera de la migración (lo que se queda en XCTest) y cubre el vocabulario de traits que reemplaza la configuración basada en jerarquías de clases de XCTest.

TL;DR

  • Swift Testing reemplaza el modelo de XCTest, basado en clases y tipado por strings, con macros: @Test, @Suite, #expect(...) y #require(...)4.
  • Las pruebas son funciones libres o métodos en tipos; las suites son cualquier tipo que contenga funciones de prueba (@Suite solo es necesario al especificar un nombre de visualización o un trait).
  • La configuración pasa de jerarquías de clases y convenciones de nombres a traits: .enabled(if:), .disabled(_:), .serialized, .timeLimit(...), .tags(...), .bug(...)5.
  • Las pruebas parametrizadas vía @Test(arguments:) producen un resultado de prueba padre con un hijo por argumento; paralelas por defecto6.
  • La migración es lado a lado, no de un solo golpe. La automatización de UI (XCUIApplication) y las pruebas de rendimiento (XCTMetric) se quedan en XCTest porque Swift Testing no las cubre3.
  • La WWDC 2025 agregó adjuntos personalizados (artefactos de prueba) y exit tests (que verifican código que se espera que termine bajo condiciones específicas)7.

Por qué existe Swift Testing

Los puntos débiles de XCTest son bien conocidos por cualquiera que haya mantenido una suite de pruebas grande de iOS:

Aserciones tipadas por strings. XCTAssertEqual(a, b, "message") no captura la expresión Swift original. Cuando la aserción falla, el mensaje de error te dice los valores en tiempo de ejecución pero no qué expresión los produjo. Depurar requiere leer el código fuente de la prueba y reconstruir el contexto.

Configuración basada en clases. El setup, el teardown y el estado compartido requieren heredar de XCTestCase. Deshabilitar una prueba requiere o bien renombrarla (para que el patrón de descubrimiento la pase por alto) o envolverla en #if DEBUG. Seleccionar qué pruebas se ejecutan requiere nombrar con cuidado.

Desajuste con concurrencia. XCTest es anterior a Swift Concurrency. XCTestExpectation más wait(for:timeout:) es el puente legacy hacia el código async; el patrón es verboso y propenso a errores. El código Swift moderno usa async/await; los modismos de XCTest se sienten anacrónicos.

Parametrización débil. XCTest no tiene soporte de pruebas parametrizadas de primera clase. Los desarrolladores lo simulan con bucles for dentro de los métodos de prueba (lo que produce un resultado de prueba para muchos casos) o escriben generadores de código (que producen N métodos de prueba a partir de una plantilla).

Swift Testing aborda cada uno:

import Testing

@Test func userInitializesWithDefaults() {
    let user = User()
    #expect(user.name == "Anonymous")
    #expect(user.preferences.isEmpty)
}

#expect captura la expresión completa. Cuando la aserción falla, la salida de la prueba muestra que user.name == "Anonymous" falló porque user.name era "Guest". El framework hace el trabajo de leer la expresión porque la macro ve el árbol sintáctico. Sin adivinanzas de “se esperaba X, se obtuvo Y”.

Las macros

Cuatro macros hacen el trabajo.

@Test y @Suite

@Test marca una función como una prueba. La función puede vivir a nivel de archivo, en una struct, en un actor, en un enum, en cualquier lugar donde una función pueda vivir en Swift. El framework descubre las funciones marcadas con @Test y las ejecuta.

@Test func loginSucceedsWithValidCredentials() async throws {
    let result = try await api.login(username: "alice", password: "valid")
    #expect(result.isAuthenticated)
}

struct AuthenticationTests {
    @Test func loginRejectsEmptyPassword() async throws {
        let result = try await api.login(username: "alice", password: "")
        #expect(!result.isAuthenticated)
        #expect(result.error == .emptyPassword)
    }
}

@Suite marca un tipo como una suite de pruebas explícitamente. El marcador es implícito cuando un tipo contiene funciones @Test; un @Suite explícito solo es necesario al añadir un nombre de visualización personalizado o aplicar un trait a nivel de suite4.

@Suite("Authentication", .tags(.security))
struct AuthenticationTests {
    @Test func loginSucceeds() async throws { ... }
    @Test func loginFails() async throws { ... }
}

#expect y #require

#expect es la aserción estándar. La prueba continúa si la expectativa falla (la falla se registra; las aserciones siguientes se ejecutan igual). #require es la variante fail-fast: si el requisito falla, la prueba se detiene de inmediato. Usa #require para precondiciones cuya falla produciría errores sin sentido en aserciones posteriores.

@Test func userPreferencesLoad() async throws {
    let user = try #require(await store.fetchUser(id: 42))
    // If fetchUser returns nil, the test stops here. The next line
    // never runs against an unwrapped optional.
    #expect(user.preferences["theme"] == "dark")
    #expect(user.preferences.count >= 1)
}

El try delante de #require importa: #require retorna el valor desempaquetado cuando el requisito se satisface (en este caso el User no opcional) o lanza cuando no. La función de prueba debe ser throws para usar #require para desempaquetar.

Pruebas parametrizadas

@Test(arguments:) ejecuta la función de prueba una vez por argumento. El framework reporta cada argumento como una prueba hija con la prueba padre como agregado.

@Test(arguments: [
    ("alice", "password123", true),
    ("bob", "", false),
    ("", "password123", false),
    ("carol", "wrongpassword", false),
])
func loginScenario(username: String, password: String, expectedSuccess: Bool) async throws {
    let result = try await api.login(username: username, password: password)
    #expect(result.isAuthenticated == expectedSuccess)
}

El framework ejecuta los cuatro argumentos en paralelo por defecto. Los mensajes de falla de prueba identifican qué tupla de argumentos falló, así que depurar una falla parametrizada no requiere ejecutar la prueba de forma aislada.

Para argumentos que son colecciones independientes, @Test(arguments: arrayA, arrayB) ejecuta el producto cruzado (cada combinación). Para pares secuenciales, usa zip(arrayA, arrayB) y pasa la secuencia resultante.

Traits: configuración como valores de primera clase

XCTest usa jerarquías de clases, convenciones de nombres y test plans de Xcode para configurar las pruebas. Swift Testing usa traits: valores aplicados a las declaraciones @Test y @Suite5:

@Test(.enabled(if: ProcessInfo.processInfo.isRunningOnCI))
func ciOnlyIntegrationTest() async throws { ... }

@Test(.disabled("Pending fix for FB12345"))
func knownFailingTest() { ... }

@Test(.timeLimit(.minutes(1)))
func potentiallySlowTest() async throws { ... }

@Test(.tags(.network, .integration), .bug("FB67890", "regression in retry logic"))
func networkRetryTest() async throws { ... }

@Suite(.serialized)
struct DatabaseTests {
    // All tests in this suite run sequentially.
    // Useful when tests share a database fixture.
    @Test func insertUser() { ... }
    @Test func updateUser() { ... }
    @Test func deleteUser() { ... }
}

El vocabulario de traits cubre los casos para los que XCTest requería infraestructura personalizada:

  • .enabled(if:) y .disabled(_:). Habilitación condicional con una expresión en tiempo de ejecución y una razón opcional. Reemplaza los bloques #if DEBUG y los trucos de renombrado.
  • .serialized. Marca una suite o una prueba parametrizada como secuencial. Heredable: una suite serializada fuerza a todas las pruebas hijas a ser seriales6.
  • .timeLimit(...). Límite de tiempo por prueba. La prueba se mata y se reporta como fallida si excede el límite.
  • .tags(...). Etiquetas para filtrado. Configura reglas de inclusión/exclusión de etiquetas en un test plan de Xcode; el plan se ejecuta vía xcodebuild test -testPlan <name>. Ten en cuenta que -only-testing: filtra por identificador de prueba (target, suite o función), no por etiqueta.
  • .bug(...). Vincula una prueba a un ID de bug con una razón. El vínculo aparece en el reporte de la prueba y (para pruebas que se sabe que están fallando) evita que la falla bloquee CI.

También se admiten traits personalizados. Una app puede definir sus propios traits para necesidades específicas del proyecto (p. ej., .requiresKeychain, .skipIfOffline).

El paralelismo es el valor por defecto

Swift Testing ejecuta las pruebas en paralelo por defecto, incluyendo los argumentos parametrizados. El valor por defecto importa porque la mayoría de las suites de pruebas crecen con el tiempo, y el paralelismo es la diferencia entre una corrida de pruebas de 30 segundos y una de 5 minutos. XCTest requería opt-in explícito (toggle de paralelismo de pruebas en el test plan); Swift Testing invierte el valor por defecto.

La implicación: las pruebas deben ser independientes. El estado compartido (un fixture de base de datos, un sistema de archivos mockeado, un singleton a nivel de app) debe estar o aislado por prueba (cada prueba obtiene un fixture nuevo) o marcado como .serialized (la suite se ejecuta de forma secuencial). Las pruebas que mutan estado global y asumen ejecución serial son bugs que Swift Testing saca a la luz más rápido que XCTest.

Migración: lado a lado, no de un solo golpe

La postura oficial de Apple sobre la migración es incremental3:

  • Ambos frameworks coexisten en el mismo target de pruebas.
  • Las pruebas nuevas pueden escribirse en Swift Testing inmediatamente.
  • Las pruebas viejas de XCTest siguen funcionando sin cambios.
  • Migra las pruebas de XCTest a Swift Testing cuando sea conveniente (un archivo tocado, una nueva función, una refactorización).

Dos casos se quedan en XCTest indefinidamente:

Pruebas de automatización de UI. XCUIApplication, XCUIElement, XCUIElementQuery, toda la superficie de pruebas de UI, son exclusivas de XCTest3. Swift Testing no provee un reemplazo. Las pruebas de UI se quedan en XCTest hasta que Apple extienda la cobertura de Swift Testing.

Pruebas de rendimiento. XCTMetric, measure(metrics:), las baselines de rendimiento, también son exclusivas de XCTest3. Swift Testing aún no tiene un equivalente.

Para todo lo demás (pruebas unitarias, pruebas de integración, pruebas async, pruebas parametrizadas), Swift Testing es el valor por defecto recomendado para código nuevo.

Lo que añadió la WWDC 2025

Dos adiciones notables a Swift Testing en la WWDC 20257:

Adjuntos personalizados. Una prueba puede adjuntar datos Attachable arbitrarios a su resultado para el triaging de fallas. Imágenes de capturas de pantalla con falla, logs de diagnóstico, archivos de entrada generados. Los adjuntos aparecen en el reporter de pruebas de Xcode y en los artefactos de CI. La superficie del API Attachment.record(_:named:sourceLocation:) en Sources/Testing/Attachments/Attachment.swift la define; se aceptan los valores que se conforman a Attachable (Data, String, tipos de imagen vía conformancia opt-in).

@Test func reportLayoutAtFailure() async throws {
    let result = await renderer.run(LayoutTestCase.complex)
    if !result.passes {
        Attachment.record(result.diagnostics, named: "diagnostics.txt")
        Attachment.record(result.screenshotData, named: "failure-screenshot.png")
    }
    #expect(result.passes)
}

Exit tests. Pruebas que verifican código que se espera que termine (que llame a exit(), que lance un fatal error, que dispare un fallo de precondición). XCTest no tenía una forma limpia de probar esto; los exit tests de Swift Testing ejecutan el código en un proceso hijo y verifican el comportamiento de terminación.

@Test func parserExitsOnMalformedInput() async {
    await #expect(processExitsWith: .failure) {
        Parser.parse(malformedInput)
    }
}

Ambas adiciones son pragmáticas. Los adjuntos personalizados resuelven un punto de dolor real (depurar fallas inestables de CI); los exit tests cubren un hueco genuino en la superficie de pruebas unitarias.

Cuándo XCTest sigue siendo la decisión correcta

Tres casos donde el código nuevo debería seguir siendo XCTest:

La prueba es una prueba de automatización de UI. XCUIApplication.launch, interacciones de tap-and-swipe, queries basadas en identificadores de accesibilidad. Swift Testing no cubre la superficie de automatización de UI; las pruebas nuevas de UI son XCTest.

La prueba es un benchmark de rendimiento. measure(metrics: [XCTClockMetric()]), baselines de rendimiento, mediciones de memoria. Swift Testing no tiene equivalentes; las pruebas nuevas de rendimiento son XCTest.

La prueba debe ejecutarse en una plataforma que no admite Swift Testing. Swift Testing requiere Swift 6 / Xcode 16+. Las pruebas que deben compilarse contra toolchains anteriores (una matriz de CI que incluye versiones más viejas de Xcode) necesitan XCTest hasta que el toolchain se ponga al día.

Para pruebas unitarias, pruebas de integración, pruebas async y pruebas parametrizadas en targets de Swift 6+, Swift Testing es el valor por defecto moderno. El costo de la migración es real, pero el costo de no migrar se acumula; cada XCTest nuevo escrito hoy es deuda técnica.

La conexión con el flujo de trabajo del agente

Las pruebas son el contrato contra el cual trabaja la generación de código asistida por IA. Cuando un agente escribe código Swift, las pruebas son la forma en que el desarrollador (o el propio agente) verifica que el cambio es correcto. Las propiedades de Swift Testing hacen que el código generado por agentes sea más verificable:

  • Captura de expresiones. La falla de #expect(user.score == calculator.compute(user)) muestra ambos lados de la comparación. Un agente que lea la falla puede arreglar el bug sin volver a leer el archivo de prueba.
  • Casos parametrizados. Un agente arreglando una regresión sabe qué entrada específica falló. El arreglo puede ser dirigido.
  • Filtrado basado en traits. Una anotación .tags(.regression) permite a un agente ejecutar solo las pruebas de regresión después de un arreglo; bucle de retroalimentación más rápido.
  • Adjuntos personalizados. Un agente depurando una prueba inestable puede adjuntar el artefacto de entrada que falla y leerlo en la siguiente corrida.

El artículo sobre hooks para desarrollo en Apple cubre los hooks Stop que ejecutan pruebas después de cada edición de Claude Code. Las fallas más rápidas, más paralelas y más informativas de Swift Testing hacen que ese bucle sea lo suficientemente apretado como para que el agente pueda iterar sin intervención manual.

Lo que este patrón significa para apps de iOS 26+

Tres conclusiones.

  1. Por defecto, escribe pruebas nuevas en Swift Testing. El framework es más expresivo, se ejecuta en paralelo por defecto, captura el contexto de fallas automáticamente y admite pruebas parametrizadas como un concepto de primera clase. El código existente de XCTest sigue funcionando; el código nuevo usa el valor por defecto moderno.

  2. Mantén la automatización de UI y las pruebas de rendimiento en XCTest. Es un hueco de cobertura de Apple, no una elección de migración. Hasta que Swift Testing se extienda a esos dominios, esas pruebas se quedan en XCTest indefinidamente.

  3. Usa los traits como vocabulario de configuración. .enabled(if:), .disabled(_:), .serialized, .timeLimit(...), .tags(...), .bug(...) cubren los casos para los que XCTest requería jerarquías de clases, convenciones de nombres y bloques #if DEBUG. Los traits son valores de primera clase que se componen; el vocabulario de traits es lo que hace que el framework se sienta moderno.

El cluster completo de Apple Ecosystem: App Intents tipados; servidores MCP; la pregunta del routing; Foundation Models; la distinción runtime vs tooling LLM; tres superficies; el patrón de fuente única de verdad; dos servidores MCP; hooks para desarrollo en Apple; Live Activities; el contrato de runtime de watchOS; internals de SwiftUI; el modelo mental espacial de RealityKit; disciplina de esquemas de SwiftData; patrones de Liquid Glass; shipping multiplataforma; la matriz de plataformas; Vision framework; Symbol Effects; inferencia con Core ML; API de Writing Tools; sobre lo que me niego a escribir. El hub está en la serie Apple Ecosystem. Para un contexto más amplio de iOS con agentes de IA, consulta la guía de iOS Agent Development.

FAQ

¿Swift Testing reemplaza a XCTest por completo?

Aún no. XCTest sigue alojando las pruebas de automatización de UI (XCUIApplication) y las pruebas de rendimiento (XCTMetric); Swift Testing no cubre esos dominios. Para pruebas unitarias, pruebas de integración, pruebas async y pruebas parametrizadas, Swift Testing es el valor por defecto recomendado para código nuevo. Ambos frameworks coexisten en el mismo target sin conflicto.

¿Cuál es la diferencia práctica entre #expect y XCTAssertEqual?

#expect captura la expresión Swift completa y reporta ambos lados de la comparación al fallar (p. ej., user.score == calculator.compute(user) falló porque user.score = 80 y calculator.compute(user) = 100). XCTAssertEqual solo conoce los valores en tiempo de ejecución y la cadena de mensaje opcional. La captura de expresiones es lo que hace que las fallas de Swift Testing se expliquen por sí solas sin volver a leer el código fuente de la prueba.

¿Cómo funcionan las pruebas parametrizadas?

@Test(arguments: [...]) ejecuta la función de prueba una vez por argumento. El framework reporta cada argumento como un resultado de prueba hijo con el padre como agregado. Las tuplas pueden pasar múltiples valores. Las pruebas parametrizadas se ejecutan en paralelo por defecto; añade .serialized para forzar la ejecución secuencial.

¿Para qué sirve #require?

#require es la variante fail-fast de #expect. Úsalo para precondiciones cuya falla produciría errores sin sentido en aserciones posteriores. try #require(value) retorna el valor desempaquetado cuando se satisface o lanza cuando no, así que el resto de la prueba puede usar el valor desempaquetado directamente. La función de prueba debe ser throws para usar #require para desempaquetar.

¿Puedo escribir pruebas de Swift Testing en Linux?

Sí. Swift Testing es de código abierto2 y se ejecuta en cada plataforma que admite Swift, incluyendo Linux. Los proyectos de Swift del lado del servidor pueden adoptar Swift Testing inmediatamente. El framework no es específico de plataformas Apple.

¿Cómo migro una suite existente de XCTest?

La recomendación de Apple es incremental: escribe pruebas nuevas en Swift Testing, migra las viejas cuando las toques3. No hay un convertidor automatizado (las diferencias del modelo son lo suficientemente grandes como para que la automatización fuera frágil). El objetivo correcto de migración son las pruebas unitarias y las pruebas de integración; las pruebas de UI y las de rendimiento se quedan en XCTest indefinidamente.

Referencias


  1. Apple Developer: Swift Testing. Resumen de Apple sobre la adopción de Swift Testing con Xcode 16+ y Swift 6. 

  2. Repositorio de código abierto: swiftlang/swift-testing en GitHub. El código fuente del framework, disponible bajo la Apache License 2.0 con la licencia estándar del Swift Project. 

  3. Documentación de Apple Developer: Migrating a test from XCTest. Guía oficial de migración de Apple que cubre la coexistencia lado a lado y las excepciones de XCUIApplication / XCTMetric. 

  4. Documentación de Apple Developer: Swift Testing. Referencia del framework para @Test, @Suite, #expect, #require y el vocabulario de traits. 

  5. Documentación de Apple Developer: Trait. El protocolo que define los traits de prueba, con traits incorporados que incluyen .enabled(if:), .disabled(_:), .serialized, .timeLimit(...), .tags(...), .bug(...)

  6. Apple Developer: Meet Swift Testing (sesión 10179 de la WWDC 2024) y Go further with Swift Testing (sesión 10195 de la WWDC 2024). Las sesiones de introducción que cubren las pruebas parametrizadas y el paralelismo. 

  7. Evidencia del repositorio de código abierto para las adiciones de la WWDC 2025: Sources/Testing/Attachments/Attachment.swift para los adjuntos personalizados y Sources/Testing/ExitTests/ para la macro de exit test #expect(processExitsWith:). Ambas aparecieron por primera vez en la WWDC 2025 y se distribuyen con Xcode 26+. 

Artículos relacionados

Five Apple Platforms, Three Shared Files: How Return Actually Ships Cross-Platform SwiftUI

Return runs on iPhone, iPad, Mac, Apple Watch, and Apple TV. Three Swift files are shared across all five targets out of…

18 min de lectura

What SwiftUI Is Made Of

SwiftUI is a result-builder DSL on top of a value-typed View tree. Once the substrate is visible, AnyView, Group, and Vi…

17 min de lectura

The Cleanup Layer Is the Real AI Agent Market

Charlie Labs pivoted from building agents to cleaning up after them. The AI agent market is moving from generation to pr…

15 min de lectura