Saltar al contenido
K
Arquitectura en iOSPaso a paso40 min de lectura · 8 min de máquina Intermedio

La pantalla de la crónica y sus pruebas

Al terminar: Publicar la crónica desde una pantalla y comprobar con pruebas los errores de red.

Al terminar, en la pantalla de un partido de Marcador habrá un botón «Crónica» que abre una pantalla con el título del partido, una línea por gol y un botón «Publicar». Al pulsarlo, la app manda la crónica al servidor y enseña «Crónica publicada con el n.º 101»; si algo falla, un mensaje claro en lugar de un error técnico. Además escribirás tus primeras pruebas contra un servicio falso: tres, una por cada resultado posible.

Importa porque es la primera pantalla que escribe en un servidor, y porque aquí se ve para qué sirvió el trabajo de la lección anterior: la pantalla enseña el texto de ErrorDeRed y no sabe nada de URLError ni de códigos HTTP.

Temas: un ViewModel que lee un partido y redacta su crónica, if case let para quedarse con un tipo de evento, una función static privada, la pantalla con sus tres secciones, el destino con un número, y el servicio falso con #expect(throws:). Nivel 2: usar.

Tres piezas de Swift antes de escribir el ViewModel

El ViewModel de esta lección tiene que redactar la crónica: recorrer los eventos de un partido, quedarse solo con los goles y juntar una línea por cada uno. Eso usa tres cosas que conviene ver antes, con otro ejemplo (un reparto de pedidos):

  • if case let .entrega(hora, cliente) = aviso: «¿este valor es el caso entrega? Si lo es, saca sus datos a hora y cliente». Es el patrón del switch de Enums: el partido de eventos escrito como un if, para cuando solo te interesa un caso y el resto da igual. Sin sacar los datos se escribe if case .entrega = aviso.
  • private static func: una función static es del tipo y no de una instancia concreta (como el static func == de struct frente a class), así que no puede usar las propiedades del objeto. private la esconde del resto de la app. Dentro de un método del mismo tipo se llama con Self., que quiere decir «el tipo en el que estoy».
  • lineas.joined(separator: "\n") une una lista de textos en uno solo poniendo un separador entre ellos; "\n" es un salto de línea. Y condicion ? a : b elige a o b según la condición.
ejemplo mínimo: if case let, una función static y joined (con comentarios)
enum Aviso {
    case entrega(hora: Int, cliente: String)
    case retraso(minutos: Int)
}
 
struct Reparto {
    let avisos: [Aviso]
 
    // `static`: no usa `self`. `private`: solo se llama desde dentro de `Reparto`.
    private static func lineas(de avisos: [Aviso]) -> String {
        var lineas: [String] = []
        for aviso in avisos {
            // Solo nos quedamos con las entregas; los retrasos se ignoran.
            if case let .entrega(hora, cliente) = aviso {
                lineas.append("\(hora)h \(cliente)")
            }
        }
        // Sin entregas, un texto fijo; con ellas, una línea por entrega.
        return lineas.isEmpty ? "Sin entregas" : lineas.joined(separator: "\n")
    }
 
    func informe() -> String {
        // `Self.`: llama a la función `static` del propio tipo.
        Self.lineas(de: avisos)
    }
}
 
let manana = Reparto(avisos: [.entrega(hora: 9, cliente: "Ana"), .retraso(minutos: 15), .entrega(hora: 11, cliente: "Luis")])
print(manana.informe())
print(Reparto(avisos: [.retraso(minutos: 5)]).informe())
9h Ana
11h Luis
Sin entregas

Las dos primeras líneas son del primer reparto (el retraso no sale); la tercera, del segundo, que no tiene ninguna entrega.

Reto · Ejercicio: el informe de una biblioteca

En tu Playground (reutiliza el mismo y borra lo anterior), escribe un enum Movimiento con dos casos, prestamo(dia:socio:) y devolucion(dia:socio:), y un struct Biblioteca con una lista de movimientos. Tiene que dar un informe() que:

  1. Escriba una línea por cada movimiento, sea del tipo que sea: «Día 1: Marta se llevó un libro» o «Día 2: Pedro devolvió un libro». Esas líneas las saca una función private static, que se llama con Self..
  2. Si no hay movimientos, esas líneas se sustituyen por «Sin movimientos».
  3. Termine con una última línea, «Préstamos: N», con cuántos movimientos son préstamos (cuéntalos con if case, sin sacar los datos).

Pruébalo con tres movimientos (préstamo de Marta el día 1, devolución de Pedro el 2 y préstamo de Pedro el 3) y con una biblioteca vacía. Lo que debe salir:

Día 1: Marta se llevó un libro
Día 2: Pedro devolvió un libro
Día 3: Pedro se llevó un libro
Préstamos: 2
Sin movimientos
Préstamos: 0
Ver solución
solución: el informe de una biblioteca
enum Movimiento {
    case prestamo(dia: Int, socio: String)
    case devolucion(dia: Int, socio: String)
}
 
struct Biblioteca {
    let movimientos: [Movimiento]
 
    // Una línea por movimiento, sea del tipo que sea: aquí hay que tratar los DOS casos.
    private static func lineas(de movimientos: [Movimiento]) -> String {
        var lineas: [String] = []
        for movimiento in movimientos {
            switch movimiento {
            case let .prestamo(dia, socio):
                lineas.append("Día \(dia): \(socio) se llevó un libro")
            case let .devolucion(dia, socio):
                lineas.append("Día \(dia): \(socio) devolvió un libro")
            }
        }
        return lineas.isEmpty ? "Sin movimientos" : lineas.joined(separator: "\n")
    }
 
    func informe() -> String {
        var prestamos = 0
        for movimiento in movimientos {
            // Solo preguntamos «¿es un préstamo?»; no hace falta sacar sus datos.
            if case .prestamo = movimiento {
                prestamos += 1
            }
        }
        return Self.lineas(de: movimientos) + "\nPréstamos: \(prestamos)"
    }
}
 
let semana = Biblioteca(movimientos: [
    .prestamo(dia: 1, socio: "Marta"),
    .devolucion(dia: 2, socio: "Pedro"),
    .prestamo(dia: 3, socio: "Pedro"),
])
print(semana.informe())
print(Biblioteca(movimientos: []).informe())

Aquí hay que tratar los dos casos, y por eso el switch; el if case sirve cuando solo importa uno.

Paso 1 · El ViewModel, que hace demasiado: CronicaViewModel.swift

Objetivo: el ViewModel de la pantalla de la crónica.

Este ViewModel hace demasiado, a propósito. Lee el partido del repositorio de partidos, decide cómo se escribe la crónica (el título es el resultado, y el texto una línea por gol) y la publica con el repositorio de crónicas. Son tres tareas en un solo sitio, y dos repositorios que conoce a la vez. Funciona, pero la regla de «cómo se escribe una crónica» no debería vivir en el ViewModel: es lo que arreglarás en Casos de uso: cuándo sí y cuándo sobran.

MarcadorApp (proyecto)
MarcadorApp
Cronica
UI
CronicaViewModel.swiftse crea
CronicaView.swiftse crea
Destino.swiftse modifica
Marcador
UI
PartidoEnDirectoView.swiftse modifica
Partidos
UI
ListaDePartidosView.swiftse modifica
MarcadorAppTests
Core
Dobles.swiftse modifica
Cronica
Data
CronicasRepositorioTests.swiftse crea
Los ficheros de esta lección en el Project navigator.

La pantalla de la crónica y su ViewModel van juntos en Cronica/UI (la carpeta Cronica ya la tienes de la lección anterior): clic derecho sobre Cronica → New Folder → UI. Después, clic derecho sobre UI → New File from Template… → Swift File, solo con el target MarcadorApp marcado, y de nombre CronicaViewModel. Con la Terminal: mkdir -p ~/Marcador-Swift/MarcadorApp/MarcadorApp/Cronica/UI && touch ~/Marcador-Swift/MarcadorApp/MarcadorApp/Cronica/UI/CronicaViewModel.swift.

Intenta escribirlo tú. Dos tipos en el mismo fichero:

  • CronicaUiState, un struct que se puede comparar, con la crónica (puede no haberla, si el partido no existe), si está publicando (empieza en no) y un mensaje (puede no haberlo).
  • CronicaViewModel, una clase final con @MainActor y @Observable, que recibe el id del partido, el repositorio de partidos y el de crónicas. Al crearse busca el partido. Si existe, redacta la crónica con una función private static: el título es «local marcador visitante» y el texto, una línea «minuto' jugador (equipo)» por cada gol, o «Sin goles» si no hubo. Si no existe, el mensaje es «No existe el partido» y su número. Su publicar() marca que está publicando, publica con el repositorio de crónicas y deja el mensaje «Crónica publicada con el n.º» y el número, o el texto del error (localizedDescription). Al terminar, anota el resultado con Registro.anotar y marca que ya no publica.

@ObservationIgnored private let cronicas aparece marcado así porque ninguna pantalla pinta el repositorio: es una pieza de trabajo, no un dato de la vista. (En un let la marca no cambia nada, como viste en @Observable; la ponemos para dejar claro que no forma parte de lo que se pinta.)

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorApp/Cronica/UI/CronicaViewModel.swift
import Foundation
import Observation
import Torneo
 
// LO QUE VE LA PANTALLA DE LA CRÓNICA.
struct CronicaUiState: Equatable {
    var cronica: Cronica?        // nil: el partido no existe
    var publicando = false
    var mensaje: String?         // «Crónica publicada con el n.º 101», o el error
}
 
// OJO: este ViewModel hace DEMASIADO, a propósito. Lee el partido de un repositorio, decide cómo se
// escribe la crónica y la publica con otro. Más adelante esa regla se irá a un caso de uso.
@MainActor
@Observable
final class CronicaViewModel {
    private(set) var uiState: CronicaUiState
 
    @ObservationIgnored private let cronicas: any CronicasRepositorio
 
    init(partidoId: Int, partidos: any PartidosRepositorio, cronicas: any CronicasRepositorio) {
        self.cronicas = cronicas
        if let partido = partidos.partido(id: partidoId) {
            uiState = CronicaUiState(cronica: Self.redactar(partido))
        } else {
            uiState = CronicaUiState(mensaje: "No existe el partido \(partidoId)")
        }
    }
 
    // La regla: el título es el resultado y el texto, una línea por gol («12' Ana (Rayo FC)»).
    private static func redactar(_ partido: Partido) -> Cronica {
        var lineas: [String] = []
        for evento in partido.eventos {
            if case let .gol(minuto, jugador, equipo) = evento {
                lineas.append("\(minuto)' \(jugador.nombre) (\(equipo.nombre))")
            }
        }
        return Cronica(
            titulo: "\(partido.local.nombre) \(partido.marcador()) \(partido.visitante.nombre)",
            texto: lineas.isEmpty ? "Sin goles" : lineas.joined(separator: "\n")
        )
    }
 
    // El botón «Publicar».
    func publicar() async {
        guard let cronica = uiState.cronica else { return }
        uiState.publicando = true
        uiState.mensaje = nil
        do {
            let id = try await cronicas.publicar(titulo: cronica.titulo, texto: cronica.texto)
            uiState.mensaje = "Crónica publicada con el n.º \(id)"
        } catch {
            // `ErrorDeRed` es `LocalizedError`: aquí sale «No hay conexión» o «El servidor respondió…».
            uiState.mensaje = error.localizedDescription
        }
        uiState.publicando = false
        Registro.anotar("crónica: \(uiState.mensaje ?? "")")
    }
}

Paso 2 · La pantalla: CronicaView.swift

Objetivo: la vista con el título, los goles y el botón «Publicar».

Intenta escribirla tú: CronicaView, que recibe su ViewModel como la de los árbitros. Una List con tres secciones: «Título» (el título en negrita), «Goles» (el texto) y una con el botón «Publicar» (desactivado mientras publica, y con una ProgressView() al lado). Debajo, si hay mensaje, un Text con él y el identificador de accesibilidad mensaje-cronica (una etiqueta invisible que usarán las pruebas de interfaz; no cambia lo que ves). El título de la barra es «Crónica del partido».

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorApp/Cronica/UI/CronicaView.swift
import SwiftUI
 
// La pantalla de la crónica: el título, los goles y el botón «Publicar».
struct CronicaView: View {
    @State private var viewModel: CronicaViewModel
 
    init(viewModel: CronicaViewModel) {
        _viewModel = State(wrappedValue: viewModel)
    }
 
    var body: some View {
        let estado = viewModel.uiState
        List {
            if let cronica = estado.cronica {
                Section("Título") {
                    Text(cronica.titulo).bold()
                }
                Section("Goles") {
                    Text(cronica.texto)
                }
                Section {
                    Button("Publicar") {
                        Task { await viewModel.publicar() }
                    }
                    .disabled(estado.publicando)
                    if estado.publicando {
                        ProgressView()
                    }
                }
            }
            if let mensaje = estado.mensaje {
                Text(mensaje).accessibilityIdentifier("mensaje-cronica")
            }
        }
        .navigationTitle("Crónica del partido")
        .navigationBarTitleDisplayMode(.inline)
    }
}

Paso 3 · Los botones y el destino

Objetivo: llegar a la pantalla desde un partido.

Tres cambios pequeños. Hasta que acabes los tres, el proyecto estará en rojo (el switch de ListaDePartidosView aún no tiene el caso .cronica, y Xcode dice switch must be exhaustive); es normal, y vuelve a compilar al terminar este paso. Primero, un caso nuevo con el número del partido en Destino.swift:

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorApp/Destino.swift
enum Destino: Hashable {
    case partido(Int)   // el id del partido (viaja en la clave, en un solo sitio)
    case goleadores
    case arbitros       // la lista de árbitros (botón «Árbitros» de la barra de Partidos)
    case cronica(Int)   // la crónica del partido con ese id (botón «Crónica» del partido)
}

Después, un enlace «Crónica» en PartidoEnDirectoView.swift, justo debajo del de «Ver goleadores». datos es el parámetro de la función contenido(_ datos:) de ese fichero, el mismo que ya usan las líneas de alrededor (datos.partido, datos.minuto); y partidoId es el número del partido:

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorApp/Marcador/UI/PartidoEnDirectoView.swift
                    NavigationLink("Ver goleadores", value: Destino.goleadores)   // ← esta línea ya la tienes
                    // La crónica de ESTE partido: el id viaja en el destino.
                    NavigationLink("Crónica", value: Destino.cronica(datos.partidoId))

El resto del fichero no cambia: solo se añaden estas dos líneas debajo de la que ya tenías (el comentario y el enlace «Crónica»).

Y el caso nuevo en el switch de .navigationDestination de ListaDePartidosView.swift, después del de .arbitros. El repositorio que se le pasa al ViewModel es el que esa vista ya tiene arriba del todo (@Environment(RepositorioDePartidos.self) private var repositorio), el mismo que pinta la lista:

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorApp/Partidos/UI/ListaDePartidosView.swift
                case .arbitros:
                    // OJO: la pantalla crea aquí, a escondidas, el servicio y el repositorio. Funciona,
                    // pero cada vez que se abre nace un repositorio nuevo. Se arregla con la inyección.
                    ArbitrosView(viewModel: ArbitrosViewModel(
                        repositorio: RepositorioDeArbitrosEnRed(servicio: ServicioJSONPlaceholder())
                    ))
                case let .cronica(id):
                    // También a escondidas: el servicio y el repositorio de crónicas nacen aquí.
                    CronicaView(viewModel: CronicaViewModel(
                        partidoId: id,
                        partidos: repositorio,
                        cronicas: RepositorioDeCronicasEnRed(servicio: ServicioJSONPlaceholder())
                    ))

El resto del fichero no cambia.

Ejecuta con Cmd+R. Abre el partido 1 y toca «Crónica». El partido que estaba en marcha se pausa mientras la pantalla de la crónica está encima (lo cancela el .task al apilar otra pantalla, como en Navegar a los goleadores). Verás «Rayo FC 3-2 Toros» como título y cinco líneas de goles; si marcaste goles a mano en ese partido desde la última vez que arrancaste la app de cero, salen también en el título y en las líneas. Pulsa «Publicar»: tras un momento, «Crónica publicada con el n.º 101». Si vuelves y publicas otra vez, sale 101 otra vez: el servidor de pruebas no guarda nada y siempre contesta lo mismo. Con el wifi del Mac apagado, «Publicar» enseña «No hay conexión».

Paso 4 · Las pruebas

Objetivo: probar los dos fallos y el éxito del repositorio, con un servicio falso.

En Dobles.swift añade, al final, el falso del servicio. Responde lo que le digas, sin red, y anota las crónicas que recibe para poder comprobarlas. Dos detalles: arbitrosQueDevuelve es un Result como el del falso de los árbitros (try arbitrosQueDevuelve.get() devuelve la lista o lanza el fallo), y if let falloAlPublicar { throw falloAlPublicar } es la forma corta de if let falloAlPublicar = falloAlPublicar: «si hay un fallo preparado, lánzalo».

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorAppTests/Core/Dobles.swift
// … lo que ya tenías no cambia …
 
// El servicio falso: responde lo que le digas, sin red, y anota las crónicas que le enviaron.
final class ServicioFalso: ServicioDelTorneo {
    var arbitrosQueDevuelve: Result<[ArbitroDTO], ErrorDeRed> = .success([])
    var idQueAsigna = 101
    var falloAlPublicar: ErrorDeRed?
    private(set) var publicadas: [CronicaNuevaDTO] = []
 
    func arbitros() async throws -> [ArbitroDTO] {
        try arbitrosQueDevuelve.get()
    }
 
    func publicarCronica(_ cronica: CronicaNuevaDTO) async throws -> CronicaPublicadaDTO {
        if let falloAlPublicar { throw falloAlPublicar }
        publicadas.append(cronica)
        return CronicaPublicadaDTO(id: idQueAsigna, title: cronica.title)
    }
}

Después, crea CronicasRepositorioTests.swift. Prueba el repositorio de Cronica/Data, así que va en el mismo sitio de las pruebas: clic derecho sobre MarcadorAppTests → New Folder → Cronica, dentro otra New Folder → Data, y en Data New File from Template… → Swift File, con solo el target MarcadorAppTests marcado. Con la Terminal: mkdir -p ~/Marcador-Swift/MarcadorApp/MarcadorAppTests/Cronica/Data && touch ~/Marcador-Swift/MarcadorApp/MarcadorAppTests/Cronica/Data/CronicasRepositorioTests.swift. Tres pruebas en Swift Testing:

  1. publicaYDevuelveElIdDelServidor: publica y recibe 101; el servicio falso anotó el título y el texto enviados.
  2. sinConexionLanzaSinConexion: si el servicio falla con .sinConexion, publicar lanza .sinConexion.
  3. unQuinientosLanzaElErrorDelServidorConSuCodigo: si falla con .servidor(codigo: 500), publicar lanza ese error.

await #expect(throws: …) { … } comprueba que el bloque lanza justo ese error (por eso ErrorDeRed es Equatable).

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorAppTests/Cronica/Data/CronicasRepositorioTests.swift
import Testing
@testable import MarcadorApp
 
// El repositorio de crónicas con un servicio FALSO: ni red ni servidor, y los fallos a la carta.
@MainActor
struct CronicasRepositorioTests {
 
    @Test func publicaYDevuelveElIdDelServidor() async throws {
        let servicio = ServicioFalso()
        let repositorio = RepositorioDeCronicasEnRed(servicio: servicio)
        let id = try await repositorio.publicar(titulo: "Rayo FC 2-1 Toros", texto: "12' Ana (Rayo FC)")
        #expect(id == 101)
        // Lo que viajó al servidor: el DTO con el título y el texto.
        #expect(servicio.publicadas.first?.title == "Rayo FC 2-1 Toros")
        #expect(servicio.publicadas.first?.body == "12' Ana (Rayo FC)")
    }
 
    @Test func sinConexionLanzaSinConexion() async {
        let servicio = ServicioFalso()
        servicio.falloAlPublicar = .sinConexion
        let repositorio = RepositorioDeCronicasEnRed(servicio: servicio)
        await #expect(throws: ErrorDeRed.sinConexion) {
            try await repositorio.publicar(titulo: "Rayo FC 2-1 Toros", texto: "Sin goles")
        }
    }
 
    @Test func unQuinientosLanzaElErrorDelServidorConSuCodigo() async {
        let servicio = ServicioFalso()
        servicio.falloAlPublicar = .servidor(codigo: 500)
        let repositorio = RepositorioDeCronicasEnRed(servicio: servicio)
        await #expect(throws: ErrorDeRed.servidor(codigo: 500)) {
            try await repositorio.publicar(titulo: "Rayo FC 2-1 Toros", texto: "Sin goles")
        }
    }
}

Lanza Cmd+U.

Punto de control

  • Cmd+B termina en Build Succeeded, y Cmd+U da 46 pruebas en verde: 30 unitarias y 16 de interfaz. Las tres nuevas son las de CronicasRepositorioTests. Si dejaste el ejercicio de la taquilla en MarcadorAppTests/Ejercicios, salen 49: los ejercicios con pruebas suman a la cuenta.
  • En el simulador, abrir el partido 1 → «Crónica» → «Publicar» enseña «Crónica publicada con el n.º 101»; al repetirlo, sigue siendo 101.
  • La pantalla de árbitros sigue funcionando igual.
  • El ejercicio de la biblioteca imprime las seis líneas.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.¿Qué hace if case let .gol(minuto, jugador, equipo) = evento { … }?

  2. 2.La pantalla de la crónica enseña «No hay conexión» y no un texto técnico. ¿Por qué?

  3. 3.¿Para qué sirve ServicioFalso en las pruebas del repositorio de crónicas?

  4. 4.¿Qué comprueba await #expect(throws: ErrorDeRed.sinConexion) { try await repositorio.publicar(…) }?

  5. 5.El CronicaViewModel lee el partido, decide cómo se redacta la crónica y la publica. ¿Qué se dice de él en la lección?

Faltan 5 de 5