Saltar al contenido
K
Arquitectura en iOSPaso a paso32 min de lectura · 20 min de máquina Básico

Tests en iOS: falsos y espías

Al terminar: Probar el ViewModel con XCTest y Swift Testing.

Al terminar, Marcador tendrá pruebas del ViewModel que no tocan disco ni notificaciones: un repositorio falso que anota los goles, un notificador espía, y las pruebas del caso de uso RegistrarGol, todo escrito en Swift Testing. La lección siguiente escribe las mismas pruebas en XCTest, el marco anterior, y añade rendimiento y accesibilidad.

Importa porque una prueba rápida y sin efectos secundarios se ejecuta a cada cambio, y porque para probar una pieza hay que poder sustituir lo que ella usa. Temas: los targets de pruebas y los dobles de prueba (falso, espía, mock). No instala ningún paquete: el marco viene con Xcode (import Testing), y el target de pruebas ya lo tienes desde que creaste el proyecto.

Dónde viven las pruebas: los targets de pruebas

Abre ~/Marcador-Swift/MarcadorApp/MarcadorApp.xcodeproj. Además de MarcadorApp, tu proyecto tiene dos targets de pruebas (los creó el asistente del proyecto en Tu primera app de iOS): MarcadorAppTests, para las pruebas unitarias, y MarcadorAppUITests, para las de interfaz. En el Project navigator (Cmd+1), cada uno es una carpeta con su nombre. La diferencia está en cómo se enlazan con la app:

TargetCómo pruebaAjuste que lo marca (pestaña Build Settings)
MarcadorAppTestsCorre dentro de la app: puede ver su código con @testable import MarcadorAppTEST_HOST apunta a MarcadorApp.app
MarcadorAppUITestsArranca la app aparte y la maneja desde fuera, como un usuarioTEST_TARGET_NAME = MarcadorApp

Para ver todas tus pruebas hay un panel propio, el Test navigator (Cmd+6): lista los dos targets, cada grupo y cada prueba, con un botón de play al lado para lanzar solo esa. Cmd+U lanza todas.

Los dobles de prueba

PartidoViewModel recibe su repositorio y su notificador por el constructor, y los conoce solo por protocolo (Protocolos y extensiones). Eso permite darle imitaciones que controlas. Los dobles de prueba son piezas falsas que ocupan el lugar de las de verdad. Son como los dobles de las películas: hacen la escena peligrosa para que el actor no se juegue nada. Los nombres no los usa todo el mundo igual; estos son los habituales:

DobleQué haceEn Marcador
Falso (fake)Funciona de verdad, sin el mundo real (sin disco)RepositorioFalso
Espía (spy)Anota qué le pidieron, para preguntarlo despuésNotificadorEspia
MockUn espía que además lleva las expectativas dentro y falla solo si no se cumplenNinguno

No hace falta ninguna librería de mocks: en Swift basta un protocolo y una clase de diez líneas. El mismo truco servirá cuando la app hable con un servidor: un servicio falso en lugar de internet, como verás en La pantalla de la crónica y sus pruebas.

Un espía completo, con una función que lo usa y su comprobación. assert(condición) detiene el programa diciendo en qué línea si la condición es falsa, y no hace nada si es cierta:

ejemplo mínimo: un espía y una función que se prueba con él (con comentarios)
// El protocolo: lo único que la función necesita saber de un altavoz.
protocol Altavoz {
    func decir(_ texto: String)
}
 
// Un espía: cumple el protocolo y solo anota lo que le piden decir.
final class AltavozEspia: Altavoz {
    private(set) var dichos: [String] = []          // se lee desde fuera, solo cambia aquí
    func decir(_ texto: String) { dichos.append(texto) }
}
 
// La función que se prueba: no sabe si habla con un altavoz de verdad o con un espía.
func saludar(a nombres: [String], con altavoz: Altavoz) {
    for nombre in nombres {
        altavoz.decir("Hola, \(nombre)")
    }
}
 
let espia = AltavozEspia()
saludar(a: ["Rosa", "Leo"], con: espia)
// assert: si la condición es falsa, el programa se detiene diciendo en qué línea; si es cierta, no hace nada.
assert(espia.dichos == ["Hola, Rosa", "Hola, Leo"])
print(espia.dichos)
["Hola, Rosa", "Hola, Leo"]

Reto · Ejercicio: un espía para el aviso de goles

En tu Playground (reutiliza el mismo y borra lo anterior), escribe:

  • Un protocolo, Avisador, con un método, avisar, que recibe el minuto de un gol.
  • Una clase, AvisadorEspia, que lo cumpla y solo anote los minutos de los que le avisan (se leen desde fuera, pero solo ella los cambia).
  • Una función, avisarDeGoles, que reciba los minutos de los goles, la duración del partido y un avisador, y avise solo de los goles que no pasen de la duración.

Con los goles 12, 55 y 80, pruébala con un espía y 90 minutos, y con otro espía y 60 minutos. Comprueba cada resultado con una aserción e imprímelo. Lo que debe salir:

[12, 55, 80]
[12, 55]
Ver solución
solución: un espía para el aviso de goles
protocol Avisador {
    func avisar(minuto: Int)
}
 
final class AvisadorEspia: Avisador {
    private(set) var minutosAvisados: [Int] = []
    func avisar(minuto: Int) { minutosAvisados.append(minuto) }
}
 
func avisarDeGoles(_ minutos: [Int], hasta duracion: Int, con avisador: Avisador) {
    for minuto in minutos where minuto <= duracion {
        avisador.avisar(minuto: minuto)
    }
}
 
let entero = AvisadorEspia()
avisarDeGoles([12, 55, 80], hasta: 90, con: entero)
assert(entero.minutosAvisados == [12, 55, 80])
print(entero.minutosAvisados)
 
let corto = AvisadorEspia()
avisarDeGoles([12, 55, 80], hasta: 60, con: corto)
assert(corto.minutosAvisados == [12, 55])
print(corto.minutosAvisados)
[12, 55, 80]
[12, 55]

La función no sabe si habla con un espía o con un notificador de verdad: solo conoce el protocolo. Es lo mismo que hace PartidoViewModel con Notificador, y por eso sus pruebas pueden comprobar qué goles se avisaron sin mostrar ningún banner.

Paso 1 · Crea Dobles.swift y mueve el espía

Objetivo: un fichero nuevo en el target de pruebas con los dos imitadores que las pruebas usarán: un repositorio falso y el notificador espía que ya escribiste, que se muda aquí.

MarcadorApp (proyecto)
MarcadorAppTests
Core
Dobles.swiftse crea
Marcador
UI
PartidoViewModelTests.swiftse modifica
En el Project navigator: un fichero nuevo y uno que cambia.

Las pruebas copian en espejo las carpetas de la app, y Dobles no pertenece a ninguna funcionalidad, aunque lo usen las pruebas de varias, así que va en MarcadorAppTests/Core (la de las pruebas, no la de MarcadorApp). Crea la carpeta: clic derecho sobre MarcadorAppTests → New Folder → Core. Después, clic derecho sobre MarcadorAppTests/Core → New File from Template… → Swift File → Next. Escribe Dobles y, en Targets, marca solo MarcadorAppTests (no MarcadorApp: un fichero de pruebas dentro de la app acabaría en el producto final). Pulsa Create. Alternativa desde la Terminal: mkdir -p ~/Marcador-Swift/MarcadorApp/MarcadorAppTests/Core && touch ~/Marcador-Swift/MarcadorApp/MarcadorAppTests/Core/Dobles.swift.

Para escribir el falso necesitas saber qué exige el protocolo que imita, el que ya usa PartidoViewModel:

el protocolo que el falso tiene que cumplir
protocol PartidosRepositorio: AnyObject {
    var equipos: [Equipo] { get }
    var partidos: [PartidoDeLista] { get }
    func partido(id: Int) -> Partido?
    func registrarGol(partidoId: Int, gol: EventoDePartido)
}

Cuatro requisitos: dos propiedades que se leen y dos funciones. El falso tiene que dar valor a las cuatro; AnyObject obliga a que sea una clase. (Para el espía, el protocolo Notificador es el de la lección anterior: permitido y notificarGol(minuto:jugador:equipo:).)

Piezas nuevas:

  • @testable import MarcadorApp: deja que la prueba vea lo que la app declara sin public (internal, lo normal).
  • private(set) var: se lee desde fuera, solo cambia desde dentro; así las pruebas leen lo anotado pero no lo falsifican.
  • Una tupla con nombres, (partidoId: Int, gol: EventoDePartido): dos valores juntos, cada uno con su nombre, para guardar «qué gol se registró y en qué partido». Es la misma idea del espía de la lección anterior (avisos, un array de tuplas); allí la leíste con map(\.minuto), aquí con .first?.partidoId.

Antes de escribir, borra el espía que escribiste en la lección anterior: en PartidoViewModelTests.swift, la clase NotificadorEspia entera (desde su comentario hasta su llave de cierre, justo antes de @MainActor). Ahora vivirá en Dobles.swift; si la dejas en los dos sitios, Xcode marca invalid redeclaration of 'NotificadorEspia'.

Intenta escribirlo tú, en Dobles.swift:

  1. RepositorioFalso, que cumple PartidosRepositorio: sirve los partidos que le des (si no, uno solo, el 1, Rayo contra Toros), tiene como equipos al Rayo y al Toros, anota en orden los ids por los que le preguntan (consultas) y los goles que le piden guardar con su partido (golesRegistrados), sin cambiar sus partidos ni escribir nada en disco. Lo anotado se lee desde fuera, pero solo él lo cambia.
  2. NotificadorEspia, el de la lección anterior, tal cual.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorAppTests/Core/Dobles.swift
import Torneo
@testable import MarcadorApp
 
// El repositorio falso: sirve los partidos que le des y anota los goles que le piden guardar.
// A diferencia del real, NO modifica sus partidos ni escribe ningún fichero.
final class RepositorioFalso: PartidosRepositorio {
    var equipos: [Equipo]
    var partidos: [PartidoDeLista]
    // Lo que le pidieron guardar, en orden: las pruebas lo leen después.
    private(set) var golesRegistrados: [(partidoId: Int, gol: EventoDePartido)] = []
    // Por qué partidos le preguntaron.
    private(set) var consultas: [Int] = []
 
    init(partidos: [PartidoDeLista] = [PartidoDeLista(id: 1, partido: Ejemplo.rayoContraToros)]) {
        self.partidos = partidos
        self.equipos = [Ejemplo.rayo, Ejemplo.toros]
    }
 
    func partido(id: Int) -> Partido? {
        consultas.append(id)
        return partidos.first { $0.id == id }?.partido
    }
 
    func registrarGol(partidoId: Int, gol: EventoDePartido) {
        golesRegistrados.append((partidoId, gol))
    }
}
 
// El espía de notificaciones: el de la lección anterior, ahora en su sitio.
final class NotificadorEspia: Notificador {
    var permitido = true
    private(set) var avisos: [(minuto: Int, jugador: String, equipo: String)] = []
 
    func notificarGol(minuto: Int, jugador: String, equipo: String) {
        avisos.append((minuto, jugador, equipo))
    }
}

Todavía no lo usa nadie, pero tiene que compilar: ejecuta las pruebas con Cmd+U (compila también el target de pruebas; lanza todas, también las de interfaz, que tardan más) y comprueba que siguen pasando.

Paso 2 · Usa el falso en las pruebas del ViewModel

PartidoViewModelTests.swift pasa a usar el falso en lugar del repositorio con datos de ejemplo. Tres cambios. El primero: sustituye la función repositorio() por esta:

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
PartidoViewModelTests.swift: repositorio(), después
    // El repositorio es un FALSO (ver `Dobles.swift`): sin disco y con los goles anotados.
    private func repositorio() -> RepositorioFalso { RepositorioFalso() }

El segundo: la prueba del gol a mano comprueba ahora lo que el falso anotó. Para entender el Sofía de la prueba: el botón de gol a mano no dice quién marcó, y la regla del caso de uso RegistrarGol es que marca el jugador de la plantilla que toca por turno: el que ocupa la posición que sale de dividir «los goles que ya lleva el equipo en ese partido» entre «el tamaño de la plantilla» y quedarte con el resto (en Swift, el operador %). Toros lleva 1 gol y tiene 2 jugadores: el resto de dividir 1 entre 2 es 1 (1 % 2), la posición 1 (la segunda, porque se cuenta desde 0), Sofía. Sustituye unGolAManoSeVeYSeGuardaEnElRepositorio por esta:

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
PartidoViewModelTests.swift: el gol a mano, después
    @Test func unGolAManoSeVeYSeGuardaEnElRepositorio() {
        let repo = repositorio()
        let viewModel = PartidoViewModel(partidoId: 1, repositorio: repo, msPorMinuto: 0)
        viewModel.alGol(.visitante)
        #expect(viewModel.marcador == Marcador(local: 0, visitante: 1))
        // El falso anotó UN gol, del equipo visitante, con el jugador que tocaba por turno.
        #expect(repo.golesRegistrados.count == 1)
        #expect(repo.golesRegistrados.first?.partidoId == 1)
        // Un `guard case` comprueba que el gol anotado es de verdad un `.gol` y saca sus datos.
        // El `?` tras el patrón es porque `first` puede ser `nil`.
        guard case let .gol(minuto, jugador, equipo)? = repo.golesRegistrados.first?.gol else {
            Issue.record("Se esperaba un gol")
            return
        }
        #expect(minuto == 0)
        #expect(jugador.nombre == "Sofía")
        #expect(equipo == Ejemplo.toros)
    }

Issue.record("…") hace fallar la prueba con ese mensaje; #expect(…) es la comprobación de Swift Testing. Y guard case let … else { … } es el if case let de Enums: el partido de eventos con salida obligada: si el valor no encaja con el patrón, el else registra el fallo y sale con return. El tercero, una prueba nueva debajo de la anterior:

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
PartidoViewModelTests.swift: una prueba nueva
    // El ViewModel pregunta por SU partido, no por otro.
    @Test func elViewModelPideAlRepositorioSuPartido() {
        let repo = repositorio()
        _ = PartidoViewModel(partidoId: 1, repositorio: repo, msPorMinuto: 0)
        #expect(repo.consultas == [1])
    }

Ejecuta con Cmd+U: pasan todas. Una de ellas, unGolAManoSeVeYSeGuardaEnElRepositorio, ya no mira el marcador del repositorio (el falso no lo modifica), sino lo que el falso anotó.

Paso 3 · RegistrarGolTests.swift, con el botón

Faltan las cuatro pruebas del caso de uso RegistrarGol con el falso, una de ellas parametrizada. No enseñan nada nuevo (la parametrizada ya la escribiste en La regla de los puntos y tu primer test) salvo una llamada, .get(): el caso de uso devuelve un Result (o el gol, o un error), y .get() saca el valor si fue un éxito y lanza el error si fue un fallo. Por eso esas pruebas son throws. Un ejemplo pequeño, con otro Result:

ejemplo mínimo: Result y get() (con comentarios)
enum ErrorDeCompra: Error { case sinStock }
 
// Devuelve un Result: o el precio, o un error.
func precioDe(_ producto: String) -> Result<Int, ErrorDeCompra> {
    producto == "pan" ? .success(3) : .failure(.sinStock)
}
 
do {
    // .get() saca el valor si fue .success; si fue .failure, lanza el error.
    let precio = try precioDe("pan").get()
    print("pan cuesta \(precio)")
    let otro = try precioDe("trufa").get()      // aquí lanza: salta al catch
    print("trufa cuesta \(otro)")
} catch {
    print("falló: \(error)")
}
pan cuesta 3
falló: sinStock

Las pruebas se dan hechas con el botón Copiar fichero, pero conviene leer dos antes de pegarlas: la que usa .get() y la parametrizada.

para leer: dos de las pruebas de RegistrarGolTests (con comentarios)
    @Test func marcaElJugadorQueTocaPorTurno() throws {
        let repo = RepositorioFalso()
        let registrar = RegistrarGol(repositorio: repo)
        // Rayo lleva 2 goles y tiene 3 jugadores: toca el índice 2 (Marta).
        let gol = try registrar(partidoId: 1, lado: .local, minuto: 30).get()   // el Result se abre con .get()
        guard case let .gol(_, jugador, _) = gol else {
            Issue.record("Se esperaba un gol")
            return
        }
        #expect(jugador == Ejemplo.marta)
        #expect(repo.golesRegistrados.count == 1)    // el falso anotó el gol
    }
 
    // Una prueba parametrizada: la MISMA prueba con varios datos (un caso por argumento).
    @Test(arguments: [Lado.local, Lado.visitante])
    func cadaLadoRegistraUnGolDeSuEquipo(lado: Lado) throws {
        let repo = RepositorioFalso()
        let gol = try RegistrarGol(repositorio: repo)(partidoId: 1, lado: lado, minuto: 10).get()
        guard case let .gol(_, _, equipo) = gol else {
            Issue.record("Se esperaba un gol")
            return
        }
        #expect(equipo == (lado == .local ? Ejemplo.rayo : Ejemplo.toros))
    }
  1. Crea el fichero. Prueba RegistrarGol, que está en Marcador/Domain, así que su prueba va en MarcadorAppTests/Marcador/Domain. La carpeta MarcadorAppTests/Marcador ya existe (la creaste para PartidoViewModelTests): clic derecho sobre ella → New Folder → Domain. Después, clic derecho sobre MarcadorAppTests/Marcador/Domain → New File from Template… → Swift File → RegistrarGolTests, con el target MarcadorAppTests marcado.
  2. Pulsa el botón de abajo. Aparecerá un aviso de que se ha copiado.
  3. Haz clic dentro del editor con el fichero abierto, selecciona todo con Cmd+A y pega con Cmd+V. Lo pegado reemplaza la plantilla que Xcode pone en un fichero nuevo.
RegistrarGolTests.swift

Las cuatro pruebas del fichero, sin ViewModel, sin pantalla y sin tiempo, comprueban que RegistrarGol elige al jugador que toca por turno, que un partido inexistente es un fallo esperable y no una excepción, que un equipo sin jugadores no puede marcar y que cada lado registra un gol de su equipo.

Para lanzar solo las unitarias, en el Test navigator (Cmd+6) pulsa el play junto a MarcadorAppTests, o usa la Terminal, desde ~/Marcador-Swift/MarcadorApp:

xcodebuild -scheme MarcadorApp -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:MarcadorAppTests test

Si tu simulador se llama distinto, mira su nombre con xcrun simctl list devices available.

Punto de control

  • -only-testing:MarcadorAppTests termina con ** TEST SUCCEEDED **: son 21 pruebas unitarias, todas de Swift Testing (la parametrizada se ejecuta dos veces, una por argumento, pero cuenta como una prueba).
  • Cmd+U da 36 pruebas en verde: 21 unitarias y 15 de interfaz.
  • En el Test navigator (Cmd+6) aparece RegistrarGolTests con la parametrizada.
  • RepositorioFalso y NotificadorEspia viven en Dobles.swift, y PartidoViewModelTests.swift ya no declara su propio espía.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.NotificadorEspia guarda cada aviso que le piden en un array para que la prueba lo mire después. ¿Qué tipo de doble es?

  2. 2.¿Para qué sirve @Test(arguments: [Lado.local, Lado.visitante])?

  3. 3.¿Por qué PartidoViewModel puede recibir un RepositorioFalso en las pruebas en lugar del repositorio de verdad?

  4. 4.¿Qué deja ver @testable import MarcadorApp en un target de pruebas unitarias?

  5. 5.En RegistrarGolTests, try registrar(partidoId: 1, lado: .local, minuto: 30).get() devuelve un Result. ¿Qué hace .get()?

Faltan 5 de 5