XCTest y las pruebas del ViewModel
Al terminar: Probar el ViewModel con XCTest.
Al terminar, Marcador tendrá las mismas pruebas del ViewModel escritas otra vez en XCTest, el marco anterior, con una tabla de equivalencias; una prueba de rendimiento que mide cuánto tarda un partido entero; y una auditoría de accesibilidad automática en las pruebas de interfaz.
Importa porque en iOS hay dos marcos de pruebas conviviendo, y vas a leer código escrito con los dos: XCTest sigue siendo obligatorio para las pruebas de interfaz y de rendimiento. Temas: XCTest frente a Swift Testing, measure y la auditoría de accesibilidad. No instala ningún paquete: XCTest viene con Xcode (import XCTest).
Swift Testing frente a XCTest
Hay dos marcos de pruebas, y en un mismo proyecto conviven.
| Swift Testing (nuevo) | XCTest (2014) |
|---|---|
struct con métodos @Test | Clase que hereda de XCTestCase; el método debe empezar por test |
#expect(a == b) | XCTAssertEqual(a, b) (una función por tipo de comprobación) |
Issue.record("…") | XCTFail("…") |
init() y deinit | setUp() y tearDown() |
@Test(arguments: […]) | No hay: se escribe un bucle o varias pruebas |
| Se ejecutan en paralelo | En serie por defecto |
| No sirve para pruebas de interfaz ni de rendimiento | Es obligatorio para las dos |
Los dos crean una instancia nueva por prueba, así que una prueba no hereda el estado de otra. Y si vienes de Kotlin: @Test de kotlin.test ↔ @Test; assertEquals(a, b) ↔ #expect(a == b); runTest ↔ marcar la prueba como async y usar await.
La misma comprobación en los dos marcos, sobre una función de goles. Estos ejemplos se leen aquí y no se ejecutan en el Playground, porque los marcos de pruebas necesitan un target de pruebas; verás los dos funcionar sobre Marcador más abajo.
import Testing
func totalDeGoles(_ local: Int, _ visitante: Int) -> Int { local + visitante }
struct GolesTests {
// Una prueba parametrizada: la MISMA prueba con varios datos (cada uno aparece como un caso).
@Test(arguments: [0, 1, 5])
func sumarCeroNoCambiaElTotal(local: Int) {
#expect(totalDeGoles(local, 0) == local)
}
}import XCTest
final class GolesXCTests: XCTestCase {
func testSumarCeroNoCambiaElTotal() {
for local in [0, 1, 5] {
XCTAssertEqual(totalDeGoles(local, 0), local)
}
}
}#expect es una macro (ya viste esta idea en @Observable): al fallar, muestra las dos partes de la comparación. XCTAssertEqual avisa con «is not equal to».
Las piezas de XCTest
Lo que usa una clase de pruebas de XCTest:
final class … : XCTestCase: una clase de pruebas; cada método que empieza portestes una prueba.setUp() async throws: el método que XCTest llama antes de cada prueba.var hucha: Hucha!: un opcional que empieza ennily se usa sin?(el!de «desempaquetado implícito»). Se rellena ensetUp, y si alguna prueba lo usara antes, se caería; por eso solo se usa dondesetUpsiempre corre antes (Opcionales).XCTAssertEqual,XCTAssertTrue,XCTAssertFalse,XCTAssertNil: las comprobaciones de XCTest, una por tipo (la última falla si el valor no esnil).measure { … }: ejecuta el bloque varias veces y mide cuánto tarda. Solo existe en XCTest.expectation,fulfill()ywait(for:timeout:): en XCTest, una prueba síncrona (measurelo es) espera a que termine una tareaasynccon una expectativa.Task { @MainActor in … }: lanza una tarea que corre en el hilo principal.measurees síncrono y quien se prueba suele serasync, así que la tarea es el puente para poder esperar.
Todas juntas, con una hucha en vez de un ViewModel:
import XCTest
final class Hucha {
private(set) var euros = 0
func meter(_ cantidad: Int) async { euros += cantidad }
}
final class HuchaXCTests: XCTestCase {
// !: empieza en nil y se usa sin ?. Solo vale porque setUp la rellena antes de cada prueba.
var hucha: Hucha!
override func setUp() async throws { // XCTest lo llama antes de CADA prueba
hucha = Hucha()
}
func testMeterSuma() async { // empieza por test: es una prueba
await hucha.meter(5)
XCTAssertEqual(hucha.euros, 5) // falla si los dos valores son distintos
XCTAssertFalse(hucha.euros > 10) // falla si la condición es cierta
XCTAssertTrue(hucha.euros > 0) // falla si la condición es falsa
}
func testRendimientoDeMeterMil() {
measure { // ejecuta el bloque varias veces y mide cuánto tarda
// measure es síncrono: una expectativa espera a que acabe la parte async.
let hecho = expectation(description: "metido")
let local = Hucha()
Task { @MainActor in // una tarea en el hilo principal hace de puente
for _ in 1...1_000 { await local.meter(1) }
hecho.fulfill() // avisa: «ya está»
}
wait(for: [hecho], timeout: 5) // espera hasta 5 s a que alguien llame a fulfill()
}
}
}Metidas en un target de pruebas, las dos pasan en verde. En el Test navigator, measure anota una línea como measured [Time, seconds] average: … con la media de sus pasadas.
Reto · Ejercicio: la taquilla en XCTest
Las pruebas del ejercicio de XCTest no caben en un Playground: van en el target de pruebas, en una carpeta propia.
- Crea la carpeta
MarcadorAppTests/Ejercicios(otraEjercicios, distinta de la deMarcadorApp) y, en ella,Taquilla, con un ficheroTaquillaXCTests.swift. Es la primera vez que haces esto dentro del target de pruebas (los ejercicios anteriores de iOS están dentro deMarcadorApp, y XCTest solo existe en el target de pruebas): clic derecho sobre la carpetaMarcadorAppTests→ New Folder →Ejercicios; dentro, New Folder →Taquilla; y dentro de ella, New File from Template… → Swift File →TaquillaXCTests, con soloMarcadorAppTestsmarcado en Targets si el diálogo lo pregunta. Alternativa desde la Terminal:mkdir -p ~/Marcador-Swift/MarcadorApp/MarcadorAppTests/Ejercicios/Taquillaytouchdel fichero dentro; Xcode los muestra solos. - Escribe una clase,
Taquilla, con su aforo y las entradas vendidas (se leen desde fuera, pero solo ella las cambia), y un método,vender, que venda una cantidad y devuelva si pudo: si no caben todas, no vende ninguna. - Escribe también una clase de pruebas de XCTest que prepare una taquilla nueva de aforo 5 antes de cada prueba, con tres pruebas:
- vender 3 deja 3 vendidas;
- tras vender 4, vender 2 más no se puede y siguen 4;
- otra que mida cuánto se tarda en vender mil entradas, una a una, en una taquilla de aforo mil.
- Ejecútalas desde el Test navigator (Cmd+6), con el play junto a
TaquillaXCTests.
Lo que debe pasar: las tres en verde. Si en la primera esperas 5 en lugar de 3, falla con:
XCTAssertEqual failed: ("3") is not equal to ("5")Estas tres pruebas se suman a las de tu proyecto: Cmd+U las lanzará también. Las cifras de los puntos de control de esta lección son sin ellas.
Ver solución
import XCTest
// La taquilla de un estadio: vende entradas sin pasar del aforo.
final class Taquilla {
let aforo: Int
private(set) var vendidas = 0
init(aforo: Int) { self.aforo = aforo }
// Devuelve si pudo vender; si no caben todas, no vende ninguna.
func vender(_ cantidad: Int) -> Bool {
guard vendidas + cantidad <= aforo else { return false }
vendidas += cantidad
return true
}
}
final class TaquillaXCTests: XCTestCase {
// Se rellena en setUp, antes de CADA prueba: cada una empieza con una taquilla nueva.
var taquilla: Taquilla!
override func setUp() async throws {
taquilla = Taquilla(aforo: 5)
}
func testVenderSumaLasEntradas() {
XCTAssertTrue(taquilla.vender(3))
XCTAssertEqual(taquilla.vendidas, 3)
}
func testNoVendeSiNoCaben() {
XCTAssertTrue(taquilla.vender(4))
XCTAssertFalse(taquilla.vender(2))
XCTAssertEqual(taquilla.vendidas, 4)
}
func testRendimientoDeVenderMil() {
measure {
let grande = Taquilla(aforo: 1_000)
for _ in 1...1_000 { _ = grande.vender(1) }
}
}
}Las tres pasan. En testRendimientoDeVenderMil, measure anota un tiempo medio de unas milésimas de segundo o menos. Aquí vender no es async, así que measure no necesita ninguna expectativa.
Paso 1 · Las mismas pruebas, en XCTest
Objetivo: un fichero nuevo, PartidoViewModelXCTests.swift, en el mismo target MarcadorAppTests, con tres de las pruebas del ViewModel escritas con XCTest y una de rendimiento. Cuando lo ejecutes con Cmd+U, el Test navigator mostrará un grupo con ese nombre al lado del de Swift Testing: son dos marcos en el mismo target.
Créalo igual que Dobles.swift: clic derecho sobre MarcadorAppTests/Marcador/UI, la carpeta que copia en espejo a MarcadorApp/Marcador/UI y donde ya está PartidoViewModelTests → New File from Template… → Swift File → PartidoViewModelXCTests, con el target MarcadorAppTests marcado (o, desde la Terminal, touch ~/Marcador-Swift/MarcadorApp/MarcadorAppTests/Marcador/UI/PartidoViewModelXCTests.swift: la carpeta ya existe desde la lección de la pantalla del uiState).
Tres piezas más que la hucha no traía:
@MainActordelante de la clase: el ViewModel es del hilo principal, y en XCTestsetUpy lostest…heredan el aislamiento de la clase.weak var referencia: una referencia débil, que se pone anilcuando ARC libera el objeto (ARC); sirve para comprobar que el ViewModel se libera.- Un bloque
do { … }a secas, sincatch: lo que declaras dentro deja de existir al salir de las llaves. Así el ViewModel de la prueba muere al terminar el bloque y la referencia débil queda vacía.
Intenta escribirlo tú: PartidoViewModelXCTests, una clase de pruebas de XCTest del hilo principal, con un repositorio falso nuevo antes de cada prueba y cuatro pruebas, todas con minutos de 0 ms:
testJugarAplicaLosGolesDelGuion: tras jugar el partido 1 entero, el marcador es 2-1, el minuto 90 y el partido no corre.testUnPartidoQueNoExisteDaUnEstadoDeError: con el partido 99, el estado de la pantalla es el error «No existe el partido n.º 99».testElViewModelSeLiberaAlSoltarlo: dentro de un bloque, un ViewModel sin el ciclo reintroducido juega 3 minutos y se apunta con una referencia que no lo retiene; fuera del bloque, la referencia está vacía.testRendimientoDeJugarUnPartidoEntero: mide cuánto tarda jugar los 90 minutos, esperando a que el partido termine en cada pasada (como mucho, 10 segundos).
Lo que debes ver: las cuatro en verde en el Test navigator.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import XCTest
import Torneo
@testable import MarcadorApp
// Las mismas pruebas, en XCTest (el marco de 2014). Es lo que encontrarás en casi todo el código
// que ya existe, y sigue siendo obligatorio para las pruebas de interfaz y de rendimiento.
// `@MainActor`: el ViewModel lo es; en XCTest, `setUp` y los `test…` heredan el aislamiento de la clase.
@MainActor
final class PartidoViewModelXCTests: XCTestCase {
// Se rellena en `setUp`, que XCTest ejecuta antes de CADA prueba (una instancia nueva por prueba).
var repositorio: RepositorioFalso!
override func setUp() async throws {
repositorio = RepositorioFalso()
}
func testJugarAplicaLosGolesDelGuion() async {
let viewModel = PartidoViewModel(partidoId: 1, repositorio: repositorio, msPorMinuto: 0)
await viewModel.jugar(duracion: 90)
XCTAssertEqual(viewModel.marcador, Marcador(local: 2, visitante: 1))
XCTAssertEqual(viewModel.minuto, 90)
XCTAssertFalse(viewModel.corriendo)
}
func testUnPartidoQueNoExisteDaUnEstadoDeError() {
let viewModel = PartidoViewModel(partidoId: 99, repositorio: repositorio, msPorMinuto: 0)
XCTAssertEqual(viewModel.uiState, .error("No existe el partido n.º 99"))
}
func testElViewModelSeLiberaAlSoltarlo() async {
// `weak`: no mantiene vivo al objeto; pasa a `nil` cuando ARC lo libera.
weak var referencia: PartidoViewModel?
do {
let viewModel = PartidoViewModel(partidoId: 1, repositorio: repositorio, msPorMinuto: 0, reintroducirElCiclo: false)
referencia = viewModel
await viewModel.jugar(duracion: 3)
}
// Fuera del bloque `do`, el ViewModel ya no tiene dueño.
XCTAssertNil(referencia)
}
// XCTest trae MEDICIÓN de rendimiento (`measure`), que Swift Testing no tiene: ejecuta el bloque
// varias veces y calcula una media. Aquí, jugar 90 minutos sin esperas.
func testRendimientoDeJugarUnPartidoEntero() {
measure {
let viewModel = PartidoViewModel(partidoId: 1, repositorio: repositorio, msPorMinuto: 0)
// `measure` es síncrono y `jugar` es `async`: una expectativa espera a que la tarea termine.
let terminado = expectation(description: "el partido termina")
Task { @MainActor in
await viewModel.jugar(duracion: 90)
terminado.fulfill()
}
wait(for: [terminado], timeout: 10)
}
}
}Ejecuta con Cmd+U. En el Test navigator (Cmd+6) aparece PartidoViewModelXCTests con cuatro pruebas. XCTest se ejecuta en serie y Swift Testing en paralelo, pero eso no se nota con pruebas tan rápidas.
Paso 2 · Una auditoría de accesibilidad en las pruebas de interfaz
Objetivo: una prueba de interfaz que pase la auditoría automática de accesibilidad sobre la lista de partidos.
sinAvisos es la lista de argumentos de lanzamiento que apaga el banner de notificaciones; vive en Ayudas.swift (el fichero que pegaste en Notificaciones locales), y no hace falta más que pasarla a launchArguments:
// La lista de argumentos que apaga el banner de notificaciones con la app delante.
let sinAvisos = ["-sinAvisosEnPrimerPlano", "YES"]Intenta escribirla tú, testAuditoriaDeAccesibilidadDeLaLista, dentro de MarcadorAppUITests en MarcadorAppUITests.swift, con las piezas de la tabla de XCUITest (performAccessibilityAudit es nueva y está explicada arriba): arranca la app sin banners (con sinAvisos), espera hasta 5 segundos a que aparezca el partido 1 y pasa la auditoría de accesibilidad con todos los tipos de comprobación. La primera versión, la obvia, falla; mira cómo.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
En MarcadorAppUITests.swift, dentro de MarcadorAppUITests, justo encima de testUnGolSacaUnaNotificacion (antes de su comentario y su @MainActor):
@MainActor
func testAuditoriaDeAccesibilidadDeLaLista() throws {
let app = XCUIApplication()
// Sin banners de notificación en primer plano.
app.launchArguments = sinAvisos
app.launch()
XCTAssertTrue(app.buttons["partido-1"].waitForExistence(timeout: 5))
// Todos los tipos de comprobación.
try app.performAccessibilityAudit()
}Deja la prueba así, con los tres tipos fuera y un comentario que diga por qué:
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// Accesibilidad: iOS 17+ trae una AUDITORÍA automática que busca contraste
// insuficiente, zonas táctiles pequeñas, texto que no escala, etiquetas que faltan…
@MainActor
func testAuditoriaDeAccesibilidadDeLaLista() throws {
let app = XCUIApplication()
app.launchArguments = sinAvisos
app.launch()
XCTAssertTrue(app.buttons["partido-1"].waitForExistence(timeout: 5))
// Se auditan todos los tipos menos tres, que pueden fallar según tu simulador y se revisan a mano:
// - `.contrast`: «Contrast failed» en algún texto; se mira con el Inspector de Accesibilidad.
// - `.dynamicType`: «Dynamic Type font sizes are unsupported»; se prueba con la letra grande
// de Ajustes → Accesibilidad.
// - `.textClipped`: «Text clipped»; se mira con esa misma letra grande.
try app.performAccessibilityAudit(for: .all.subtracting([.contrast, .dynamicType, .textClipped]))
}Ejecutar la suite
Desde la Terminal, la suite completa:
cd ~/Marcador-Swift/MarcadorApp
xcodebuild -scheme MarcadorApp -destination 'platform=iOS Simulator,name=iPhone 17' build testTarda unos minutos (casi todo en las pruebas de interfaz, que arrancan la app en el simulador). Si tu simulador se llama distinto, mira su nombre con xcrun simctl list devices available. Para lo rápido, solo las unitarias: añade -only-testing:MarcadorAppTests. En Xcode, Cmd+U las lanza todas y el Test navigator (Cmd+6) las lista, una por caso: la parametrizada aparece con un caso por argumento.
Al final habrá 41 pruebas: 25 unitarias (21 en Swift Testing y 4 en XCTest) y 16 de interfaz (15 en MarcadorAppUITests y testLaunch, la prueba de arranque). La parametrizada se ejecuta dos veces, una por argumento, pero cuenta como una prueba. Si dejaste el ejercicio de la taquilla, Cmd+U enseñará tres más.
El paquete Torneo tiene sus propias pruebas, de otro tipo: desde ~/Marcador-Swift/Torneo, xcrun swift test ejecuta 138.
A Marcador le falta una pieza de cualquier app real: hablar con un servidor. La lección siguiente, Red: HTTP, JSON y URLSession, empieza por ahí, y en las que le siguen Dobles.swift gana los falsos de la red, empezando por Red en Marcador: la lista de árbitros.
Punto de control
xcodebuild -scheme MarcadorApp -destination 'platform=iOS Simulator,name=iPhone 17' build test, desde~/Marcador-Swift/MarcadorApp, termina con** TEST SUCCEEDED **, y al repetirlo pasa igual: 41 pruebas, todas en verde: 25 unitarias (21 en Swift Testing y 4 en XCTest) y 16 de interfaz (15 enMarcadorAppUITestsytestLaunch).- En el Test navigator (Cmd+6) aparecen
PartidoViewModelXCTestscon cuatro pruebas, incluidatestRendimientoDeJugarUnPartidoEntero, yRegistrarGolTestscon la parametrizada. - La auditoría
testAuditoriaDeAccesibilidadDeLaListapasa con.all.subtracting([.contrast, .dynamicType, .textClipped]). 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.¿Qué equivale en XCTest a
#expect(a == b)de Swift Testing?2.Necesitas medir cuánto tarda en ejecutarse un bloque de código con
measure { }. ¿Con qué marco lo escribes?3.Una prueba de interfaz audita la accesibilidad con todos los tipos y falla con «Contrast failed». ¿Qué significa y qué se hace mientras no se arregla?
4.En
testRendimientoDeJugarUnPartidoEnterohay una expectativa dentro demeasure { … }. ¿Por qué?5.En una clase de XCTest,
var taquilla: Taquilla!lleva!y se usa sin?. ¿Qué hay que cuidar?