wandres.dev
REDUCERS Y EFECTOS · State, Action, Effect

@Dependency: inyectar el mundo para poder controlarlo

Un reducer puro no puede leer el reloj, generar un UUID ni llamar a la red directamente, porque eso lo haría no determinista e intestable. La solución de TCA es la inyección de dependencias con el property wrapper Dependency: el reducer declara qué trozos del mundo necesita y la arquitectura se los provee, con una versión viva en producción y otra controlada en tests y previews. Esta lección cubre las dependencias de la biblioteca (continuousClock, uuid, date), cómo registrar la tuya con DependencyKey y el macro DependencyClient, y la tríada liveValue, testValue y previewValue que convierte el control del entorno en una propiedad del sistema de tipos.

⏱ 18 min

Un reducer puro tiene prohibido leer el reloj del sistema, generar un UUID, tirar un dado o hablar con la red, porque cualquiera de esas cosas lo volvería no determinista: la misma acción sobre el mismo estado dejaría de dar el mismo resultado, y con ello se caerían el testeo y la reproducibilidad. Pero una app real necesita el reloj, los identificadores y la red. La salida de TCA a esa paradoja es la inyección de dependencias: en lugar de que el reducer tome el mundo, el mundo se le entrega. El reducer declara qué trozos del entorno necesita con el property wrapper @Dependency, y la arquitectura los provee —con una versión de verdad en producción y una versión controlada en tests y previews—. Controlar el entorno deja de ser una técnica de test y pasa a ser una propiedad del sistema de tipos.

🎯 Al terminar esta lección sabrás
  • Explicar por qué inyectar el mundo es la condición de un reducer determinista.
  • Usar @Dependency para leer el reloj, generar UUID y acceder a un cliente de red.
  • Registrar una dependencia propia con DependencyKey y el macro @DependencyClient.
  • Distinguir liveValue, testValue y previewValue, y sobrescribir con withDependencies.

El mundo no debe estar cableado al reducer

Llamar a Date(), UUID() o URLSession.shared directamente dentro de la lógica es lo que en TCA se llama tener el mundo cableado: la impureza soldada al reducer, imposible de sustituir. Sus consecuencias son concretas y dolorosas. Los tests dependen del reloj real —una aserción sobre una fecha falla mañana— y de la red real —lentos, intermitentes, ligados a un servidor—. Las previews de SwiftUI no pueden dibujarse porque intentan pegar a un backend que no existe en el canvas. La solución, que TCA hereda de la librería swift-dependencies, es un contenedor de dependencias global pero sobrescribible: hay un único punto donde vive cada capacidad del entorno, y puedes reemplazarla en un ámbito acotado.

// Cableado: intestable y no determinista
case .guardarPulsado:
  state.items.append(Item(id: UUID(), creado: Date()))
  return .none

Ese código parece inocente y es veneno: cada vez que corras el test obtendrás un UUID distinto y una fecha distinta, así que ninguna aserción sobre state.items podrá ser exacta.

La cura no consiste en prohibir el reloj o la red —una app real los necesita— sino en cambiar quién los provee. En vez de que el reducer los tome del ambiente global, los declara como algo que se le debe entregar, y esa inversión de control es toda la idea.

Conviene subrayar que este contenedor no es un singleton al uso ni un framework de inyección con grafos y anotaciones. Es un almacén de valores con alcance dinámico: cada dependencia tiene un valor por defecto y puedes sobrescribirla para un ámbito acotado —un test, una preview, una rama concreta del árbol de features— sin afectar al resto de la app. Esa combinación de valor por defecto y sobrescritura local es lo que la vuelve ergonómica y a la vez segura.

@Dependency: declarar lo que el reducer necesita

El property wrapper @Dependency lee un valor del contenedor DependencyValues por su ruta de clave. Se declara en el struct del reducer y queda disponible dentro de reduce.

@Reducer
struct Feature {
  @Dependency(\.uuid) var uuid
  @Dependency(\.date.now) var ahora
  @Dependency(\.continuousClock) var clock

  func reduce(into state: inout State, action: Action) -> Effect<Action> {
    switch action {
    case .guardarPulsado:
      state.items.append(Item(id: uuid(), creado: ahora))
      return .none
    }
  }
}

Ahora uuid() no llama al generador real del sistema: llama al que el contenedor tenga registrado en este momento. En producción es el generador de verdad; en un test escribes $0.uuid = .incrementing y obtienes identificadores predecibles —el primero acaba en cero, el siguiente en uno— que sí puedes afirmar. El mundo entró por la puerta que el reducer eligió, y esa puerta tiene una llave que tú controlas.

Las dependencias de la biblioteca

swift-dependencies trae registradas las capacidades más comunes del entorno, listas para inyectar. No las creas tú: solo las lees con @Dependency y las sustituyes en tests.

continuousClock

@Dependency(\.continuousClock) da un reloj cuyo sleep(for:) sustituye a Task.sleep. En tests lo cambias por un TestClock para avanzar el tiempo a mano o un ImmediateClock que no espera nada.

🆔

uuid

@Dependency(\.uuid) genera identificadores sin llamar a UUID(). En tests, .incrementing produce UUID deterministas; en producción, los reales del sistema.

📅

date

@Dependency(\.date.now) da el instante actual sin llamar a Date(). En tests lo fijas a una fecha concreta para que las aserciones no dependan de cuándo se ejecutan.

El reloj es el caso más instructivo. Un try await Task.sleep(for: .seconds(2)) dentro de un efecto obliga a que el test espere dos segundos reales; con @Dependency(\.continuousClock) y un TestClock, el test avanza el reloj con await clock.advance(by: .seconds(2)) de forma instantánea y determinista. El tiempo, esa dependencia que parecía imposible de controlar, se vuelve un valor más.

Registrar tu dependencia: DependencyKey y @DependencyClient

Tus servicios externos —la API, la base de datos, el analytics— se modelan como un struct de closures, no como un protocolo. Un struct de closures se sustituye campo a campo con trivialidad, sin crear una clase falsa por cada test. El macro @DependencyClient genera el inicializador y, sobre todo, un testValue cuyas closures llaman a unimplemented: si un test invoca una operación que no preparaste, falla en voz alta en vez de devolver un valor silencioso.

@DependencyClient
struct ClienteAPI {
  var cargarArticulos: @Sendable () async throws -> [Articulo]
  var guardar: @Sendable (Articulo) async throws -> Void
}

extension ClienteAPI: DependencyKey {
  static let liveValue = ClienteAPI(
    cargarArticulos: { try await Backend.pedirArticulos() },
    guardar: { articulo in try await Backend.enviar(articulo) }
  )
}

extension DependencyValues {
  var clienteAPI: ClienteAPI {
    get { self[ClienteAPI.self] }
    set { self[ClienteAPI.self] = newValue }
  }
}

La extensión de DependencyValues expone la ruta \.clienteAPI, y a partir de ahí el reducer la usa como cualquier dependencia de la biblioteca:

@Dependency(\.clienteAPI) var clienteAPI
// ...
return .run { send in
  await send(.datosRecibidos(try await clienteAPI.cargarArticulos()))
}

liveValue, testValue, previewValue y withDependencies

Cada dependencia tiene hasta tres caras. liveValue es la implementación real que corre en la app publicada. testValue es la de los tests: con @DependencyClient viene no implementada por defecto, lo que fuerza a que declares explícitamente qué operaciones usa cada test. previewValue es la de las previews de SwiftUI, normalmente con datos de muestra. Para sobrescribir en un ámbito concreto se usa withDependencies, y el TestStore lo integra en su inicializador.

let store = TestStore(initialState: Feature.State()) {
  Feature()
} withDependencies: {
  $0.clienteAPI.cargarArticulos = { [.demo] }
  $0.uuid = .incrementing
  $0.continuousClock = ImmediateClock()
}
flowchart LR
RD[reducer con Dependency] --> DV[DependencyValues]
DV -->|en produccion| L[liveValue]
DV -->|en tests| T[testValue]
DV -->|en previews| P[previewValue]
style DV fill:#89b4fa,color:#11111b
style T fill:#f9e2af,color:#11111b
Inyectar el mundo es convertir el entorno en un argumento, y un argumento se puede elegir

La inyección de dependencias de TCA parece fontanería y es en realidad un cambio de estatus ontológico: el mundo deja de ser un hecho ambiental que el código sufre y pasa a ser un argumento que el código recibe. Un Date() incrustado es un hecho: ocurre, no lo eliges, no lo puedes cambiar. Un @Dependency(\.date.now) es un argumento: llega de fuera, tiene un valor por defecto vivo y uno controlado en test, y en cualquier ámbito puedes decidir cuál rige. Esa conversión —de hecho a argumento— es lo que hace posible todo lo que TCA promete, porque un argumento se puede sustituir y un hecho no. Fíjate en la asimetría de esfuerzo: cablear el mundo es gratis hoy y carísimo mañana, cuando descubres que ningún test es fiable y ninguna preview arranca; inyectarlo cuesta unas líneas hoy y es gratis para siempre después. La tríada liveValue, testValue, previewValue codifica una verdad incómoda: tu código corre en tres mundos distintos —producción, test, diseño— y cada uno necesita un entorno distinto, así que fingir que hay un solo mundo cableado es negar la realidad de cómo se desarrolla software. El testValue no implementado que genera @DependencyClient lleva esta filosofía al límite sano: por defecto, ninguna dependencia funciona en un test, y cada una que uses debes declararla; el test se vuelve así un contrato explícito de qué trozos del mundo toca esa lógica. Cuando ves las dependencias no como un patrón de testeo sino como la decisión de que el entorno sea siempre elegible, entiendes por qué TCA es tan testeable: no añadió testeabilidad, se negó desde el principio a cablear el mundo.

⚔️ Descablea una feature del mundo real
  1. Encuentra en tu código un Date(), UUID() o llamada de red directa dentro de un reducer o su efecto. Nómbralo como la impureza cableada que es.
  2. Sustitúyelo por @Dependency(\.date.now), @Dependency(\.uuid) o un cliente propio, y verifica que la lógica sigue idéntica en producción.
  3. Modela un servicio externo como struct de closures anotado con @DependencyClient, y regístralo con DependencyKey y una extensión de DependencyValues.
  4. Escribe un TestStore que fije $0.uuid = .incrementing y $0.continuousClock = ImmediateClock(), y afirma un estado con un UUID exacto y sin esperas reales.
  5. Explica qué ocurre en el test si invocas una operación del cliente que no sobrescribiste, y por qué ese fallo ruidoso del testValue es una virtud y no un estorbo.