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

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 @TestClase 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 deinitsetUp() y tearDown()
@Test(arguments: […])No hay: se escribe un bucle o varias pruebas
Se ejecutan en paraleloEn serie por defecto
No sirve para pruebas de interfaz ni de rendimientoEs 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.

ejemplo mínimo: Swift Testing (con comentarios)
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)
    }
}
ejemplo mínimo: XCTest (con comentarios)
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 por test es una prueba.
  • setUp() async throws: el método que XCTest llama antes de cada prueba.
  • var hucha: Hucha!: un opcional que empieza en nil y se usa sin ? (el ! de «desempaquetado implícito»). Se rellena en setUp, y si alguna prueba lo usara antes, se caería; por eso solo se usa donde setUp siempre corre antes (Opcionales).
  • XCTAssertEqual, XCTAssertTrue, XCTAssertFalse, XCTAssertNil: las comprobaciones de XCTest, una por tipo (la última falla si el valor no es nil).
  • measure { … }: ejecuta el bloque varias veces y mide cuánto tarda. Solo existe en XCTest.
  • expectation, fulfill() y wait(for:timeout:): en XCTest, una prueba síncrona (measure lo es) espera a que termine una tarea async con una expectativa.
  • Task { @MainActor in … }: lanza una tarea que corre en el hilo principal. measure es síncrono y quien se prueba suele ser async, así que la tarea es el puente para poder esperar.

Todas juntas, con una hucha en vez de un ViewModel:

ejemplo mínimo: setUp, un opcional con !, XCTAssert y measure con una expectativa (con comentarios)
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.

  1. Crea la carpeta MarcadorAppTests/Ejercicios (otra Ejercicios, distinta de la de MarcadorApp) y, en ella, Taquilla, con un fichero TaquillaXCTests.swift. Es la primera vez que haces esto dentro del target de pruebas (los ejercicios anteriores de iOS están dentro de MarcadorApp, y XCTest solo existe en el target de pruebas): clic derecho sobre la carpeta MarcadorAppTests → New Folder → Ejercicios; dentro, New Folder → Taquilla; y dentro de ella, New File from Template… → Swift File → TaquillaXCTests, con solo MarcadorAppTests marcado en Targets si el diálogo lo pregunta. Alternativa desde la Terminal: mkdir -p ~/Marcador-Swift/MarcadorApp/MarcadorAppTests/Ejercicios/Taquilla y touch del fichero dentro; Xcode los muestra solos.
  2. 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.
  3. 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.
  4. 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
MarcadorAppTests/Ejercicios/Taquilla/TaquillaXCTests.swift
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.

MarcadorApp (proyecto)
MarcadorAppTests
Marcador
UI
PartidoViewModelXCTests.swiftse crea
En el Project navigator: un fichero nuevo.

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:

  • @MainActor delante de la clase: el ViewModel es del hilo principal, y en XCTest setUp y los test… heredan el aislamiento de la clase.
  • weak var referencia: una referencia débil, que se pone a nil cuando ARC libera el objeto (ARC); sirve para comprobar que el ViewModel se libera.
  • Un bloque do { … } a secas, sin catch: 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:

  1. testJugarAplicaLosGolesDelGuion: tras jugar el partido 1 entero, el marcador es 2-1, el minuto 90 y el partido no corre.
  2. testUnPartidoQueNoExisteDaUnEstadoDeError: con el partido 99, el estado de la pantalla es el error «No existe el partido n.º 99».
  3. 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.
  4. 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
MarcadorAppTests/Marcador/UI/PartidoViewModelXCTests.swift
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:

Ayudas.swift: sinAvisos, ya lo tienes
// 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):

MarcadorAppUITests.swift: la auditoría, con el fallo
    @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
MarcadorAppUITests.swift: la auditoría, con tres tipos menos
    // 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 test

Tarda 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 en MarcadorAppUITests y testLaunch).
  • En el Test navigator (Cmd+6) aparecen PartidoViewModelXCTests con cuatro pruebas, incluida testRendimientoDeJugarUnPartidoEntero, y RegistrarGolTests con la parametrizada.
  • La auditoría testAuditoriaDeAccesibilidadDeLaLista pasa con .all.subtracting([.contrast, .dynamicType, .textClipped]).
  • 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.¿Qué equivale en XCTest a #expect(a == b) de Swift Testing?

  2. 2.Necesitas medir cuánto tarda en ejecutarse un bloque de código con measure { }. ¿Con qué marco lo escribes?

  3. 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. 4.En testRendimientoDeJugarUnPartidoEntero hay una expectativa dentro de measure { … }. ¿Por qué?

  5. 5.En una clase de XCTest, var taquilla: Taquilla! lleva ! y se usa sin ?. ¿Qué hay que cuidar?

Faltan 5 de 5