---
name: swift-testing
description: >
  Testing Swift con el nuevo marco swift-testing (@Test, #expect), XCTest,
  mocks basados en protocolos (sin frameworks externos), async testing,
  XCUITest para UI, swift-snapshot-testing y test plans en Xcode.
  Cargar cuando se escriban tests iOS/macOS o se configure cobertura Swift.
version: "1.0.0"
herramientasPermitidas: [Read, Grep]
exclusiones:
  - "No cargar para implementar el código Swift de producción — primero implementar con `swift-experto`, luego escribir tests."
  - "No cargar para diseñar arquitectura Swift (TCA, MVVM, DI) — para diseño cargar `swift-patrones`."
  - "No cargar para errores de compilación Swift o Xcode en los tests — para build errors cargar `build-errors-swift`."
  - "No cargar para tests de UI con Espresso en Android — este skill cubre testing específico de Swift/iOS con XCTest y swift-testing."
evolvable: true  # default para skill estandar
---
# Swift Testing — swift-testing, XCTest y Protocolos

## Cuándo NO cargar

- El código Swift de producción aún no existe — primero implementar con `swift-experto`, luego escribir tests.
- La pregunta es sobre diseñar arquitectura Swift (TCA, MVVM, DI) — cargar `swift-patrones`.
- Los errores son de compilación Swift o Xcode en los archivos de test — cargar `build-errors-swift`.
- Los tests son para Android con Espresso o Kotlin — este skill cubre testing de Swift/iOS específicamente.

## swift-testing — Marco moderno (Xcode 16+, Swift 6)

```swift
import Testing

// @Test reemplaza a func test...() de XCTest
// No necesita heredar de XCTestCase
struct FacturasServiceTests {

    // @Test con nombre descriptivo (el nombre del método puede ser largo)
    @Test("Crear factura con folio único devuelve la factura guardada")
    func crearConFolioUnico() async throws {
        let repo = FakeFacturasRepository()
        let svc = FacturasService(repository: repo)

        let factura = try await svc.crear(folio: "F-001", empresaId: "emp-1")

        #expect(factura.folio == "F-001")
        #expect(factura.estatus == .borrador)
        #expect(!factura.id.isEmpty)
    }

    // #expect en lugar de XCTAssert — evalúa la expresión completa en el error
    @Test("Buscar factura inexistente devuelve nil")
    func buscarInexistente() async throws {
        let repo = FakeFacturasRepository()
        let svc = FacturasService(repository: repo)

        let resultado = await svc.buscar(id: "no-existe")

        #expect(resultado == nil)
    }

    // Test parametrizado — ejecuta el test con cada valor
    @Test("Calcular IVA según tasa", arguments: [
        (precio: 100.0, tasa: 0.16, ivaEsperado: 16.0),
        (precio: 200.0, tasa: 0.08, ivaEsperado: 16.0),
        (precio: 0.0,   tasa: 0.16, ivaEsperado: 0.0),
    ])
    func calcularIva(precio: Double, tasa: Double, ivaEsperado: Double) {
        let calculadora = CalculadoraImpuestos()
        #expect(calculadora.iva(precio: precio, tasa: tasa) == ivaEsperado)
    }

    // Verificar que lanza error
    @Test("Crear factura con folio duplicado lanza error")
    func crearConFolioDuplicado() async throws {
        let repo = FakeFacturasRepository()
        await repo.insertar(Factura(id: "1", folio: "F-001", empresaId: "emp-1"))
        let svc = FacturasService(repository: repo)

        await #expect(throws: FacturasError.folioDuplicado) {
            try await svc.crear(folio: "F-001", empresaId: "emp-1")
        }
    }
}

// Suites — agrupar tests relacionados
@Suite("FacturasService — operaciones de escritura")
struct FacturasServiceEscrituraTests {
    let repo = FakeFacturasRepository()
    lazy var svc = FacturasService(repository: repo)
    // ...
}
```

---

## Mocks Basados en Protocolos — Sin Frameworks

```swift
// Definir protocolo para la dependencia real
protocol IFacturasRepository {
    func obtener(empresaId: String) async throws -> [Factura]
    func guardar(_ factura: Factura) async throws
    func eliminar(id: String) async throws
}

// Fake en memoria — para tests de integración
actor FakeFacturasRepository: IFacturasRepository {
    private var facturas: [Factura] = []

    func obtener(empresaId: String) async -> [Factura] {
        facturas.filter { $0.empresaId == empresaId }
    }

    func guardar(_ factura: Factura) async {
        if let idx = facturas.firstIndex(where: { $0.id == factura.id }) {
            facturas[idx] = factura
        } else {
            facturas.append(factura)
        }
    }

    func eliminar(id: String) async {
        facturas.removeAll { $0.id == id }
    }

    // Helper de test
    func insertar(_ factura: Factura) async {
        facturas.append(factura)
    }
}

// Spy — para verificar llamadas (capturar argumentos)
final class SpyFacturasRepository: IFacturasRepository {
    var guardarLlamadas: [Factura] = []
    var eliminarIds: [String] = []

    func obtener(empresaId: String) async throws -> [Factura] { [] }

    func guardar(_ factura: Factura) async throws {
        guardarLlamadas.append(factura)
    }

    func eliminar(id: String) async throws {
        eliminarIds.append(id)
    }
}

// Uso del Spy en test
@Test("Servicio llama a guardar exactamente una vez")
func llamadaGuardar() async throws {
    let spy = SpyFacturasRepository()
    let svc = FacturasService(repository: spy)

    try await svc.crear(folio: "F-001", empresaId: "emp-1")

    #expect(spy.guardarLlamadas.count == 1)
    #expect(spy.guardarLlamadas.first?.folio == "F-001")
}
```

---

## Async Testing en XCTest (proyectos iOS 15 y anteriores)

```swift
class FacturasViewModelTests: XCTestCase {

    func test_cargar_estadoExito_cuandoRepositorioRetornaDatos() async throws {
        // Arrange
        let repo = FakeFacturasRepository()
        await repo.insertar(Factura(id: "1", folio: "F-001", empresaId: "emp-1"))
        let vm = FacturasViewModel(repository: repo)

        // Act
        await vm.cargar()

        // Assert
        guard case .exito(let facturas) = vm.estado else {
            XCTFail("Estado esperado: .exito, actual: \(vm.estado)")
            return
        }
        XCTAssertEqual(facturas.count, 1)
    }

    // expectation para código con closures (legacy)
    func test_notificacion_enviada_cuandoFacturaCreada() {
        let expectacion = expectation(description: "notificación enviada")
        let svc = NotificacionesService()

        svc.enviarNotificacion(factura: facturaEjemplo) {
            expectacion.fulfill()
        }

        waitForExpectations(timeout: 5)
    }
}
```

---

## swift-snapshot-testing

```swift
// podFile / Package.swift: PointFree/swift-snapshot-testing
import SnapshotTesting
import SwiftUI

class FacturaRowSnapshotTests: XCTestCase {

    func test_facturaRow_apariencia_pendiente() {
        let view = FacturaRow(factura: .pendienteEjemplo)
            .frame(width: 375)

        // Primera ejecución: guarda el snapshot de referencia
        // Siguientes: compara contra la referencia
        assertSnapshot(of: view, as: .image(layout: .fixed(width: 375, height: 80)))
    }

    // Snapshot con modo oscuro
    func test_facturaRow_modoOscuro() {
        let view = FacturaRow(factura: .pendienteEjemplo)
            .preferredColorScheme(.dark)

        assertSnapshot(of: view, as: .image(layout: .fixed(width: 375, height: 80)),
                       named: "dark-mode")
    }
}
```

---

Para XCUITest (UI testing con accessibilityIdentifier), configuración de
Test Plans en Xcode y cobertura con fastlane/xcodebuild, ver
[recursos/xcuitest-planes.md](recursos/xcuitest-planes.md).

---

## Gotchas / Errores comunes no obvios

**`@Test` con `swift-testing` no puede estar en el mismo archivo que código que usa `XCTestCase`**: mezclar swift-testing y XCTest en el mismo archivo causa errores de compilación en proyectos que aún no migraron completamente. Causa: los dos frameworks tienen convenciones de lifecycle incompatibles. Fix: separar los tests en archivos distintos — los tests nuevos en archivos con swift-testing, los legados en archivos con XCTestCase.

**Actor-based fake con `nonisolated` en un método de protocolo requerido por el test causa error de aislamiento**: si el protocolo del dominio declara `func obtener() async -> [Item]` y el fake es un `actor`, la función async es automáticamente aislada al actor. Intentar llamarla desde un test no-async context falla. Fix: los fakes actor-based funcionan bien en tests `async` — asegurarse que el `@Test` o `func test...() async` sea async.

**`XCTestExpectation` con `fulfill()` llamado dos veces causa fallo de test intermitente**: si una expectation tiene `expectedFulfillmentCount = 1` pero el código la cumple dos veces (por ejemplo por un notification duplicada), el test falla con "fulfilled more times than expected". Fix: verificar que el código bajo test no produce eventos duplicados, o usar `expectation.assertForOverFulfill = false` si se aceptan cumplimientos adicionales.

**Snapshot testing con `assertSnapshot` falla en CI si las snapshots se generaron en una máquina con escala de pantalla diferente**: las snapshots de vistas SwiftUI dependen del rendering en puntos, que puede diferir entre simuladores de diferentes escala (1x, 2x, 3x). Fix: usar siempre la misma configuración de simulador en local y CI para snapshot tests, o configurar `precision` y `perceptualPrecision` para tolerancia a diferencias menores de rendering.

## Checklist de tests Swift

- [ ] Usar swift-testing (@Test, #expect) para código nuevo (Xcode 16+)
- [ ] Mocks implementan protocolos del dominio, sin frameworks de terceros
- [ ] Fakes para estado (actor en memoria), Spies para verificar llamadas
- [ ] Tests async usan async throws, no XCTestExpectation cuando es posible
- [ ] Snapshots guardados como referencia en el repositorio
- [ ] Test plan configurado en Xcode con cobertura habilitada
- [ ] CancellationError no atrapado silenciosamente en catch genérico
