Mi Problema 🤔
Estoy escribiendo tests con Swift Testing, el stack nativo de Apple. Comparo dos modelos con #expect(a == b) y verifico errores con #expect(throws: MyError.self) — la API que la propia librería ofrece de serie. Lanzo los tests. Y mi binario muere con esto:
*** Signal 10: Backtracing from 0x18a3c4e8c... done ***
Process terminated by signal 10 (SIGBUS)
Stack trace:
...
_swift_buildDemanglingForMetadata
...
swift_testing.Issue.record(...)
SIGBUS. No es un fallo de aserción. Es el runtime de Swift rompiéndose dentro del propio test runner. Y solo pasa cuando un test falla — si todos pasan, no me entero del problema.
El patrón es muy concreto. El runtime crashea cuando uso #expect(==) sobre enums con valores asociados, o cuando uso #expect(throws:) sobre un tipo de error que es un enum con valores asociados. En cuanto el test falla, swift-testing intenta construir el mensaje de error pidiéndole al runtime que reconstruya los metadatos de tipo de los valores que comparó. Y ahí truena: _swift_buildDemanglingForMetadata, signal 10, fin.
Es el bug swiftlang/swift#76608, abierto desde septiembre de 2024 y reproducible en Swift 6.3.1 sobre macOS 26 — tanto en debug como en release.
Lo curioso es que el camino feliz no toca nunca este bug. Si la comparación pasa, no hay mensaje de error que formatear, no hay Mirror, no hay SIGBUS. El bug solo aparece justo cuando más necesitas el output del test: cuando algo falla. Y no podía mover una pieza hasta resolverlo.
Mi Solución 🧩
La pista clave es dónde crashea swift-testing: en el camino del failure message, formateando los valores. Si yo hago la comparación por mi cuenta y le paso al reporter solamente un Bool, swift-testing no tiene nada que reconstruir. No hay metadata que reflejar. El espejo no se rompe porque no se llega a usar.
El truco que evita el SIGBUS cabe en dos líneas:
let isEqual = actual == expected
#expect(isEqual)
Esa es toda la idea. La igualdad la calculo yo, swift-testing solo recibe un Bool ya resuelto. Sin valores que reflejar, no hay Mirror, no hay SIGBUS. El precio es perder los valores en el mensaje de fallo — pero eso lo recupero formateando el texto a mano antes de pasarlo a Issue.record.
A partir de ahí monté un paquete TestKit con tres helpers que aplican esa misma idea para los tres casos en los que tropezaba con el bug:
- expectEqual(actual, expected) — comparación de cualquier Equatable: structs, modelos, enums con valores asociados. Sustituye a #expect(a == b) en todos los tests del monorepo.
- expectThrows(E.self) { … } — verifica que un closure lanza un error del tipo esperado. Hace do/catch a mano y comprueba con catch is E — solo el tipo, nunca el valor. Cubre lo que #expect(throws:) intentaba dar de serie. Tiene overload sync y async.
- expectEqualLines(actual, expected) — diff línea a línea para verificar SQL generado y otros snapshots inline. La comparación también se reduce a Bool antes de tocar el reporter.
Tres helpers, una sola idea: calcular el resultado fuera del macro, pasar solo el booleano. Cuando algún día Swift 6.4 cierre el issue 76608, basta con sustituir el cuerpo de los helpers por #expect directos y la suite ni se entera.
Hay un detalle adicional para que los mensajes de fallo sigan siendo legibles: conformé los tipos de error y schema a CustomStringConvertible con un switch estático sobre los casos — nunca interpolando (self), porque eso volvería a invocar Mirror y reabriría la trampa.
Mi Resultado 🎯
Mi suite de tests vuelve a correr sin SIGBUS y con mensajes de error suficientemente legibles. La regla que aplico ahora en todo el monorepo:
- expectEqual(a, b) para cualquier comparación entre structs, enums o modelos
- expectThrows(E.self) { … } para verificar el tipo de error lanzado
- #expect(…) directo solo cuando el argumento ya es Bool, nil o contains — ahí no hay metadata de valores que reflejar y el bug no se dispara
Los beneficios:
- Suite que no crashea — los fallos cuentan como fallos, no como abortos del proceso
- Stack mínimo — solo dependo de Testing y del propio toolchain de Swift
- Mensajes propios — controlo cómo se imprime cada lado al fallar, gracias a CustomStringConvertible
- Aislamiento del bug del runtime — cuando Swift 6.4 lo solucione, basta con cambiar el cuerpo de los helpers y la suite ni se entera
La lección que me llevo: cuando una herramienta del runtime se rompe en el camino del error, la solución no es renunciar al lenguaje, es no dejar que la herramienta entre por ese camino. Hago yo la comparación, le paso un booleano al reporter, y el mirror se queda intacto.
Keep coding, keep running 🏃♂️