wandres.dev
ORBIT: TESTING · assertions del flujo

Dobles de prueba: sustituir el repositorio y testear sin red

Un container solo es testeable si sus dependencias se pueden sustituir, y la calidad del test depende menos del arnés que de la calidad del doble que ocupa el lugar del repositorio. Esta lección distingue el falso del simulacro y defiende el primero para las dependencias con estado, muestra cómo construir un repositorio falso basado en flujos que permita inyectar emisiones y fallos a voluntad, explica por qué la frontera debe ser una interfaz propia y no el cliente de red, y advierte del riesgo de que el doble derive del original hasta volver verde un test que en producción fallaría.

⏱ 18 min

Todo lo que hemos construido hasta aquí —la secuencia de estados, el orden de los efectos, el reloj virtual— descansa sobre una condición previa que rara vez se discute con el cuidado que merece: que el container reciba sus colaboradores desde fuera y no los fabrique dentro. Un container que instancia su propio repositorio, que llama a un objeto singleton o que resuelve una dependencia desde un contenedor global no es un container difícil de testear, es un container imposible de testear, y ninguna librería de pruebas puede arreglar eso. Pero la inyección solo abre la puerta; lo que decide el valor real del test es qué colocas al otro lado. Un doble bien construido convierte el camino infeliz —la red que cae, la caché rancia, el servidor que responde tarde y mal— en algo tan barato de reproducir como el camino feliz, y ese abaratamiento es lo que hace que esos caminos por fin se prueben.

🎯 Al terminar esta lección sabrás
  • Separar la frontera de datos con una interfaz propia y entender por qué no debe ser el cliente de red.
  • Elegir entre falso y simulacro según si la dependencia tiene estado y comportamiento propios.
  • Construir un repositorio falso basado en flujos que permita inyectar emisiones, retardos y fallos.
  • Prevenir la deriva entre el doble y el original con tests de contrato compartidos.

La frontera se dibuja donde termina tu dominio

Antes de escribir ningún doble hay que decidir qué se sustituye, y esa decisión tiene una respuesta correcta que mucha gente falla: se sustituye la interfaz que tú posees, no la librería que consumes. Si el container depende directamente del cliente de red o del objeto de acceso a la base de datos, el doble tendrá que imitar formas ajenas —códigos de estado, cuerpos de respuesta, excepciones de la librería— y el test acabará hablando el idioma del transporte en vez del idioma del negocio.

interface ArticuloRepo {
    fun observar(id: Id): Flow<Articulo>
    suspend fun refrescar(id: Id): Result<Unit>
}

Esa interfaz es tuya, está expresada en tipos de tu dominio y no menciona ni la red ni el disco. El container depende de ella y de nada más, y el test puede describir escenarios en términos comprensibles: el artículo existe en local pero el refresco falla, el artículo llega tarde, el artículo cambia mientras la pantalla está abierta. Ninguna de esas frases necesita hablar de peticiones ni de códigos numéricos.

💡
La interfaz no es ceremonia: es dónde pones la costura

La objeción habitual es que una interfaz con una sola implementación es abstracción innecesaria. En una capa interna, es cierto. En la frontera con el mundo exterior, no: esa interfaz es lo que permite que exista una segunda implementación —la de prueba— y con ella el noventa por ciento de tu batería de tests. La abstracción se justifica por la costura que crea, no por el número de implementaciones de producción.

Falso frente a simulacro

Un simulacro configurado con una librería declara respuestas para llamadas concretas y luego verifica que se hicieron. Un falso es una implementación real y sencilla, escrita a mano, que se comporta como el original dentro de un dominio reducido. La elección entre ambos no es de estilo.

🎭

Simulacro

Adecuado para dependencias sin estado en las que lo interesante es que se llamó: un registrador de analítica, un emisor de notificaciones. Verificar la interacción es aquí verificar el requisito.

🧪

Falso

Adecuado para dependencias con estado y con flujos: un repositorio, una caché, una fuente de preferencias. Emite, guarda, recuerda; el test comprueba resultados en vez de coreografías.

Para un repositorio, el falso gana casi siempre, y la razón es que un repositorio tiene memoria: lo que devuelve la segunda vez depende de lo que ocurrió en la primera. Reproducir eso con un simulacro exige encadenar respuestas por número de invocación, y ese encadenamiento acopla el test al orden de las llamadas internas del container, que es exactamente el detalle de implementación que uno querría poder refactorizar sin romper nada.

class ArticuloRepoFalso(inicial: Articulo? = null) : ArticuloRepo {
    private val flujo = MutableStateFlow(inicial)
    var resultadoRefresco: Result<Unit> = Result.success(Unit)
    var retardoRefrescoMs: Long = 0

    override fun observar(id: Id): Flow<Articulo> = flujo.filterNotNull()

    override suspend fun refrescar(id: Id): Result<Unit> {
        delay(retardoRefrescoMs)
        return resultadoRefresco
    }

    // palanca del test: simula que la fuente cambia sola
    fun emitir(articulo: Articulo) { flujo.value = articulo }
}

Las tres palancas expuestas —el resultado del refresco, el retardo y la emisión manual— cubren la práctica totalidad de los escenarios de una pantalla de detalle sin tocar una sola línea del container. El retardo, combinado con el reloj virtual de la lección anterior, permite además afirmar qué ve el usuario durante la operación, que es donde viven los indicadores de carga y las cancelaciones.

El flujo completo, sin red y sin emulador

Con la costura en su sitio, un test de extremo a extremo de la capa de presentación deja de ser un ejercicio de infraestructura y se vuelve una descripción del comportamiento.

flowchart LR
T[Test] -->|inyecta| F[Repo falso]
F -->|flujo de dominio| C[Container]
C -->|estados y efectos| A[Aserciones]
R[Red real] -.->|nunca participa| C
style F fill:#a6e3a1,color:#11111b
style R fill:#f38ba8,color:#11111b
@Test
fun `si el refresco falla se muestra el error y se conserva lo local`() = runTest {
    val repo = ArticuloRepoFalso(inicial = articuloLocal).apply {
        resultadoRefresco = Result.failure(SinConexion)
    }
    val vm = DetalleViewModel(repo)

    vm.test(this) {
        expectInitialState()
        runOnCreate()
        expectState { copy(articulo = articuloLocal) }
        containerHost.refrescar()
        expectState { copy(refrescando = true) }
        expectState { copy(refrescando = false, aviso = Aviso.SinConexion) }
        expectNoItems()
    }
}

Ese test verifica una regla de producto nada trivial —ante un fallo de red se avisa pero no se borra lo que ya había— en unos pocos milisegundos y sin ninguna dependencia externa. Escribir el mismo caso contra un servidor real exigiría poder provocar la caída a voluntad, y contra un emulador exigiría además tolerar su latencia; ninguna de las dos cosas ocurre en la práctica, y por eso el camino infeliz es el que nunca se prueba en las bases de código que no dibujan esta costura.

⚠️
El peligro real del doble: la deriva

Un falso empieza siendo fiel y envejece siendo cómodo. Alguien añade al repositorio real una validación, una normalización o un caso de error, y el falso no se entera; a partir de ahí tus tests verifican un mundo que ya no existe y pasan justamente cuando deberían fallar. La defensa es un test de contrato: una batería única, escrita contra la interfaz, que se ejecuta dos veces —contra la implementación real, en integración, y contra el falso— y que falla en cuanto ambos dejan de coincidir en lo que prometen.

El contrato compartido y la disciplina del falso

El test de contrato es la pieza que convierte un doble en algo defendible, y su construcción es más sencilla de lo que su nombre sugiere: una clase abstracta con los casos escritos contra la interfaz y una función que entrega la implementación, con dos subclases que la resuelven de forma distinta.

abstract class ArticuloRepoContrato {
    abstract fun crear(inicial: Articulo): ArticuloRepo

    @Test
    fun `observar emite el articulo inicial`() = runTest {
        val repo = crear(articuloLocal)
        assertEquals(articuloLocal, repo.observar(id).first())
    }

    @Test
    fun `refrescar no borra lo observado si falla`() = runTest { /* ... */ }
}

class ContratoDelFalso : ArticuloRepoContrato() {
    override fun crear(inicial: Articulo) = ArticuloRepoFalso(inicial)
}

La subclase que ejercita la implementación real vive en la fuente de tests instrumentados o de integración, se ejecuta con menos frecuencia y puede permitirse ser lenta; la del falso corre en cada compilación. Lo importante es que ambas comparten literalmente las mismas afirmaciones, de modo que la pregunta “¿mi doble sigue siendo fiel?” deja de responderse por intuición y pasa a responderla la máquina de integración continua.

Hay además tres reglas de higiene que mantienen sano un falso a lo largo del tiempo. La primera es que el falso no debe contener lógica condicional compleja: si empieza a tener ramas, deja de ser un doble y se convierte en una segunda implementación con sus propios fallos, y acabarás depurando el test en vez del código. La segunda es que las palancas se exponen como propiedades sencillas y no como funciones de configuración encadenadas, porque un test debe leerse como una descripción de escenario y no como el manual de un artefacto. La tercera es que el falso pertenece a la misma unidad de código que la interfaz que implementa, y se publica junto a ella; un falso escondido en la carpeta de tests de una sola pantalla acaba duplicado tres veces, con tres comportamientos distintos y ningún contrato que los concilie.

ℹ️
Un falso bueno se parece a una implementación en memoria

La mejor guía para escribir un falso no es imitar al original sino escribir la implementación más simple que cumpla el contrato: una estructura en memoria con la semántica correcta. Esa versión suele ser útil más allá del test —para las vistas previas de Compose, para una demostración sin servidor, para el modo sin conexión de un prototipo— y esa reutilización es la mejor señal de que el contrato estaba bien definido.

Un doble de prueba no imita una dependencia: encarna la hipótesis que tienes sobre ella

Conviene mirar el falso desde un ángulo incómodo, porque explica por qué tantas baterías verdes conviven con producciones rotas. Cuando escribes un repositorio falso no estás construyendo una copia barata del real: estás escribiendo, en código ejecutable, tu creencia sobre cómo se comporta la frontera del sistema. Si crees que el servidor nunca devuelve una lista con elementos repetidos, tu falso nunca los devuelve, y tu container jamás será interrogado sobre ese caso; el test no falla porque la pregunta no llegó a formularse. De ahí la conclusión que conviene tomarse en serio: la cobertura de tu batería está acotada por la imaginación de tus dobles, y ningún porcentaje de líneas ejecutadas informa sobre esa cota. Esto emparenta el testing con dobles con toda la familia de métodos que sustituyen un sistema por un modelo —simulaciones, bancos de ensayo, gemelos digitales— y hereda su virtud y su patología: la virtud es que puedes explorar escenarios que la realidad no te concede a demanda, como la caída de la red en el instante exacto de un pago; la patología es que el modelo se convierte, sin avisar, en la realidad contra la que decides, y toda la organización acaba razonando sobre un mundo simplificado que ya nadie compara con el verdadero. La cura no es desconfiar de los dobles ni sustituirlos por tests de integración lentos que nadie ejecutará, sino mantener explícita y viva la lista de suposiciones que cada doble encarna: escribirlas, revisarlas cuando un incidente de producción demuestre que una era falsa, y anclarlas con tests de contrato que obliguen al modelo y al original a decir lo mismo. El programador maduro no pregunta si sus dobles son fieles, porque sabe que la fidelidad total es imposible y además inútil; pregunta qué suposición concreta encarna cada uno, cuál de ellas se rompería primero, y si esa ruptura la detectaría un test o un usuario.

⚔️ Construye la costura y ponla a prueba
  1. Localiza en tu proyecto un container que instancie o resuelva por su cuenta alguna dependencia y conviértela en parámetro del constructor.
  2. Define la interfaz de repositorio en tipos de tu dominio, sin mencionar la red ni el almacenamiento, y comprueba que el container no importa nada del transporte.
  3. Escribe un falso con al menos tres palancas: resultado configurable, retardo configurable y emisión manual desde el test.
  4. Cubre con él dos caminos infelices que hoy no estén cubiertos y anota cuánto tardas en escribir cada uno.
  5. Enumera por escrito las suposiciones que tu falso encarna y marca cuáles verificarías con un test de contrato contra la implementación real.