Testing y tooling en iOS para seniors: Swift Testing, snapshots, mocks, Instruments y tiempos de compilación
Cómo testear una app iOS viniendo de Jest y Testing Library: Swift Testing frente a XCTest, snapshot testing, mocks vía protocolos y URLProtocol sin jest.mock, XCUITest frente a Maestro, Instruments, SwiftLint y cómo modularizar para bajar los tiempos de compilación.
En React Native, testear era Jest para la lógica, Testing Library para los componentes, jest.mock para cualquier módulo, Detox o Maestro para el flujo completo, y Flipper o el DevTools de React para mirar dentro. En iOS el reparto es parecido, pero cada pieza tiene una restricción que cambia cómo se diseña el código: no hay jest.mock, así que los mocks entran por protocolos o por la capa de red; no hay snapshots de árbol de componentes, hay snapshots de píxeles; y el tiempo de compilación es un factor de diseño, no un detalle de tooling. Este post cubre la caja de herramientas completa de un senior en iOS: Swift Testing frente a XCTest, snapshot testing, mocks, tests de UI con XCUITest o Maestro, Instruments para rendimiento, SwiftLint, y la arquitectura que mantiene los tiempos de compilación bajos.
Resumen para perezosos
- Swift Testing (
@Test,#expect, tests parametrizados) es el framework para código nuevo; XCTest sigue siendo necesario para tests de UI y de rendimiento. Los dos conviven en el mismo target. - Sin
jest.mock, los mocks entran por diseño: un protocolo por dependencia con una implementación falsa, yURLProtocolpara interceptar la red sin tocar el cliente. Los snapshots comparan imágenes renderizadas, y son el test más barato para una vista de SwiftUI. - Los tiempos de compilación se bajan con arquitectura: packages pequeños, tipos explícitos en expresiones largas, y Tuist con caché. Instruments dice dónde se va el tiempo de la app; el compilador, con flags, dice dónde se va el tiempo del build.
En este artículo:
- Tests unitarios — Swift Testing frente a XCTest · Mocks sin jest.mock · Snapshot testing
- Tests de UI — XCUITest frente a Maestro
- Tooling — Instruments · SwiftLint y formato · Tiempos de compilación
Swift Testing frente a XCTest
XCTest es el framework de siempre: clases que heredan de XCTestCase, métodos que empiezan con test, y aserciones XCTAssertEqual. Swift Testing llegó en 2024 con Xcode 16 como el framework moderno: funciones sueltas marcadas con @Test, una sola macro de aserción #expect que muestra cada subexpresión al fallar, tests parametrizados que corren un caso por argumento, y struct en vez de clases, con lo que cada test recibe una instancia nueva sin setUp.
import Testing
@testable import Domain
struct PriceFormatterTests {
@Test func formatsWholeAmounts() {
#expect(PriceFormatter.format(12, currency: "USD") == "$12.00")
}
@Test(arguments: [
(Decimal(0.5), "$0.50"),
(Decimal(1234.5), "$1,234.50"),
(Decimal(-3), "-$3.00"),
])
func formatsEdgeCases(amount: Decimal, expected: String) {
#expect(PriceFormatter.format(amount, currency: "USD") == expected)
}
@Test func rejectsUnknownCurrency() throws {
#expect(throws: PriceFormatter.Error.unknownCurrency) {
try PriceFormatter.format(1, currency: "XYZ", strict: true)
}
}
}
Lo que cambia respecto a Jest en la práctica:
#expect(a == b)reemplaza aexpect(a).toBe(b), y al fallar imprime el valor deay debsin que escribas un mensaje.#requirees lo mismo pero aborta el test si falla, útil para desenvolver opcionales:let user = try #require(result.user).- Los tests parametrizados reemplazan a
it.each, y cada caso aparece por separado en el navegador de Xcode, con su propio resultado. - Los tests son
asynccuando hace falta, sinwaitForExpectations. Un@Test func loads() async throwsespera lo que tenga que esperar. - Los tests corren en paralelo por defecto, en el mismo proceso. Si dos tests comparten estado global (un singleton,
UserDefaults), fallan de forma intermitente..serializeden un@Suitelos pone en serie, pero la mejor respuesta es no tener estado global, que es lo que la inyección de dependencias del post de arquitectura ya resolvía.
Lo que Swift Testing todavía no hace y XCTest sí: tests de UI (XCUIApplication) y tests de rendimiento (measure { } con métricas). Los dos frameworks conviven en el mismo target, así que la regla es simple: Swift Testing para todo lo nuevo, XCTest para UI y rendimiento.
Mocks sin jest.mock: protocolos y URLProtocol
jest.mock("./api") reemplazaba un módulo entero en el test sin tocar el código. En Swift no existe: el compilador enlaza tipos concretos y no hay un sistema de módulos que se pueda interceptar en tiempo de ejecución. Los mocks entran por dos caminos, y los dos son decisiones de diseño.
Un protocolo por dependencia, con una implementación falsa. Es la razón por la que el post de arquitectura insistía en inyectar desde el primer día:
protocol OrderRepository: Sendable {
func fetchAll() async throws -> [Order]
}
// En el target de tests:
final class FakeOrderRepository: OrderRepository, @unchecked Sendable {
var result: Result<[Order], Error> = .success([])
private(set) var fetchCount = 0
func fetchAll() async throws -> [Order] {
fetchCount += 1
return try result.get()
}
}
@Test @MainActor
func showsErrorWhenRepositoryFails() async {
let repository = FakeOrderRepository()
repository.result = .failure(URLError(.notConnectedToInternet))
let model = OrdersViewModel(repository: repository)
await model.load()
#expect(model.error != nil)
#expect(repository.fetchCount == 1)
}
Con swift-dependencies, el mismo test se escribe con withDependencies { $0.orderClient.fetchAll = { throw URLError(.notConnectedToInternet) } } y no hace falta la clase falsa. Con Factory, Container.shared.orderRepository.register { FakeOrderRepository() }. La estructura es la misma: la dependencia se reemplaza desde fuera, no desde dentro del módulo.
Para generar las implementaciones falsas sin escribirlas, hay macros y herramientas (Mockable, Cuckoo, Sourcery con plantillas) que producen un mock por protocolo con registro de llamadas. Para un proyecto mediano, escribirlas a mano suele ser menos trabajo que mantener la herramienta.
URLProtocol para interceptar la red. Cuando quieres testear el APIClient real (decodificación, manejo de códigos HTTP, reintentos) sin red, una subclase de URLProtocol registrada en la URLSessionConfiguration intercepta cada petición y devuelve lo que el test diga. Es el equivalente de msw o nock:
final class StubURLProtocol: URLProtocol {
nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
guard let handler = Self.handler else { return }
do {
let (response, data) = try handler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() {}
}
@Test func decodesOrdersFromServer() async throws {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [StubURLProtocol.self]
StubURLProtocol.handler = { request in
#expect(request.url?.path == "/orders")
let body = #"[{"id":"1","total":"12.50","created_at":"2026-09-01T00:00:00Z","items":[]}]"#
return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, Data(body.utf8))
}
let client = APIClient(baseURL: URL(string: "https://api.test")!, session: URLSession(configuration: config), decoder: .api, tokenProvider: { nil })
let orders: [OrderDTO] = try await client.get("/orders")
#expect(orders.count == 1)
#expect(orders[0].total == 12.50)
}
Con esto, el cliente real se testea con respuestas grabadas del backend, incluidos los errores 4xx y 5xx, sin ningún mock en el código de producción.
Snapshot testing: la forma barata de testear una vista
Testing Library renderizaba un componente en un DOM simulado y consultaba por rol o texto. En SwiftUI no hay un DOM que consultar, y aunque existen librerías que inspeccionan el árbol de vistas (ViewInspector), son frágiles frente a cambios de SwiftUI. La forma que se sostiene es el snapshot testing de imágenes: renderizar la vista a un PNG y compararlo con una referencia guardada en el repositorio.
La librería estándar es swift-snapshot-testing (Point-Free), que se integra con XCTest y con Swift Testing:
import SnapshotTesting
import SwiftUI
import Testing
@testable import OrdersFeature
@MainActor
struct OrderRowSnapshotTests {
@Test func rendersPendingOrder() {
let view = OrderRow(order: .fixture(status: .pending))
.frame(width: 390)
assertSnapshot(of: view, as: .image(layout: .sizeThatFits, traits: .init(userInterfaceStyle: .light)))
assertSnapshot(of: view, as: .image(layout: .sizeThatFits, traits: .init(userInterfaceStyle: .dark)), named: "dark")
assertSnapshot(
of: view,
as: .image(layout: .sizeThatFits, traits: .init(preferredContentSizeCategory: .accessibilityExtraLarge)),
named: "a11y-xl"
)
}
}
La primera ejecución graba la referencia; las siguientes comparan. Tres variantes por vista (clara, oscura, letra grande) cuestan tres líneas y atrapan la mayoría de las regresiones visuales, incluidas las que rompen Dynamic Type, que era el punto del post de UI.
Las reglas para que no se vuelvan un problema:
- Mismo simulador en local y en CI. Un cambio de versión de iOS o de modelo cambia el render y todos los snapshots fallan. Se fija el simulador en el script de tests, y se regraban en un PR aparte cuando se actualiza.
- Datos fijos. Fechas, nombres, montos: todo del fixture, nada de
Date.now. - Vistas pequeñas. Una fila, una tarjeta, un formulario. Un snapshot de una pantalla completa falla por cualquier cambio y no dice cuál.
- Precisión con tolerancia (
precision: 0.99) para absorber diferencias de antialiasing entre máquinas.
Los snapshots también sirven para otros tipos: as: .json para un Codable, as: .dump para cualquier valor, as: .lines para texto. El de una respuesta de API decodificada es el que más veces me ha avisado de un cambio de contrato con el backend.
XCUITest frente a Maestro
XCUITest es el framework de Apple para tests de UI: un proceso aparte que lanza la app, la controla por accesibilidad (etiquetas, identificadores) y afirma sobre lo que ve. Se escribe en Swift con XCTest, corre desde Xcode o xcodebuild test, y puede grabar interacciones desde el editor.
final class CheckoutUITests: XCTestCase {
func testCompletesCheckout() {
let app = XCUIApplication()
app.launchArguments = ["-ui-testing", "-seed-cart"] // la app arranca con datos fijos
app.launch()
app.buttons["cart.checkout"].tap()
app.textFields["checkout.email"].tap()
app.textFields["checkout.email"].typeText("ana@example.com")
app.buttons["checkout.pay"].tap()
XCTAssertTrue(app.staticTexts["checkout.confirmation"].waitForExistence(timeout: 5))
}
}
Maestro es el que ya conoces de React Native: flujos en YAML, sin código, que se ejecutan contra el simulador o dispositivo. Funciona igual con una app nativa, porque habla con la app por accesibilidad, no por el runtime:
appId: com.empresa.app
---
- launchApp:
arguments:
ui-testing: true
seed-cart: true
- tapOn:
id: "cart.checkout"
- tapOn:
id: "checkout.email"
- inputText: "ana@example.com"
- tapOn:
id: "checkout.pay"
- assertVisible:
id: "checkout.confirmation"
Cuándo cada uno:
| Criterio | XCUITest | Maestro |
|---|---|---|
| Quién los escribe | Desarrolladores, en Swift | QA o desarrolladores, en YAML |
| Velocidad de ejecución | Lenta; cada test lanza la app | Más rápida por flujo; menos configuración |
| Estabilidad | Buena con identificadores y waitForExistence | Buena; reintentos y esperas integrados |
| Integración con Xcode | Total: resultados, capturas y grabación en el navegador de tests | Externa: CLI y Maestro Cloud |
| Acceso al sistema | Puede interactuar con alertas de permisos, Ajustes y otras apps | Limitado a la app y a alertas comunes |
| Rendimiento y métricas | XCTMetric para tiempo de arranque y scroll | No |
En la práctica uso los dos: Maestro para los flujos de humo que corren en cada PR (arrancar, iniciar sesión, llegar a la pantalla principal, completar un checkout), porque son rápidos de escribir y de leer; XCUITest para lo que requiere el sistema (flujos de permisos, deep links desde otra app, notificaciones) y para métricas de rendimiento. En los dos casos, lo que hace estable un test de UI es lo mismo: identificadores de accesibilidad (.accessibilityIdentifier("checkout.pay")) en cada control con el que se interactúa, y un modo de la app que arranca con datos fijos y sin red.
Instruments: dónde se va el tiempo de la app
Flipper y el profiler de React DevTools no tienen equivalente directo porque el problema es otro: no hay un puente ni un hilo de JavaScript que mirar. Lo que hay es Instruments, la herramienta de perfilado del sistema, que se abre desde Xcode con Product > Profile y ofrece plantillas para cada tipo de problema:
- Time Profiler: dónde se va el tiempo de CPU, por hilo y por función. El primer sitio para un scroll que se traba o un arranque lento.
- SwiftUI: cuántas veces se evalúa cada
body, cuánto tarda, y qué cambio de estado lo disparó. Es el que responde “¿por qué esta vista se re-evalúa tanto?”, y la respuesta suele ser un@Observablecuya propiedad se lee en unbodyque no la necesita. - Allocations y Leaks: memoria retenida y ciclos de referencia. En Swift los ciclos aparecen con closures que capturan
selfsin[weak self]en clases de larga vida. - Network: cada petición con tiempos, tamaño y estado.
- Animation Hitches: cuadros perdidos en animaciones y scroll, con la causa (layout, render, commit).
- App Launch: fases del arranque, para el tiempo hasta el primer cuadro.
Dos prácticas que cambian el resultado: perfilar en un dispositivo físico y en un build de Release (el simulador y Debug dan tiempos que no se parecen a producción), y usar os_signpost en el código para marcar los intervalos que te importan ("load orders", "decode") y verlos en la línea de tiempo de Instruments junto al resto.
Para producción, MetricKit entrega diagnósticos agregados de arranque, cuadros perdidos, consumo de batería y crashes sin SDK de terceros, y Xcode Organizer muestra los mismos datos por versión para las apps publicadas. Un crash reporter (Crashlytics, Sentry) sigue siendo necesario para stack traces individuales con contexto.
SwiftLint y swift-format
ESLint y Prettier tienen sus equivalentes, con una diferencia: en Swift el formato y el lint son dos herramientas separadas y ninguna viene activada por defecto.
SwiftLint es el linter: reglas de estilo y de corrección (force_unwrapping, unused_closure_parameter, cyclomatic_complexity), con un .swiftlint.yml y un plugin de build para que los avisos aparezcan en Xcode. Las reglas que activo siempre, además de las que vienen por defecto:
opt_in_rules:
- force_unwrapping # el `!` que el post de Swift pedía evitar
- implicitly_unwrapped_optional
- unowned_variable_capture
- private_outlet
- sorted_imports
- explicit_init
disabled_rules:
- line_length # lo maneja el formateador
- todo
swift-format es el formateador, mantenido por Apple e integrado en el toolchain desde Swift 6, con swift format como subcomando. Se configura con .swift-format y se ejecuta en un hook de pre-commit o en CI con --lint. Antes de que viniera integrado, SwiftFormat (de Nick Lockwood, sin guion) era el estándar, y sigue siendo más configurable; los dos funcionan.
La combinación que uso: swift-format para que nadie discuta de formato, SwiftLint para las reglas de corrección, y los dos en CI fallando el PR. Lo mismo que ESLint más Prettier, con un archivo más.
Tiempos de compilación: cómo medirlos y cómo bajarlos
El primer post prometía que este tema tendría su sección, porque es la queja más frecuente de quien viene de Fast Refresh. Un build limpio de un proyecto mediano puede tardar varios minutos; un build incremental después de tocar un archivo, de segundos a un minuto según lo que ese archivo arrastre. La diferencia entre esos dos extremos es arquitectura.
Cómo medir. Xcode muestra el tiempo del último build en la barra de estado, y Product > Perform Action > Build With Timing Summary desglosa por fase. Para encontrar las expresiones que tardan, dos flags en Other Swift Flags del target:
-Xfrontend -warn-long-function-bodies=100
-Xfrontend -warn-long-expression-type-checking=100
Producen un warning por cada función o expresión cuyo chequeo de tipos supere cien milisegundos. Casi siempre son las mismas dos cosas: expresiones largas con literales sin tipo (let total = a * 0.2 + b * 1.5 - c / 3) y body de SwiftUI con muchas ramas. La solución es anotar tipos (let total: Double = ...) y partir el body en subvistas.
Cómo bajar el incremental. Swift recompila el módulo entero cuando cambia la interfaz de un tipo que otros archivos usan. Con todo en un target, cualquier cambio en un tipo compartido recompila casi todo. Con packages por capa, un cambio en OrdersFeature recompila OrdersFeature y la app, y nada más. Es el argumento de rendimiento para la modularización que el post de arquitectura daba por otros motivos, y en un proyecto grande es el que más se nota.
Las palancas, en orden de impacto:
- Packages pequeños con dependencias en una dirección. Dominio sin dependencias, red y persistencia sobre dominio, features sobre los tres. Un cambio en una feature no recompila ninguna otra.
internalpor defecto,publicsolo en la interfaz. Menos superficie pública, menos módulos que dependen de un cambio.- Tipos explícitos en expresiones largas y
bodypartidos en vistas pequeñas. Lo que los flags de arriba señalan. - Tuist con caché de módulos para que los packages sin cambios lleguen compilados. En CI, la diferencia entre un build limpio de varios minutos y uno de uno o dos.
- Previews en el package de la feature, no en la app. Compilan solo ese módulo.
- Menos macros y menos genéricos profundos en caliente. Cada macro se expande en cada compilación; un tipo con diez niveles de genéricos anidados es caro de chequear. Se notan cuando el timing summary los señala, no antes.
El proyecto donde más aprendí de tiempos de compilación no fue el más grande, sino uno de un solo target con un archivo de constantes de diseño que importaban todas las vistas. Cambiar un color recompilaba la app completa. Moverlo a un package de diseño fue una hora de trabajo, y el build incremental de las pantallas pasó de “ir por café” a “esperar sentado”.
Preguntas frecuentes
¿Necesito tests de UI si tengo snapshots y tests de ViewModel?
Menos de los que crees. Los snapshots cubren el render y los tests de ViewModel cubren la lógica; los tests de UI cubren la integración entre pantallas, la navegación real y los flujos del sistema. Unos pocos flujos de humo con Maestro, corriendo en cada PR, dan la mayor parte del valor. Cientos de tests de UI son lentos, frágiles y rara vez atrapan algo que los otros dos niveles no atraparon.
¿Cómo testeo código con @MainActor o con actores?
Los tests de Swift Testing pueden marcarse @MainActor (como en el ejemplo de arriba) y ser async. Para actores propios, se testea el comportamiento observable con await. Para controlar el tiempo (Task.sleep, debounces), se inyecta un Clock y en el test se usa uno de prueba (swift-clocks de Point-Free) que avanza a mano; sin eso, un test de debounce de 300 ms tarda 300 ms.
¿Hay cobertura de código?
Sí, integrada: se activa en el esquema de tests y Xcode muestra la cobertura por archivo y por línea. xcodebuild test -enableCodeCoverage YES la genera en CI, y xccov la exporta a JSON para el reporte. Sin herramientas externas.
¿Cómo corro los tests en CI sin Xcode abierto?
xcodebuild test -scheme App -destination 'platform=iOS Simulator,name=iPhone 16', o fastlane scan, que envuelve lo mismo con mejor salida. Para packages sin dependencia de UIKit, swift test desde la línea de comandos es más rápido porque no levanta el simulador. Con Tuist, tuist test corre solo los módulos afectados por el cambio.
¿Vale la pena TDD en SwiftUI?
En la lógica (ViewModels, dominio, parsers), sí y funciona igual que en cualquier lenguaje. En las vistas, no: el ciclo de escribir un snapshot antes de la vista no aporta nada, porque el snapshot es la vista. Lo que sí funciona en las vistas es diseñar con Previews y datos falsos, y grabar el snapshot cuando la Preview se ve bien.
Conclusión
La caja de herramientas de testing en iOS no es más pobre que la de React Native, pero exige más diseño: sin jest.mock, los mocks entran por protocolos inyectados o por URLProtocol, y eso obliga a una arquitectura que en JavaScript podías posponer. Swift Testing es el framework para todo lo nuevo, con XCTest para UI y rendimiento. Los snapshots de imagen son el test más barato para una vista, y con tres variantes cubren modo oscuro y Dynamic Type. Maestro sirve igual que antes para los flujos de humo, y XCUITest para lo que toca el sistema. Instruments responde dónde se va el tiempo de la app, y los flags del compilador más los packages responden dónde se va el tiempo del build.
Para empezar: un target de tests por package con Swift Testing, un FakeRepository por protocolo, un StubURLProtocol para el cliente, tres snapshots por vista, un flujo de humo en Maestro, SwiftLint y swift-format en CI, y los flags de tiempo de compilación activados desde el primer día. El último post cierra la serie con el argumento que la abrió, ahora con detalle: lo que solo puedes hacer en nativo (WidgetKit, Live Activities, App Intents, StoreKit 2, Foundation Models, watchOS) y cómo presentarlo a un cliente.