Introducción: Una nueva era para las pruebas en Swift
Si llevas tiempo desarrollando para las plataformas de Apple, seguro que conoces la experiencia de trabajar con XCTest: subclasear XCTestCase, nombrar cada método con el prefijo test, lidiar con más de cuarenta variantes de XCTAssert, y configurar el setup y teardown heredando métodos. Funciona, sí, pero seamos sinceros — se siente anticuado. Yo pasé años con esa dinámica y, aunque te acostumbras, siempre sentí que el framework luchaba contra el lenguaje en lugar de trabajar con él. XCTest fue diseñado en la era de Objective-C y, aunque ha evolucionado, nunca se pensó desde cero para Swift.
Con la llegada de Swift Testing, presentado en la WWDC 2024 e integrado en Xcode 16, Apple introdujo un framework de pruebas completamente moderno. Construido desde los cimientos para aprovechar lo mejor de Swift: macros, concurrencia estructurada y tipos valor. Y con las novedades de Swift 6.2 y Xcode 26 (WWDC 2025), el framework ha alcanzado una madurez impresionante con funcionalidades como exit tests, confirmaciones con rangos y test scoping.
En esta guía vamos a recorrer Swift Testing desde los fundamentos hasta las características más avanzadas, con ejemplos prácticos que puedes aplicar hoy mismo. Si ya tienes tests con XCTest, no te preocupes — ambos frameworks conviven perfectamente en el mismo target, así que puedes migrar poco a poco sin drama.
Fundamentos: @Test y #expect
El corazón de Swift Testing son dos conceptos increíblemente simples: marcas una función con @Test para convertirla en un test, y usas #expect para verificar condiciones. Así de directo. Sin herencia, sin prefijos mágicos, sin ceremonias.
Tu primer test con Swift Testing
Veamos cómo se ve un test básico. Imaginemos que estamos construyendo una app de gestión de tareas llamada TareasApp y queremos probar nuestro modelo:
import Testing
struct Tarea {
let id: UUID
var titulo: String
var descripcion: String
var estaCompletada: Bool
var prioridad: Prioridad
var fechaCreacion: Date
enum Prioridad: Int, Comparable, Sendable {
case baja = 0
case media = 1
case alta = 2
case urgente = 3
static func < (lhs: Prioridad, rhs: Prioridad) -> Bool {
lhs.rawValue < rhs.rawValue
}
}
init(titulo: String, descripcion: String = "", prioridad: Prioridad = .media) {
self.id = UUID()
self.titulo = titulo
self.descripcion = descripcion
self.estaCompletada = false
self.prioridad = prioridad
self.fechaCreacion = Date()
}
mutating func completar() {
estaCompletada = true
}
}
@Test("Una tarea nueva no está completada")
func tareaNuevaNoCompletada() {
let tarea = Tarea(titulo: "Revisar código")
#expect(!tarea.estaCompletada)
#expect(tarea.titulo == "Revisar código")
#expect(tarea.prioridad == .media)
}
¿Ves lo limpio que queda? No hay clase que heredar, no necesitas el prefijo test en el nombre de la función, y #expect sustituye a todas las variantes de XCTAssert. La macro @Test acepta un nombre legible como parámetro, que aparecerá en el navegador de tests de Xcode. Es un detalle pequeño, pero marca una diferencia enorme cuando tienes cientos de tests.
#expect vs #require: cuándo usar cada uno
Este punto es clave, así que presta atención. Swift Testing ofrece dos macros de aserción con propósitos distintos:
- #expect — Registra un fallo pero continúa la ejecución del test. Similar a la mayoría de
XCTAssert. - #require — Lanza un error y detiene inmediatamente el test. Similar a
XCTUnwrap, pero mucho más versátil.
@Test("Verificar propiedades de una tarea urgente")
func propiedadesTareaUrgente() throws {
let tarea = Tarea(titulo: "Deploy a producción", prioridad: .urgente)
// #require detiene el test si falla — útil para precondiciones
try #require(tarea.titulo.isEmpty == false, "El título no puede estar vacío")
// #expect registra el fallo pero continúa
#expect(tarea.prioridad == .urgente)
#expect(!tarea.estaCompletada)
}
La regla general: usa #require cuando el fallo haría que las aserciones posteriores carezcan de sentido (como un unwrap fallido), y #expect para verificaciones independientes. En la práctica, terminas usando #require más de lo que esperarías — resulta que muchos tests tienen precondiciones implícitas que no siempre son obvias a primera vista.
Verificación de errores
Swift Testing simplifica enormemente la verificación de errores lanzados. Desde Swift 6.1, las macros #expect(throws:) devuelven el error capturado como opcional, lo que te permite hacer validaciones adicionales sobre el error mismo:
enum ErrorTarea: Error, Equatable {
case tituloVacio
case tituloDemasiadoLargo(Int)
case fechaInvalida
}
struct GestorTareas {
var tareas: [Tarea] = []
mutating func agregar(titulo: String, prioridad: Tarea.Prioridad = .media) throws -> Tarea {
guard !titulo.isEmpty else {
throw ErrorTarea.tituloVacio
}
guard titulo.count <= 200 else {
throw ErrorTarea.tituloDemasiadoLargo(titulo.count)
}
let tarea = Tarea(titulo: titulo, prioridad: prioridad)
tareas.append(tarea)
return tarea
}
}
@Test("Agregar tarea sin título lanza error")
func agregarTareaSinTitulo() throws {
var gestor = GestorTareas()
// Verificar que lanza el error esperado
#expect(throws: ErrorTarea.tituloVacio) {
try gestor.agregar(titulo: "")
}
}
@Test("Agregar tarea con título demasiado largo devuelve detalles del error")
func tituloDemasiadoLargo() throws {
var gestor = GestorTareas()
let tituloLargo = String(repeating: "a", count: 250)
// Desde Swift 6.1: el error se devuelve para inspección adicional
let error = #expect(throws: ErrorTarea.self) {
try gestor.agregar(titulo: tituloLargo)
}
if case .tituloDemasiadoLargo(let longitud) = error {
#expect(longitud == 250)
}
}
Suites: Organizando tus tests
En Swift Testing, cualquier tipo que contenga funciones marcadas con @Test se considera implícitamente una suite. Puedes usar struct, final class, enum o incluso actor. Apple recomienda usar struct por defecto (a menos que necesites un deinit para limpieza), y sinceramente, es un buen consejo.
import Testing
@Suite("Gestor de Tareas")
struct GestorTareasTests {
// Cada test obtiene su propia instancia — sin estado compartido
var gestor = GestorTareas()
@Test("Agregar una tarea incrementa el conteo")
mutating func agregarTarea() throws {
let tarea = try gestor.agregar(titulo: "Escribir tests")
#expect(gestor.tareas.count == 1)
#expect(tarea.titulo == "Escribir tests")
}
@Test("Agregar múltiples tareas las ordena correctamente")
mutating func agregarMultiplesTareas() throws {
_ = try gestor.agregar(titulo: "Primera", prioridad: .baja)
_ = try gestor.agregar(titulo: "Segunda", prioridad: .urgente)
_ = try gestor.agregar(titulo: "Tercera", prioridad: .media)
#expect(gestor.tareas.count == 3)
}
}
Un detalle importantísimo aquí: Swift Testing crea una instancia separada de la suite para cada test. Esto es enorme. No hay estado compartido entre tests accidentalmente — un problema que en XCTest era más frecuente de lo que nos gustaría admitir, donde las propiedades de clase podían filtrarse entre métodos de test sin que te dieras cuenta.
Setup y teardown con init/deinit
En lugar de los métodos setUp() y tearDown() de XCTest, Swift Testing usa los inicializadores y deinicializadores estándar de Swift. Nada de métodos especiales — simplemente el init de toda la vida:
@Suite("Tests con configuración inicial")
struct TestsConSetup {
let gestor: GestorTareas
let tareasIniciales: [Tarea]
init() throws {
var g = GestorTareas()
let t1 = try g.agregar(titulo: "Tarea de prueba 1", prioridad: .alta)
let t2 = try g.agregar(titulo: "Tarea de prueba 2", prioridad: .baja)
self.gestor = g
self.tareasIniciales = [t1, t2]
}
@Test("El gestor tiene las tareas iniciales")
func verificarTareasIniciales() {
#expect(gestor.tareas.count == 2)
}
@Test("Las tareas iniciales no están completadas")
func tareasNoCompletadas() {
for tarea in gestor.tareas {
#expect(!tarea.estaCompletada)
}
}
}
Suites anidadas
Puedes anidar suites para crear jerarquías lógicas de organización. Esto viene genial cuando tienes un modelo con muchos comportamientos que quieres agrupar:
@Suite("Modelo Tarea")
struct TareaModelTests {
@Suite("Creación")
struct Creacion {
@Test("Valores por defecto correctos")
func valoresPorDefecto() {
let tarea = Tarea(titulo: "Test")
#expect(tarea.prioridad == .media)
#expect(!tarea.estaCompletada)
}
}
@Suite("Completar tarea")
struct Completar {
@Test("Marcar como completada cambia el estado")
func completarTarea() {
var tarea = Tarea(titulo: "Test")
tarea.completar()
#expect(tarea.estaCompletada)
}
}
}
Tests Parametrizados: El superpoder de Swift Testing
Honestamente, esta es la funcionalidad que más me emocionó cuando empecé a usar Swift Testing. Los tests parametrizados te permiten declarar un único test que se ejecute automáticamente con diferentes conjuntos de datos, en lugar de escribir múltiples tests casi idénticos o usar bucles for dentro de un solo test.
@Test("Prioridades se comparan correctamente", arguments: [
(Tarea.Prioridad.baja, Tarea.Prioridad.media, true),
(Tarea.Prioridad.media, Tarea.Prioridad.alta, true),
(Tarea.Prioridad.alta, Tarea.Prioridad.urgente, true),
(Tarea.Prioridad.urgente, Tarea.Prioridad.baja, false),
])
func compararPrioridades(a: Tarea.Prioridad, b: Tarea.Prioridad, esperado: Bool) {
#expect((a < b) == esperado)
}
Swift Testing divide automáticamente este test en casos independientes — uno por cada argumento — y los ejecuta en paralelo. Si un caso falla, los demás siguen ejecutándose. Y lo mejor: en el navegador de tests puedes ver exactamente cuál falló, sin tener que adivinar.
Usando zip para combinar argumentos
Cuando tienes dos colecciones de datos y quieres emparejarlas uno a uno (no como producto cartesiano), usa zip:
@Test("Títulos válidos se crean correctamente",
arguments: zip(
["Comprar leche", "Revisar PR", "Deploy v2.0"],
[Tarea.Prioridad.baja, .media, .urgente]
))
func crearTareasConTitulosValidos(titulo: String, prioridad: Tarea.Prioridad) throws {
var gestor = GestorTareas()
let tarea = try gestor.agregar(titulo: titulo, prioridad: prioridad)
#expect(tarea.titulo == titulo)
#expect(tarea.prioridad == prioridad)
}
Un aviso importante: sin zip, si pasas dos colecciones como argumentos separados, Swift Testing genera el producto cartesiano (todas las combinaciones posibles). Eso puede resultar en una explosión exponencial de casos que probablemente no es lo que quieres.
Enums conformando a CaseIterable
Un patrón especialmente elegante es usar enums que conforman CaseIterable como fuente de argumentos:
@Test("Todas las prioridades tienen un rawValue válido",
arguments: Tarea.Prioridad.allCases)
func prioridadesValidas(prioridad: Tarea.Prioridad) {
#expect(prioridad.rawValue >= 0)
#expect(prioridad.rawValue <= 3)
}
Traits: Personalizando el comportamiento de tus tests
Los traits son modificadores que puedes aplicar a tests o suites para controlar su ejecución. Son, en mi opinión, una de las funcionalidades que más distinguen a Swift Testing de XCTest.
Tags: Agrupación semántica
Los tags te permiten categorizar tests de forma transversal, más allá de la estructura de suites:
extension Tag {
@Tag static var modelo: Self
@Tag static var red: Self
@Tag static var persistencia: Self
@Tag static var integracion: Self
}
@Suite("Tests del modelo", .tags(.modelo))
struct ModeloTests {
@Test("Crear tarea con valores por defecto")
func crearTareaDefecto() {
let tarea = Tarea(titulo: "Test")
#expect(tarea.prioridad == .media)
}
}
@Suite("Tests de red", .tags(.red))
struct RedTests {
@Test("Obtener tareas del servidor")
func obtenerTareas() async throws {
// Tests de red aquí
}
}
Los tags son especialmente útiles para filtrar tests en Xcode: puedes ejecutar todos los tests con un tag específico, independientemente de en qué suite estén. Esto es oro puro cuando quieres correr solo los tests rápidos antes de hacer un commit.
Habilitación condicional
Puedes habilitar o deshabilitar tests de forma condicional, lo cual es tremendamente práctico:
@Test("Feature experimental solo en debug",
.enabled(if: Configuration.esDebug))
func featureExperimental() {
// Solo se ejecuta si Configuration.esDebug es true
}
@Test("Feature deshabilitada temporalmente",
.disabled("Pendiente de fix en el backend — ticket IOS-4521"))
func featureRotaTemporalmente() {
// No se ejecuta, pero queda documentado el motivo
}
@Test("Bug conocido en el cálculo de fechas",
.bug("https://bugs.ejemplo.com/IOS-4521", "Falla con zona horaria UTC-12"))
func calculoFechasConBug() {
// Vinculado al sistema de seguimiento de bugs
}
Límites de tiempo
Puedes establecer tiempos máximos de ejecución para evitar que tests lentos bloqueen tu pipeline de CI:
@Test("La sincronización completa en menos de 30 segundos",
.timeLimit(.seconds(30)))
func sincronizacionRapida() async throws {
// Si tarda más de 30 segundos, el test falla
try await sincronizarDatos()
}
Ejecución serializada
Swift Testing ejecuta los tests en paralelo por defecto — algo fantástico para velocidad. Pero si tienes tests que comparten algún recurso y no pueden ejecutarse simultáneamente, usa .serialized:
@Suite("Tests de base de datos", .serialized)
struct BaseDeDatosTests {
@Test("Insertar registro")
func insertar() async throws {
// Se ejecuta antes que los siguientes
}
@Test("Consultar registros")
func consultar() async throws {
// Se ejecuta después de insertar
}
@Test("Eliminar registro")
func eliminar() async throws {
// Se ejecuta al final
}
}
Eso sí, úsalo con moderación. La ejecución en paralelo es una de las grandes ventajas de Swift Testing, y serializar tests innecesariamente ralentiza tu suite entera.
Probando código asíncrono
Una de las áreas donde Swift Testing realmente brilla es en probar código asíncrono. El framework fue diseñado con la concurrencia de Swift como base, así que las funciones de test pueden ser async de forma completamente nativa. Nada de expectativas o callbacks raros.
Tests async básicos
import Foundation
protocol ServicioTareas: Sendable {
func obtenerTareas() async throws -> [Tarea]
func guardarTarea(_ tarea: Tarea) async throws
}
struct ServicioTareasRemoto: ServicioTareas {
let urlBase: URL
func obtenerTareas() async throws -> [Tarea] {
let (datos, _) = try await URLSession.shared.data(from: urlBase.appending(path: "tareas"))
return try JSONDecoder().decode([Tarea].self, from: datos)
}
func guardarTarea(_ tarea: Tarea) async throws {
var request = URLRequest(url: urlBase.appending(path: "tareas"))
request.httpMethod = "POST"
request.httpBody = try JSONEncoder().encode(tarea)
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw ErrorTarea.fechaInvalida
}
}
}
// Mock para testing
struct ServicioTareasMock: ServicioTareas {
var tareas: [Tarea]
var debeFallar: Bool = false
func obtenerTareas() async throws -> [Tarea] {
if debeFallar { throw ErrorTarea.tituloVacio }
return tareas
}
func guardarTarea(_ tarea: Tarea) async throws {
if debeFallar { throw ErrorTarea.tituloVacio }
}
}
@Suite("Tests del servicio de tareas")
struct ServicioTareasTests {
@Test("Obtener tareas devuelve la lista correcta")
func obtenerTareas() async throws {
let tareasEsperadas = [
Tarea(titulo: "Tarea 1", prioridad: .alta),
Tarea(titulo: "Tarea 2", prioridad: .baja)
]
let servicio = ServicioTareasMock(tareas: tareasEsperadas)
let resultado = try await servicio.obtenerTareas()
#expect(resultado.count == 2)
#expect(resultado[0].titulo == "Tarea 1")
#expect(resultado[1].prioridad == .baja)
}
@Test("Servicio que falla lanza error")
func servicioFalla() async {
let servicio = ServicioTareasMock(tareas: [], debeFallar: true)
await #expect(throws: ErrorTarea.self) {
try await servicio.obtenerTareas()
}
}
}
Probando actores
Los actores son una pieza fundamental de la concurrencia en Swift, y probarlos con Swift Testing resulta muy natural gracias al soporte nativo de async:
actor CacheTareas {
private var cache: [UUID: Tarea] = [:]
private let servicio: ServicioTareas
init(servicio: ServicioTareas) {
self.servicio = servicio
}
func obtenerTarea(id: UUID) -> Tarea? {
cache[id]
}
func cargarTareas() async throws {
let tareas = try await servicio.obtenerTareas()
for tarea in tareas {
cache[tarea.id] = tarea
}
}
var conteo: Int {
cache.count
}
}
@Suite("Tests del cache de tareas")
struct CacheTareasTests {
@Test("Cargar tareas llena el cache")
func cargarTareas() async throws {
let tareas = [
Tarea(titulo: "Tarea cacheada 1"),
Tarea(titulo: "Tarea cacheada 2")
]
let servicio = ServicioTareasMock(tareas: tareas)
let cache = CacheTareas(servicio: servicio)
try await cache.cargarTareas()
let conteo = await cache.conteo
#expect(conteo == 2)
}
@Test("Obtener tarea por ID después de cargar")
func obtenerTareaPorId() async throws {
let tarea = Tarea(titulo: "Tarea específica")
let servicio = ServicioTareasMock(tareas: [tarea])
let cache = CacheTareas(servicio: servicio)
try await cache.cargarTareas()
let resultado = await cache.obtenerTarea(id: tarea.id)
try #require(resultado != nil)
#expect(resultado?.titulo == "Tarea específica")
}
}
Confirmaciones: Verificando eventos asíncronos
A veces necesitas verificar que un evento ocurrió dentro de un contexto asíncrono — por ejemplo, un callback, un handler de delegado o un evento de notificación. Para eso Swift Testing ofrece la API de confirmation, y es más útil de lo que parece a primera vista.
Confirmación básica
protocol ObservadorTareas: AnyObject {
func tareaCompletada(_ tarea: Tarea)
func tareaCreada(_ tarea: Tarea)
}
class GestorTareasObservable {
weak var observador: ObservadorTareas?
private(set) var tareas: [Tarea] = []
func agregar(titulo: String, prioridad: Tarea.Prioridad = .media) {
let tarea = Tarea(titulo: titulo, prioridad: prioridad)
tareas.append(tarea)
observador?.tareaCreada(tarea)
}
func completar(indice: Int) {
guard indice < tareas.count else { return }
tareas[indice].completar()
observador?.tareaCompletada(tareas[indice])
}
}
@Test("El observador recibe notificación al crear tarea")
func notificacionAlCrear() async {
let gestor = GestorTareasObservable()
await confirmation("Se notificó la creación") { confirmar in
// Creamos un observador que llama a confirmar()
let observador = ObservadorMock(alCrear: { _ in confirmar() })
gestor.observador = observador
gestor.agregar(titulo: "Nueva tarea")
}
}
Confirmaciones con rangos (Swift 6.1+)
A veces no sabes exactamente cuántas veces se disparará un evento. Las confirmaciones con rangos te permiten especificar un rango aceptable:
@Test("Eventos de progreso se disparan varias veces")
func eventosDeProgreso() async {
let descargador = Descargador()
// Esperamos entre 3 y 10 notificaciones de progreso
await confirmation(
"Progreso notificado",
expectedCount: 3...10
) { progresoRecibido in
descargador.alProgresar = { _ in
progresoRecibido()
}
await descargador.descargarArchivo(url: urlDePrueba)
}
}
Esto es tremendamente útil para código donde el número exacto de callbacks puede variar — actualizaciones de progreso de descarga, eventos de UI, procesos por lotes... ese tipo de situaciones donde ser demasiado específico haría tus tests frágiles.
Exit Tests: Verificando fallos fatales (Swift 6.2)
Llevábamos años pidiendo algo así. Los exit tests son una de las novedades más esperadas de Swift 6.2. Hasta ahora, simplemente no había forma de verificar que tu código crashea correctamente cuando debe hacerlo — por ejemplo, cuando llamas a fatalError() o preconditionFailure(). Los exit tests resuelven esto de forma elegante, creando un subproceso que ejecuta el código y observa si termina como se espera.
struct Configuracion {
let maxTareas: Int
init(maxTareas: Int) {
precondition(maxTareas > 0, "maxTareas debe ser mayor que 0")
self.maxTareas = maxTareas
}
}
@Test("Configuración con maxTareas = 0 produce fatalError")
func configuracionInvalida() async {
await #expect(processExitsWith: .failure) {
_ = Configuracion(maxTareas: 0)
}
}
// También puedes observar la salida estándar
@Test("El mensaje de error es descriptivo")
func mensajeDeError() async {
let resultado = await #expect(
processExitsWith: .failure,
observing: [\.standardErrorContent]
) {
_ = Configuracion(maxTareas: -5)
}
if let resultado {
// Verificar que el mensaje de error contiene información útil
let salida = String(decoding: resultado.standardErrorContent, as: UTF8.self)
#expect(salida.contains("maxTareas"))
}
}
Nota importante: Los exit tests solo funcionan en macOS, Linux, FreeBSD, OpenBSD y Windows. No están disponibles en iOS, ya que requieren la creación de subprocesos. Si necesitas probar este tipo de fallos para código iOS, tendrás que ejecutar esos tests específicos en el simulador de macOS.
Test Scoping: Setup y teardown reutilizables (Swift 6.1+)
El test scoping es una funcionalidad avanzada que resuelve un dolor real: crear mecanismos reutilizables de configuración y limpieza. En lugar de duplicar lógica de setup en cada suite (algo que todos hemos hecho alguna vez), defines un trait personalizado que inyecta el entorno necesario.
import Testing
struct ConBaseDeDatosTemporal: TestTrait, SuiteTrait, TestScoping {
func provideScope(
for test: Test,
testCase: Test.Case?,
performing function: @Sendable () async throws -> Void
) async throws {
// Setup: Crear base de datos temporal
let db = try BaseDeDatosTemporal()
try db.migrar()
// Inyectar en el contexto del test
BaseDeDatosContext.actual = db
// Ejecutar el test
try await function()
// Teardown: Limpiar la base de datos
try db.eliminar()
BaseDeDatosContext.actual = nil
}
}
// Uso del trait personalizado
@Suite("Tests de persistencia", .serialized)
@ConBaseDeDatosTemporal()
struct PersistenciaTests {
@Test("Guardar y recuperar tarea")
func guardarYRecuperar() async throws {
let db = try #require(BaseDeDatosContext.actual)
let tarea = Tarea(titulo: "Persistida")
try await db.guardar(tarea)
let recuperada = try await db.obtener(id: tarea.id)
#expect(recuperada?.titulo == "Persistida")
}
}
Este patrón es particularmente potente para tests de integración donde necesitas configurar y limpiar recursos externos como bases de datos, servidores mock o sistemas de archivos temporales. Una vez que lo implementas, se convierte en algo que usas en todos tus proyectos.
withKnownIssue: Gestionando problemas conocidos
Todos hemos estado ahí: un test que falla por un bug conocido que aún no se ha resuelto. En proyectos grandes, esto pasa constantemente. En lugar de deshabilitar el test por completo (y arriesgarte a olvidarte de él para siempre), withKnownIssue te permite documentar el fallo de forma explícita:
@Test("Sincronización bidireccional")
func sincronizacionBidireccional() async throws {
withKnownIssue("El servidor de staging tiene un bug en la API de sync — IOS-5432") {
let resultado = try await sincronizador.sincronizar()
#expect(resultado.conflictos.isEmpty)
}
}
El test se ejecuta, el fallo se registra como conocido, pero no rompe tu pipeline de CI. Y aquí viene lo mejor: cuando el bug se resuelva y el test empiece a pasar, recibirás un aviso de que el known issue ya no aplica. Básicamente, el framework te avisa cuando puedes limpiar.
Migrando desde XCTest
Si ya tienes tests escritos con XCTest, la buena noticia es que puedes migrar gradualmente. Ambos frameworks coexisten en el mismo target, e incluso en el mismo archivo. No hay necesidad de hacer una migración big-bang.
Tabla de equivalencias
Aquí tienes una referencia rápida de cómo traducir los conceptos principales de XCTest a Swift Testing:
// ─── XCTest ───────────────────────────────────
import XCTest
class MiTestCase: XCTestCase {
var sut: GestorTareas!
override func setUp() {
sut = GestorTareas()
}
override func tearDown() {
sut = nil
}
func testAgregarTarea() throws {
let tarea = try sut.agregar(titulo: "Test")
XCTAssertEqual(sut.tareas.count, 1)
XCTAssertEqual(tarea.titulo, "Test")
XCTAssertFalse(tarea.estaCompletada)
}
func testTituloVacioLanzaError() {
XCTAssertThrowsError(try sut.agregar(titulo: "")) { error in
XCTAssertEqual(error as? ErrorTarea, .tituloVacio)
}
}
}
// ─── Swift Testing ────────────────────────────
import Testing
@Suite("Gestor de Tareas")
struct GestorTareasTests {
var gestor = GestorTareas()
@Test("Agregar tarea")
mutating func agregarTarea() throws {
let tarea = try gestor.agregar(titulo: "Test")
#expect(gestor.tareas.count == 1)
#expect(tarea.titulo == "Test")
#expect(!tarea.estaCompletada)
}
@Test("Título vacío lanza error")
func tituloVacioLanzaError() {
var g = GestorTareas()
#expect(throws: ErrorTarea.tituloVacio) {
try g.agregar(titulo: "")
}
}
}
Qué NO se puede migrar (todavía)
Hay algunas cosas que Swift Testing no soporta y debes mantener en XCTest:
- Tests de UI (XCUITest) — Swift Testing es solo para tests unitarios y de integración.
- Tests de rendimiento con
XCTMetricymeasure { }— No hay equivalente en Swift Testing por ahora. - Anotación
@MainActorimplícita — XCTest ejecuta tests síncronos en el actor principal por defecto. En Swift Testing, los tests pueden ejecutarse en cualquier hilo, así que necesitas ser explícito con@MainActorsi tu código lo requiere.
Y una regla fundamental: no mezcles frameworks dentro de un mismo test. No llames a XCTAssert desde un test de Swift Testing, ni uses #expect desde un XCTestCase. Parece obvio, pero he visto que pasa más de lo que pensarías.
Probando modelos de SwiftData
Si seguiste nuestra guía de SwiftData en iOS 26, ahora es el momento perfecto para aprender a probar esos modelos. La combinación de Swift Testing con SwiftData es bastante natural:
import Testing
import SwiftData
// Modelo de SwiftData (de nuestra guía anterior)
@Model
class TareaPersistente {
var titulo: String
var descripcion: String
var estaCompletada: Bool
var prioridad: Int
var fechaCreacion: Date
init(titulo: String, descripcion: String = "", prioridad: Int = 1) {
self.titulo = titulo
self.descripcion = descripcion
self.estaCompletada = false
self.prioridad = prioridad
self.fechaCreacion = Date()
}
}
@Suite("Tests de SwiftData", .serialized)
struct SwiftDataTests {
let container: ModelContainer
let context: ModelContext
init() throws {
// Usar almacenamiento en memoria para tests
let config = ModelConfiguration(isStoredInMemoryOnly: true)
container = try ModelContainer(
for: TareaPersistente.self,
configurations: config
)
context = ModelContext(container)
}
@Test("Insertar y recuperar tarea")
func insertarYRecuperar() throws {
let tarea = TareaPersistente(titulo: "Test SwiftData", prioridad: 2)
context.insert(tarea)
try context.save()
let descriptor = FetchDescriptor()
let tareas = try context.fetch(descriptor)
#expect(tareas.count == 1)
#expect(tareas.first?.titulo == "Test SwiftData")
#expect(tareas.first?.prioridad == 2)
}
@Test("Filtrar tareas por prioridad")
func filtrarPorPrioridad() throws {
context.insert(TareaPersistente(titulo: "Baja", prioridad: 0))
context.insert(TareaPersistente(titulo: "Alta", prioridad: 2))
context.insert(TareaPersistente(titulo: "Urgente", prioridad: 3))
try context.save()
let descriptor = FetchDescriptor(
predicate: #Predicate { $0.prioridad >= 2 }
)
let tareasAltas = try context.fetch(descriptor)
#expect(tareasAltas.count == 2)
}
@Test("Eliminar tarea")
func eliminarTarea() throws {
let tarea = TareaPersistente(titulo: "Para eliminar")
context.insert(tarea)
try context.save()
context.delete(tarea)
try context.save()
let descriptor = FetchDescriptor()
let tareas = try context.fetch(descriptor)
#expect(tareas.isEmpty)
}
}
El truco clave aquí es usar ModelConfiguration(isStoredInMemoryOnly: true) para que cada suite de tests trabaje con una base de datos fresca en memoria. Sin contaminar datos entre ejecuciones, sin archivos SQLite flotando por ahí. Limpio y predecible.
Buenas prácticas y patrones recomendados
Después de explorar todas las funcionalidades de Swift Testing, vamos a recopilar las buenas prácticas que realmente marcan la diferencia en proyectos reales:
1. Prefiere structs como suites
Las structs como suites te garantizan aislamiento de estado. Cada test recibe su propia instancia, eliminando la posibilidad de compartir estado accidentalmente entre tests. Créeme, este tipo de bugs son los más difíciles de diagnosticar.
2. Usa #require para precondiciones críticas
No desperdicies tiempo de ejecución verificando aserciones que dependen de una precondición fallida. Si el setup falla, usa #require para abortar inmediatamente con un mensaje claro. Tu yo futuro te lo agradecerá.
3. Aprovecha los tests parametrizados
En lugar de escribir cinco tests casi idénticos que prueban diferentes inputs, escribe uno parametrizado. Es más mantenible, más legible y ejecuta cada caso de forma independiente.
4. Etiqueta tus tests con tags
Los tags te permiten ejecutar subconjuntos de tests desde Xcode o desde CI. Por ejemplo, podrías tener tags como .rapido, .integracion, .red, y filtrar según el contexto.
5. Evita .serialized innecesariamente
La ejecución en paralelo es una de las mayores ventajas de Swift Testing. Solo usa .serialized cuando los tests realmente comparten un recurso que no puede ser concurrente. Si puedes refactorizar para eliminar la dependencia, hazlo — vale la pena el esfuerzo.
6. Tests async por defecto
Incluso si tu código no es asíncrono ahora mismo, considera si tus tests podrían beneficiarse de ser async para aprovechar la concurrencia del framework. El overhead es mínimo y la ganancia en velocidad de ejecución puede ser notable.
7. Nombres descriptivos con la macro @Test
Aprovecha el parámetro de nombre de @Test para escribir descripciones legibles. En lugar de nombres de funciones crípticos, usa descripciones como @Test("La sincronización reintenta 3 veces antes de fallar"). Cuando un test falla en CI a las 3 de la mañana, vas a agradecer saber exactamente qué se supone que debía hacer.
Integración con CI/CD
Swift Testing se integra de forma transparente con los pipelines de CI/CD. Si usas Xcode Cloud, GitHub Actions o cualquier otra plataforma, los tests de Swift Testing se ejecutan junto con los de XCTest usando el mismo comando xcodebuild test:
xcodebuild test \
-scheme TareasApp \
-destination "platform=iOS Simulator,name=iPhone 16" \
-resultBundlePath TestResults.xcresult
Un cambio pragmático en Xcode 26: las runtime issues se reportan como advertencias por defecto en lugar de fallos. Esto evita que tests que antes pasaban fallen repentinamente al actualizar Xcode — algo que todos hemos sufrido alguna vez. Si quieres ser más estricto en tests específicos, puedes configurarlos para que traten las runtime issues como errores.
Conclusión: El futuro de las pruebas en Swift
Swift Testing representa un salto generacional en la experiencia de testing para las plataformas de Apple. Con su API expresiva basada en macros, soporte nativo de concurrencia, tests parametrizados y un sistema de traits extensible, escribir y mantener tests se ha convertido en algo genuinamente agradable. Y no digo esto a la ligera — cualquiera que haya pasado tardes peleándose con XCTest sabe lo que significa.
Las novedades de Swift 6.2 — exit tests, confirmaciones con rangos y el nuevo manejo de runtime issues — demuestran que Apple sigue invirtiendo fuertemente en este framework. Y lo mejor: no necesitas migrar todo de golpe. Puedes empezar a escribir nuevos tests con Swift Testing hoy mismo mientras mantienes los tests existentes de XCTest funcionando perfectamente.
Si seguiste nuestras guías anteriores sobre concurrencia en Swift 6.2 y SwiftData en iOS 26, ahora tienes las herramientas para construir aplicaciones robustas con código asíncrono, persistencia moderna y tests completos. El siguiente paso natural es aplicar lo aprendido aquí en tus proyectos reales. Las herramientas están listas — solo falta que te lances.