Testear la lógica: modelos, repositorios y estado
La lógica de una app solo es comprobable si antes se ha vuelto determinista, y volverla determinista significa convertir en parámetros explícitos todo lo que el sistema le regalaba: el reloj, la red, el disco, el azar y el identificador. Esta lección explica cómo se dibuja la frontera del sujeto bajo prueba, qué doble corresponde a cada situación y por qué la verificación de llamadas suele ser una trampa, y cómo se controlan el tiempo y la red sin recurrir a esperas ni a servidores reales.
Una prueba de lógica que resulta difícil de escribir casi nunca revela un problema de pruebas: revela que el código bajo examen adquiere sus dependencias en lugar de recibirlas. Un modelo que construye su propio cliente de red, lee la hora del reloj del sistema y guarda en el almacén compartido no es un objeto difícil de probar, es un objeto que no tiene entradas, y sin entradas no hay función, y sin función no hay nada que verificar. Testear la lógica consiste por tanto en un trabajo previo de diseño: hacer explícito lo que estaba implícito hasta que el comportamiento del sujeto quede determinado por argumentos que la prueba controla. El resto es sintaxis.
- Dibujar la frontera del sujeto bajo prueba y expresarla como protocolos estrechos que el llamador provee.
- Elegir entre falso, stub y espía según lo que la prueba deba afirmar, evitando la verificación de llamadas cuando no es la regla.
- Controlar el tiempo mediante un reloj inyectable para probar reintentos, esperas y antirrebote sin dormir el hilo.
- Sustituir la red con un doble determinista y comprobar los estados de carga, éxito y error de un modelo observable.
La frontera del sujeto es un protocolo estrecho
El primer gesto es separar la decisión de la ejecución. La lógica interesante de una app casi siempre es una función del estado y de unos datos hacia un nuevo estado; todo lo demás es fontanería que se debe poder sustituir.
// La frontera: describe lo que el modelo NECESITA, no lo que el servicio SABE hacer.
protocol RepositorioTareas: Sendable {
func cargar() async throws -> [Tarea]
func guardar(_ tarea: Tarea) async throws
}
@Observable @MainActor
final class ListaTareasModel {
enum Estado: Equatable { case inactivo, cargando, listo, fallo(String) }
private(set) var tareas: [Tarea] = []
private(set) var estado: Estado = .inactivo
private let repositorio: any RepositorioTareas
init(repositorio: any RepositorioTareas) { self.repositorio = repositorio }
func cargar() async {
estado = .cargando
do {
tareas = try await repositorio.cargar()
estado = .listo
} catch {
estado = .fallo(String(describing: error))
}
}
}
El protocolo debe describir la necesidad del consumidor, no la capacidad del proveedor. Un protocolo con dieciocho métodos porque el servicio real tiene dieciocho obliga a cada doble a implementarlos todos y convierte cada cambio en el servicio en un cambio en todas las pruebas. Dos métodos que el modelo usa de verdad producen dobles de cinco líneas y desacoplan el ritmo de cambio de las dos capas. Esa es también la diferencia entre inyectar por constructor y leer un contenedor global desde dentro: lo primero hace la dependencia visible en la firma y por tanto sustituible en cada prueba; lo segundo la esconde y obliga a mutar estado compartido entre casos, que es la vía más rápida a una suite dependiente del orden. Conviene además centralizar la construcción del sujeto en una función hacerSut por suite, que devuelva también los dobles que la prueba vaya a interrogar: así una dependencia nueva no obliga a tocar treinta pruebas, y si esa función acaba necesitando diez parámetros, la señal no va sobre las pruebas sino sobre un sujeto con demasiadas responsabilidades.
Falso, stub y espía: qué afirma cada uno
Los dobles no son intercambiables, y elegir mal produce pruebas que se rompen sin que nada esté roto. La distinción práctica es qué papel juega el doble en la afirmación final.
Stub · programa la respuesta
Devuelve lo que la prueba necesita para llegar al caso interesante, incluido un error. No se interroga después. Es el doble por defecto y el más barato de mantener.
Falso · implementación real y simple
Un repositorio en memoria con un diccionario dentro. Sirve cuando la prueba ejercita varias operaciones seguidas y la coherencia entre ellas importa.
Espía · registra lo ocurrido
Guarda las llamadas recibidas para poder afirmar sobre efectos que no son observables en el estado, como que se envió el evento de analítica.
Verificador estricto · úsalo poco
Exigir un número exacto de llamadas en un orden exacto convierte la prueba en una copia de la implementación: cualquier refactor legítimo la pone roja sin que haya defecto.
final class RepositorioFalso: RepositorioTareas, @unchecked Sendable {
var resultado: Result<[Tarea], Error> = .success([])
private(set) var guardadas: [Tarea] = [] // parte de espía
private(set) var vecesCargado = 0
func cargar() async throws -> [Tarea] { vecesCargado += 1; return try resultado.get() }
func guardar(_ tarea: Tarea) async throws { guardadas.append(tarea) }
}
@Test @MainActor func elFalloDeCargaDejaEstadoDeFallo() async {
let repo = RepositorioFalso()
repo.resultado = .failure(ErrorRed.sinConexion)
let sut = ListaTareasModel(repositorio: repo)
await sut.cargar()
#expect(sut.tareas.isEmpty) // no hay datos parciales
#expect(sut.estado == .fallo(String(describing: ErrorRed.sinConexion)))
}
La regla que ahorra más dolor a medio plazo es afirmar sobre el estado resultante siempre que sea posible, y recurrir al espía solo cuando el efecto no deja huella en el estado. Comprobar que el modelo terminó en fallo sobrevive a cualquier reorganización interna; comprobar que llamó dos veces a cargar fija un detalle de implementación que quizá mañana sea uno o tres sin que el comportamiento cambie.
El tiempo como parámetro, no como circunstancia
Todo lo que en el código dice “espera”, “reintenta”, “expira” o “agrupa” es lógica de negocio disfrazada de tiempo, y es exactamente donde viven los defectos difíciles. Dormir el hilo en la prueba es la solución equivocada: multiplica la duración de la suite y sustituye determinismo por probabilidad.
struct ClienteConReintento<C: Clock> where C.Duration == Duration {
let reloj: C // producción: ContinuousClock
let base: any RepositorioTareas
let intentos: Int
func cargar() async throws -> [Tarea] {
var espera = Duration.milliseconds(200)
for intento in 1...intentos {
do { return try await base.cargar() } catch {
guard intento < intentos else { throw error }
try await reloj.sleep(for: espera) // el reloj decide, no el sistema
espera *= 2 // retroceso exponencial
}
}
throw ErrorRed.sinConexion
}
}
Al declarar la dependencia como un Clock genérico, producción recibe ContinuousClock y la prueba recibe un reloj de prueba que avanza cuando ella lo ordena. La suite pasa a comprobar la propiedad interesante —que hubo tres intentos y que la espera se duplicó— en microsegundos y sin ninguna condición de carrera. El mismo patrón cubre el antirrebote de un buscador, la caducidad de una credencial y el temporizador de una cuenta atrás. Y la lección general aplica a las otras dos fuentes de indeterminismo: el generador de números aleatorios y el generador de identificadores también deben ser parámetros, o la prueba tendrá que afirmar sobre valores que no puede predecir.
Escribir una espera de medio segundo confiando en que la tarea ya habrá terminado produce el peor de los mundos: una prueba lenta que además falla en la máquina cargada de la integración continua. Si la operación es asíncrona, espérala con await; si el resultado llega por una secuencia, consúmela; si es un callback, envuélvelo con confirmation o con una continuación. El tiempo real no debe aparecer nunca en una prueba de lógica.
flowchart LR sut[Sujeto bajo prueba] --> r[Reloj inyectado] sut --> rep[Repositorio inyectado] sut --> id[Generador de identificadores inyectado] r --> t[Prueba: reloj que avanza cuando ella lo ordena] rep --> t2[Prueba: falso en memoria o stub programado] id --> t3[Prueba: secuencia predecible] t --> d[Salidas determinadas por las entradas] t2 --> d t3 --> d
La red: interceptar en el borde correcto
Cuando el sujeto es el propio cliente de red —el que construye la petición y decodifica la respuesta— sustituir el protocolo entero no sirve, porque justo eso es lo que hay que probar. El punto de intercepción correcto está por debajo, en la capa de transporte.
final class ProtocoloFalso: URLProtocol {
nonisolated(unsafe) static var responder: ((URLRequest) -> (HTTPURLResponse, Data))!
override class func canInit(with peticion: URLRequest) -> Bool { true }
override class func canonicalRequest(for p: URLRequest) -> URLRequest { p }
override func stopLoading() {}
override func startLoading() {
let (respuesta, datos) = Self.responder(request)
client?.urlProtocol(self, didReceive: respuesta, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: datos)
client?.urlProtocolDidFinishLoading(self)
}
}
Con ese interceptor registrado en una configuración de sesión efímera, la prueba controla el código de estado, las cabeceras y el cuerpo exactos, y puede por tanto fijar las tres cosas que de verdad importan en esa capa: que la petición se construye con el verbo, la ruta y la autorización correctos; que una respuesta bien formada se decodifica en el modelo esperado; y que un cuerpo malformado o un código de error se traducen en el error de dominio que la interfaz sabe mostrar. Guardar cargas reales como recursos fijos y decodificarlas es, además, la prueba más barata que existe contra el cambio silencioso de un contrato ajeno.
Si algo hay que llevarse de esta lección es que la testabilidad no es una propiedad que se añade al final, sino el nombre que damos a una propiedad estructural que el código tiene o no tiene: que sus salidas estén determinadas por sus entradas. Todo lo que hemos hecho aquí —extraer un protocolo estrecho, inyectar por constructor, parametrizar el reloj, interceptar el transporte, hacer explícito el generador de identificadores— es exactamente el mismo movimiento repetido sobre distintas dependencias, y ninguno de esos movimientos se justifica por las pruebas. El protocolo estrecho existe porque desacopla ritmos de cambio. El reloj inyectado existe porque el retroceso exponencial es una política del producto y merece un nombre. El transporte sustituible existe porque la frontera con un servidor ajeno es el sitio donde tu sistema es más frágil y conviene poder ejercitarla. Que además todo eso vuelva el código comprobable no es una coincidencia feliz: es que ambas cosas son consecuencia de la misma virtud, que consiste en que cada pieza declare de qué depende. Por eso la señal más valiosa que da una prueba difícil de escribir no aparece cuando falla, sino mucho antes, mientras la escribes: si para verificar una regla necesitas montar seis colaboradores, sembrar el almacén compartido y esperar dos segundos, la prueba te está diciendo que esa regla no vive en ningún sitio identificable de tu arquitectura, y que la solución no es un doble más sofisticado sino un tipo nuevo que la contenga. Escuchar esa señal en lugar de vencerla con herramientas es lo que separa una suite que envejece bien de una que hay que borrar en dos años.
- Toma un modelo tuyo que construya su propia dependencia por dentro y muévela al constructor detrás de un protocolo con solo los métodos que usa.
- Escribe la fábrica
hacerSutde esa suite y comprueba que añadir una dependencia nueva solo obliga a tocar un sitio. - Sustituye un
Task.sleepreal de tu código por una espera sobre un reloj inyectado y escribe la prueba que fija el retroceso exponencial sin dormir un solo milisegundo. - Convierte una prueba que verifica número de llamadas en otra que afirma sobre el estado final, y explica qué refactor deja ahora de romperla.
- Monta el interceptor de transporte y escribe tres pruebas del mismo endpoint: respuesta válida, cuerpo malformado y código de error, cada una con su error de dominio esperado.