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:
| Target | Cómo prueba | Ajuste que lo marca (pestaña Build Settings) |
|---|---|---|
MarcadorAppTests | Corre dentro de la app: puede ver su código con @testable import MarcadorApp | TEST_HOST apunta a MarcadorApp.app |
MarcadorAppUITests | Arranca la app aparte y la maneja desde fuera, como un usuario | TEST_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:
| Doble | Qué hace | En Marcador |
|---|---|---|
| Falso (fake) | Funciona de verdad, sin el mundo real (sin disco) | RepositorioFalso |
| Espía (spy) | Anota qué le pidieron, para preguntarlo después | NotificadorEspia |
| Mock | Un espía que además lleva las expectativas dentro y falla solo si no se cumplen | Ninguno |
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:
// 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
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í.
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:
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 sinpublic(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 conmap(\.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:
RepositorioFalso, que cumplePartidosRepositorio: 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.NotificadorEspia, el de la lección anterior, tal cual.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
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
// 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
@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
// 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:
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ó: sinStockLas pruebas se dan hechas con el botón Copiar fichero, pero conviene leer dos antes de pegarlas: la que usa .get() y la parametrizada.
@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))
}- Crea el fichero. Prueba
RegistrarGol, que está enMarcador/Domain, así que su prueba va enMarcadorAppTests/Marcador/Domain. La carpetaMarcadorAppTests/Marcadorya existe (la creaste paraPartidoViewModelTests): clic derecho sobre ella → New Folder →Domain. Después, clic derecho sobreMarcadorAppTests/Marcador/Domain→ New File from Template… → Swift File →RegistrarGolTests, con el targetMarcadorAppTestsmarcado. - Pulsa el botón de abajo. Aparecerá un aviso de que se ha copiado.
- 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.
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 testSi tu simulador se llama distinto, mira su nombre con xcrun simctl list devices available.
Punto de control
-only-testing:MarcadorAppTeststermina 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
RegistrarGolTestscon la parametrizada. RepositorioFalsoyNotificadorEspiaviven enDobles.swift, yPartidoViewModelTests.swiftya no declara su propio espía.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.
NotificadorEspiaguarda cada aviso que le piden en un array para que la prueba lo mire después. ¿Qué tipo de doble es?2.¿Para qué sirve
@Test(arguments: [Lado.local, Lado.visitante])?3.¿Por qué
PartidoViewModelpuede recibir unRepositorioFalsoen las pruebas en lugar del repositorio de verdad?4.¿Qué deja ver
@testable import MarcadorAppen un target de pruebas unitarias?5.En
RegistrarGolTests,try registrar(partidoId: 1, lado: .local, minuto: 30).get()devuelve unResult. ¿Qué hace.get()?