Swifty Journey Blog
De Casos de Uso a Código: Construyendo el Core con TDD (sin infraestructura)
5 min de lectura

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:

  1. Las pruebas guían hacia mejor diseño - Cada API pública fue moldeada pensando primero en las pruebas.
  2. Los errores informan la arquitectura - Los patrones de fallback surgieron de escenarios de fallo.
  3. Los patrones de concurrencia emergen naturalmente - Las necesidades de performance llevaron a async let.
  4. La evolución de protocolos requiere disciplina - Los cambios en cascada son inevitables.
  5. 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 CryptoCompare
  • BTCPricePersistence → un store real usando UserDefaults o almacenamiento en archivo
  • BTCPriceFeature → un ViewModel que orquesta todo
  • Pruebas 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 🚀.

Recursos de Desarrollo

Recursos de Desarrollo

Elige tu píldora de desarrollo — sumérgete en herramientas y recursos para iOS y más allá.

Ver recursos

Disclaimer: Algunos enlaces son de afiliado. Pagas lo mismo; una pequeña comisión puede apoyar este sitio.