
De Casos de Uso a Código: Construyendo el Core con TDD (sin infraestructura)
Aprende cómo convertir casos de uso claros en código Core listo para producción usando TDD. Definiremos entidades, protocolos y casos de uso para una app BTC/USD — todo probado, modular y sin infraestructura.
Introducción
En el artículo anterior transformamos requisitos vagos en historias de usuario, narrativas y casos de uso. Ahora toca mostrar cómo esos casos de uso se convierten en código.
Pero aquí viene el giro: no empezaremos con networking ni persistencia. En su lugar, nos enfocaremos en el Core: el corazón de la app donde viven los contratos y los comportamientos. Todo lo demás (infraestructura) vendrá después.
La meta: construir un Core modular, reutilizable y comprobable con TDD.
Al final verás cómo TDD naturalmente nos guía hacia una arquitectura limpia, y cómo los desafíos reales de desarrollo moldean mejores decisiones de diseño.
Paso 1: Definir las Entidades del Dominio
Empezamos con el modelo esencial del dominio: PriceQuote
. Representa un valor BTC/USD en un instante.
public struct PriceQuote: Equatable, Sendable {
public let value: Decimal
public let currency: String
public let timestamp: Date
public init(value: Decimal, currency: String, timestamp: Date) {
self.value = value
self.currency = currency
self.timestamp = timestamp
}
}
Decisiones de diseño aquí:
Decimal
nos da precisión para valores financieros (sin errores de coma flotante).Sendable
garantiza uso seguro a través de límites de concurrencia.Equatable
facilita pruebas y comparaciones.
Esta única entidad será la base de todo lo que sigue.
Paso 2 - Contratos: Puertos para Datos y Persistencia
Ahora definimos los protocolos (puertos). Estos contratos describen lo que el sistema necesita sin atarnos a implementaciones específicas.
public protocol PriceLoader: Sendable {
func loadLatest() async throws -> PriceQuote
}
PriceLoader
→ cómo obtenemos el último precio (Binance, CryptoCompare, etc. más adelante).Sendable
→ seguro para uso concurrente.async throws
→ reconoce que las operaciones pueden fallar y tomar tiempo.
Principio de Clean Architecture: Define los puertos en el Core; impleméntalos fuera.
Paso 3 - Primer Caso de Uso: FetchLatestPrice
Implementemos el comportamiento más simple: cargar la última cotización. Pero lo haremos test-first.
🔴 ROJO - Escribe la prueba fallida
@Suite("FetchLatestPriceUseCaseTests")
struct FetchLatestPriceUseCaseTests {
@Test func fetchLatestPrice_deliversValueFromLoader() async throws {
let expected = PriceQuote(
value: 68_901.23,
currency: "USD",
timestamp: Date()
)
let sut = FetchLatestPrice(loader: LoaderStub(result: .success(expected)))
let received = try await sut.execute()
#expect(received == expected)
}
}
Error de compilación: No se encuentra FetchLatestPrice
en el alcance
¡Esto es exactamente lo que queremos! La prueba nos dice lo que necesitamos construir.
🟢 VERDE - Haz que pase con el código mínimo
public struct FetchLatestPrice: Sendable {
private let loader: PriceLoader
public init(loader: PriceLoader) {
self.loader = loader
}
public func execute() async throws -> PriceQuote {
try await loader.loadLatest()
}
}
Y el stub de prueba:
private struct LoaderStub: PriceLoader {
let result: Result<PriceQuote, Error>
func loadLatest() async throws -> PriceQuote { try result.get() }
}
🔄 REFACTOR - Agregar manejo de errores
@Test func fetchLatestPrice_propagatesLoaderError() async {
let sut = FetchLatestPrice(loader: LoaderStub(result: .failure(DummyError.any)))
await #expect(throws: Error.self) {
_ = try await sut.execute()
}
}
private enum DummyError: Error { case any }
Insight clave: Solo escribimos código después de que las pruebas lo exigieron. TDD nos forzó a pensar primero en la API desde la perspectiva del cliente.
Paso 4 - Resiliencia: FetchWithFallback
Los requisitos decían: usa una fuente secundaria si la primaria falla. Implementémoslo con TDD.
🔴 ROJO - Define el comportamiento con pruebas
@Suite("FetchWithFallbackUseCaseTests")
struct FetchWithFallbackUseCaseTests {
@Test func fetchWithFallback_usesPrimaryOnSuccess() async throws {
let expected = PriceQuote(value: 68_900, currency: "USD", timestamp: Date())
let primary = LoaderStub(result: .success(expected))
let fallback = LoaderStub(result: .failure(DummyError.any))
let sut = FetchWithFallback(primary: primary, fallback: fallback)
let received = try await sut.execute()
#expect(received == expected)
}
@Test func fetchWithFallback_usesFallbackWhenPrimaryFails() async throws {
let expected = PriceQuote(value: 68_800, currency: "USD", timestamp: Date())
let primary = LoaderStub(result: .failure(DummyError.any))
let fallback = LoaderStub(result: .success(expected))
let sut = FetchWithFallback(primary: primary, fallback: fallback)
let received = try await sut.execute()
#expect(received == expected)
}
@Test func fetchWithFallback_throwsWhenBothFail() async {
let primary = LoaderStub(result: .failure(DummyError.any))
let fallback = LoaderStub(result: .failure(DummyError.any))
let sut = FetchWithFallback(primary: primary, fallback: fallback)
await #expect(throws: Error.self) {
_ = try await sut.execute()
}
}
}
🟢 VERDE - Implementa la lógica de fallback
public struct FetchWithFallback: Sendable {
private let primary: PriceLoader
private let fallback: PriceLoader
public init(primary: PriceLoader, fallback: PriceLoader) {
self.primary = primary
self.fallback = fallback
}
public func execute() async throws -> PriceQuote {
do {
return try await primary.loadLatest()
} catch {
return try await fallback.loadLatest()
}
}
}
Elegante. El do-catch expresa naturalmente el fallback.
Paso 5 - Desafío Real: Manejo de Timeouts
¿Y si una fuente tarda demasiado? No podemos congelar la app esperando. Agreguemos soporte de timeout.
Primero, necesitamos una abstracción de reloj.
public protocol Clock: Sendable {
func now() -> Date
func sleep(for seconds: TimeInterval) async
}
public struct SystemClock: Clock {
public init() {}
public func now() -> Date { Date() }
public func sleep(for seconds: TimeInterval) async {
try? await Task.sleep(nanoseconds: .init(seconds * 1_000_000_000))
}
}
🔴 ROJO - Prueba con timeout
@Suite("FetchWithFallback + Timeout")
struct FetchWithFallbackTimeoutTests {
@Test func usesPrimary_whenPrimaryFinishesBeforeTimeout() async throws {
let expected = PriceQuote(value: 68_900, currency: "USD", timestamp: Date())
let primary = ClosureLoader { expected } // Cargador rápido
let fallback = ClosureLoader { throw DummyError.any }
let sut = FetchWithFallback(
primary: primary,
fallback: fallback,
timeout: 1,
clock: TestClock() // Sin esperas reales en tests
)
let received = try await sut.execute()
#expect(received == expected)
}
// Helper para escenarios de prueba flexibles
struct ClosureLoader: PriceLoader {
let action: @Sendable () async throws -> PriceQuote
func loadLatest() async throws -> PriceQuote { try await action() }
}
private struct TestClock: Clock {
func now() -> Date { Date() }
func sleep(for seconds: TimeInterval) async {
// No-op: sin sleep en tests
}
}
}
🟢 VERDE - Implementa timeout con una carrera de tareas
public struct FetchWithFallback: Sendable {
private let primary: PriceLoader
private let fallback: PriceLoader
private let timeout: TimeInterval
private let clock: Clock
public init(
primary: PriceLoader,
fallback: PriceLoader,
timeout: TimeInterval = 0.8, // 800ms por defecto
clock: Clock = SystemClock()
) {
self.primary = primary
self.fallback = fallback
self.timeout = timeout
self.clock = clock
}
public func execute() async throws -> PriceQuote {
do {
return try await withThrowingTaskGroup(of: PriceQuote.self) { group in
group.addTask { try await primary.loadLatest() }
group.addTask {
await clock.sleep(for: timeout)
throw TimeoutError.primaryTimeout
}
let result = try await group.next()!
group.cancelAll()
return result
}
} catch {
return try await fallback.loadLatest()
}
}
}
private enum TimeoutError: Error { case primaryTimeout }
Patrón clave: Usar TaskGroup
para correr el cargador primario contra una tarea de timeout.
Paso 6 - Introducir Cache: La Capa Persistente
Ahora implementemos caché. Hay que persistir el último precio válido para escenarios offline.
🔴 ROJO - Empezar con la intención en pruebas
@Suite("PriceStoreTests")
struct PriceStoreTests {
@Test func saveValidPrice_shouldPersistSuccessfully() async throws {
let quote = PriceQuote(
value: 68_901.23,
currency: "USD",
timestamp: Date()
)
let sut = PriceStoreStub()
try await sut.save(quote) // No debe lanzar
}
@Test func loadCachedPrice_afterSave_shouldReturnSavedPrice() async throws {
let quote = PriceQuote(value: 68_901.23, currency: "USD", timestamp: Date())
let sut = PriceStoreStub()
try await sut.save(quote)
let cached = await sut.loadCached()
#expect(cached == quote) // Round-trip ok
}
}
Error de compilación: No existe PriceStore
.
🟢 VERDE - Crea el protocolo y una implementación mínima
public protocol PriceStore: Sendable {
func save(_ quote: PriceQuote) async throws
func loadCached() async -> PriceQuote?
}
// Stub de prueba
private actor PriceStoreStub: PriceStore {
private var cachedQuote: PriceQuote?
func save(_ quote: PriceQuote) async throws {
cachedQuote = quote
}
func loadCached() async -> PriceQuote? {
cachedQuote
}
}
🔄 REFACTOR - Agregar el caso de uso
public struct PersistLastValidPrice: Sendable {
private let store: PriceStore
public init(store: PriceStore) {
self.store = store
}
public func execute(_ quote: PriceQuote) async throws {
try await store.save(quote)
}
public func loadCached() async -> PriceQuote? {
await store.loadCached()
}
}
Y agreguemos pruebas de error más completas:
@Test func savePrice_whenStorageFails_shouldReturnError() async {
let quote = PriceQuote(value: 68_901.23, currency: "USD", timestamp: Date())
let sut = PriceStoreStub(shouldFail: true)
await #expect(throws: Error.self) {
try await sut.save(quote)
}
}
Conclusión
Fuimos de la definición de casos de uso → código Core funcionando usando puro TDD. El proceso reveló varios insights clave:
- Las pruebas guían hacia mejor diseño - Cada API pública fue moldeada pensando primero en las pruebas.
- Los errores informan la arquitectura - Los patrones de fallback surgieron de escenarios de fallo.
- Los patrones de concurrencia emergen naturalmente - Las necesidades de performance llevaron a
async let
. - La evolución de protocolos requiere disciplina - Los cambios en cascada son inevitables.
- El sistema de tipos guía la seguridad - La decisión entre actor vs struct se vuelve obvia.
Nuestro Core de la app BTC/USD ahora puede:
- ✅ Cargar cotizaciones con fallback y timeout
- ✅ Cachear valores para uso offline
- ✅ Formatear datos para presentación
- ✅ Orquestar operaciones en ritmo de 1 segundo
- ✅ Manejar todos los escenarios de error con gracia
Todo modular. Todo testeado. Todo sin IO.
El desarrollo no siempre fue suave: nos topamos con loops infinitos, condiciones de carrera y cambios que rompían todo. Pero cada desafío nos enseñó algo valioso sobre diseño de sistemas concurrentes.
¿Qué sigue?
En el próximo artículo conectaremos este Core con el mundo real:
BTCPriceNetworking
→ loaders concretos para las APIs de Binance y CryptoCompareBTCPricePersistence
→ un store real usandoUserDefaults
o almacenamiento en archivoBTCPriceFeature
→ un ViewModel que orquesta todoPruebas de integración
→ escenarios end-to-end con URLProtocolStub
Solo entonces construiremos el CLI y las apps iOS que los usuarios realmente verán.
El Core está listo. Es hora de conectarlo con la realidad 🚀.