wandres.dev
DEPENDENCYKEY · live, test y preview

testValue y previewValue: fallar a propósito y dibujar sin backend

Cada dependencia vive en tres mundos y cada mundo pide un valor distinto. Esta lección explica la jerarquía TestDependencyKey y DependencyKey, por qué el testValue por defecto debe ser una implementación que falla en voz alta con unimplemented, cómo se comporta la librería cuando accedes a una dependencia sin implementación de test, qué datos merece un previewValue para que el canvas de SwiftUI muestre estados reales, y el patrón de separar el módulo de interfaz del módulo vivo.

⏱ 19 min

Tu código no corre en un mundo, corre en tres: la app publicada, la suite de tests y el canvas de las previews. Fingir que hay uno solo es la causa de la mitad de la fricción del desarrollo en Apple —tests que tardan porque hablan con la red, previews que no dibujan porque intentan autenticarse—. La tríada liveValue, testValue y previewValue responde a esa realidad con una honestidad casi incómoda: te obliga a decir qué entorno rige en cada mundo. Y la pieza más contraintuitiva es el testValue, cuyo trabajo por defecto no es funcionar sino fallar. Esa inversión —una dependencia que se rompe si la tocas sin haberla declarado— es la que convierte un test en una demostración con premisas explícitas.

🎯 Al terminar esta lección sabrás
  • Distinguir TestDependencyKey de DependencyKey y saber qué requisito aporta cada uno.
  • Construir un testValue con unimplemented y justificar por qué fallar supera a devolver vacío.
  • Escribir un previewValue con datos de muestra que exhiban estados interesantes sin tocar la red.
  • Separar el módulo de interfaz del módulo vivo para no arrastrar SDK pesados a cada feature.

Tres mundos y una jerarquía de dos protocolos

La librería parte el registro en dos niveles. TestDependencyKey pide testValue y ofrece previewValue con un valor por defecto; DependencyKey lo refina y añade liveValue. Esa jerarquía no es burocracia: permite que un módulo declare la interfaz de un cliente junto con sus versiones de test y preview sin conocer siquiera la implementación real.

public protocol TestDependencyKey {
  associatedtype Value: Sendable
  static var testValue: Value { get }
  static var previewValue: Value { get }   // por defecto, el testValue
}

public protocol DependencyKey: TestDependencyKey {
  static var liveValue: Value { get }
}

Cuando conformas solo DependencyKey y no escribes testValue, la librería no te deja caer en silencio al valor vivo. Al acceder a esa dependencia desde un contexto de test emite un fallo con un mensaje inequívoco: la dependencia no tiene implementación de test y no está permitido usar la viva desde un test. El mensaje es el diseño hablando: prefiere romperte el test hoy a que mañana descubras que tu suite hacía peticiones reales.

Valor Rige en Qué debe contener
liveValue App publicada La implementación real, sin ramas condicionales
testValue Tests Endpoints que fallan salvo los que el test declare
previewValue Canvas y previews Datos de muestra ricos, sin red ni disco

testValue: una implementación que se niega a funcionar

La herramienta es unimplemented, que fabrica una closure cuya única conducta es reportar un fallo con el nombre que le das y, si la firma lo permite, lanzar. El placeholder cubre el caso de una firma que no puede lanzar y necesita devolver algo para compilar.

extension ClienteArticulos: TestDependencyKey {
  static let testValue = ClienteArticulos(
    cargar: unimplemented("ClienteArticulos.cargar", placeholder: []),
    buscar: unimplemented("ClienteArticulos.buscar", placeholder: []),
    guardar: unimplemented("ClienteArticulos.guardar"),
    borrar: unimplemented("ClienteArticulos.borrar")
  )
}

La tentación de sustituir eso por un cliente inofensivo —devolver un array vacío, no hacer nada al guardar— es fuerte y hay que resistirla, porque un doble complaciente produce el peor resultado posible: un test que pasa por el motivo equivocado. Si tu reducer llama a guardar cuando no debería, el doble silencioso lo acepta y la aserción sobre el estado sigue verde; el unimplemented lo denuncia con el nombre del endpoint en el mensaje. Dicho de otro modo: el testValue no es un doble de prueba, es un detector de dependencias no declaradas.

Hay una excepción razonable y conviene delimitarla: los endpoints que no producen ningún efecto observable en la lógica —una llamada de analítica que el reducer emite pero cuyo resultado nunca consulta— pueden tener una implementación de test vacía si tu suite no pretende afirmarlos. En cuanto quieras comprobar qué se emitió, vuelve a la versión que denuncia y sustitúyela en el test por una que acumule los eventos.

De ahí se sigue la forma canónica de un test: parte de que nada funciona y enciende solo lo que el escenario necesita.

let store = TestStore(initialState: Lista.State()) {
  Lista()
} withDependencies: {
  $0.clienteArticulos.cargar = { [.demo] }   // lo único que este test permite
}

El bloque withDependencies se lee entonces como la lista de premisas de la demostración: estos son los trozos del mundo que esta lógica toca, y ningún otro. Cuando alguien añada más adelante una llamada a buscar dentro del reducer, este test fallará señalando el endpoint nuevo, que es exactamente la conversación que quieres tener en una revisión de código.

Nombra siempre el endpoint dentro del unimplemented con la forma tipo punto operación. Ese texto es lo único que verás cuando el fallo salte a las tres de la tarde en la máquina de integración continua, y la diferencia entre leer el nombre exacto y leer un fallo genérico es la diferencia entre corregir en un minuto y abrir el depurador.

flowchart TB
D[una dependencia] --> L[liveValue en la app publicada]
D --> T[testValue en la suite]
D --> P[previewValue en el canvas]
T --> F[endpoints que fallan por defecto]
F --> W[el test declara solo lo que usa]
P --> S[datos de muestra sin red]
style T fill:#f9e2af,color:#11111b
style W fill:#a6e3a1,color:#11111b

previewValue: datos que enseñan algo

El canvas no necesita rigor, necesita representatividad. Un previewValue que devuelve una lista vacía técnicamente funciona y no sirve para nada: no verás celdas, ni truncamientos, ni el aspecto de un título largo. Elige datos que exhiban los casos que de verdad te preocupan y, si la vista tiene estados de carga o de error, hazlos alcanzables.

extension ClienteArticulos {
  static let previewValue = ClienteArticulos(
    cargar: {
      try await Task.sleep(for: .milliseconds(400))   // deja ver el indicador
      return [.corto, .conTituloMuyLargo, .sinImagen]
    },
    buscar: { texto in Articulo.muestra.filter { $0.titulo.contains(texto) } },
    guardar: { _ in },
    borrar: { _ in }
  )
}

Hay una lectura más profunda de esto que conviene tener presente. El previewValue es, de hecho, la especificación ejecutable de los datos que tu interfaz debe saber mostrar; si al escribirlo te cuesta decidir qué casos incluir, lo que estás descubriendo es que nadie ha definido todavía los límites de la vista —cuánto texto cabe, qué ocurre sin imagen, cómo se ve un cero—. Escribirlo bien es, por tanto, un ejercicio de diseño de producto disfrazado de utilidad de desarrollo.

Dos avisos. El primero es que previewValue hereda de testValue si no lo defines, lo que significa que una preview de una feature con dependencias no implementadas se rompe al primer efecto; es molesto y es correcto, porque te empuja a escribirlo. El segundo es que una preview jamás debe tocar la red, el disco del usuario ni un servicio de analítica: el canvas se reconstruye docenas de veces por minuto mientras editas, y una preview que emite eventos reales es un generador de basura silencioso.

💡
Sobrescribir dependencias en una preview concreta

No tienes que conformarte con el previewValue global. Dentro de un #Preview puedes construir el Store pasando el bloque de dependencias, y así montar una preview del estado de error y otra del estado vacío a partir de la misma vista. Es la manera más barata de revisar estados que en la app real cuesta horas reproducir.

Separar la interfaz de la implementación viva

El patrón que escala a proyectos grandes consiste en poner la struct del cliente y su conformidad a TestDependencyKey en un módulo de interfaz ligero, y la conformidad a DependencyKey con el liveValue en un módulo aparte que solo enlaza la app.

Las features dependen del módulo de interfaz, que no arrastra el SDK de red, la base de datos ni el framework de terceros; sus tests compilan y corren en una fracción del tiempo, y ninguna feature puede llamar por accidente a la implementación real porque literalmente no la tiene enlazada. Es la misma idea que separar una cabecera de su unidad de traducción, aplicada al grafo de módulos de Swift Package Manager, y su beneficio crece de forma superlineal con el tamaño del equipo.

📄

Módulo de interfaz

Contiene la struct del cliente, su conformidad a TestDependencyKey con testValue y previewValue, y la extensión de DependencyValues. Depende de casi nada y compila en segundos.

🔌

Módulo vivo

Contiene la conformidad a DependencyKey con el liveValue y todo el peso real: red, persistencia, SDK de terceros. Solo lo enlaza el objetivo de la app.

🧩

Módulos de feature

Dependen únicamente de la interfaz. No pueden invocar la implementación real ni por accidente, y sus tests no pagan el coste de compilar el mundo entero.

Ese reparto tiene además un efecto de diseño que suele pasarse por alto: obliga a que la firma del cliente sea autosuficiente. Si la interfaz necesitara un tipo del SDK para expresarse, el módulo ligero dejaría de ser ligero, y la separación fallaría en la compilación antes de fallar en la revisión. La estructura de módulos se convierte así en un guardián de la frontera del dominio, tema que cierra este nivel.

📝
Lo esencial

TestDependencyKey exige testValue y ofrece previewValue por defecto; DependencyKey lo refina añadiendo liveValue. Un testValue construido con unimplemented hace que ningún endpoint funcione salvo los que un test declare, y convierte el bloque de sobrescrituras en la lista explícita de premisas del escenario. El previewValue alimenta el canvas con datos de muestra representativos y jamás toca red ni disco. Separar el módulo de interfaz del módulo vivo mantiene todo eso barato de compilar e imposible de saltarse.

Fallar por defecto invierte la carga de la prueba y convierte el test en una demostración con premisas

Casi toda la industria del mocking comparte una premisa que nadie enuncia: que el doble de prueba debe ser complaciente, devolver ceros y vacíos y dejar que el test siga. Esa premisa es la que produce la patología más cara del testing, la del test que pasa por una razón distinta de la que su nombre afirma; y no se detecta con cobertura, porque el código se ejecutó, ni con revisión, porque el fallo está en lo que el test no dice. El testValue no implementado invierte la carga de la prueba: el estado por defecto del mundo pasa a ser inaccesible, y cada trozo que la lógica toque debe declararse explícitamente para que exista. La consecuencia es que el bloque de sobrescrituras deja de ser configuración y se convierte en la lista de premisas de una demostración: este teorema sobre mi reducer se sostiene bajo exactamente estas suposiciones sobre el entorno, y el compilador y el runtime vigilan que no metas una premisa de contrabando. Esa propiedad tiene un segundo efecto, más valioso que el primero: hace que el conjunto de dependencias de una feature sea observable. Si al escribir un test descubres que necesitas encender siete endpoints de cuatro clientes distintos, acabas de recibir un diagnóstico de acoplamiento que ninguna métrica estática te habría dado con esa claridad, porque no mide referencias sintácticas sino trozos del mundo realmente ejercitados por el flujo. Y el corolario incómodo cierra el círculo: un testValue complaciente no ahorra trabajo, lo aplaza y lo encarece, porque convierte cada regresión futura en una investigación en lugar de en un mensaje de fallo con el nombre del endpoint. La disciplina que impone esta tríada es la de tratar el entorno como parte de la especificación y no como decorado; una vez la asumes, la pregunta qué habría pasado si el servidor devolvía un error deja de ser una hipótesis y pasa a ser una línea de código.

⚔️ Haz que tus dobles griten
  1. Escribe el testValue completo de tu cliente con unimplemented en todos los endpoints y observa qué tests existentes empiezan a fallar.
  2. Para cada fallo, decide si el reducer debía tocar ese endpoint o si acabas de encontrar un acoplamiento indebido.
  3. Sustituye un doble complaciente que devolvía vacío y comprueba si algún test seguía verde por el motivo equivocado.
  4. Escribe un previewValue con tres artículos que exhiban título largo, ausencia de imagen y caso mínimo, y añade una espera corta para ver el indicador de carga.
  5. Divide tu paquete en módulo de interfaz y módulo vivo, mide el tiempo de compilación de los tests antes y después y anota la diferencia.