Fetching latest headlines…
Tenía 100% de cobertura y aun así se cayó producción
NORTH AMERICA
🇺🇸 United StatesAugust 4, 2026

Tenía 100% de cobertura y aun así se cayó producción

1 views0 likes0 comments
Originally published byDev.to

Hoy buena parte del código que entra a mis proyectos lo escribe un modelo. No es un experimento, es cómo trabajamos. Y eso me trajo una pregunta que antes casi no me hacía, porque el código lo escribía yo y más o menos sabía dónde estaban los huecos: si no escribí esto, ¿cómo sé que sirve?

La respuesta obvia es "mido cobertura". Yo la mido. Y una vez tuve todo en verde mientras la página se caía en producción.

Este post es sobre esa contradicción, y sobre las decisiones que hacen que el número signifique algo o no signifique nada.

Primero, qué mido

Nada exótico: flutter test --coverage genera el lcov.info y después filtro con lcov --remove antes de sacar el porcentaje.

lcov --remove coverage/lcov.info \
    '**/*.freezed.dart' \
    '**/*.g.dart' \
    '**/*.config.dart' \
    '**/constants/*.dart' \
    '**/theme/*.dart' \
    '**/di/*.dart' \
    '**/router/*.dart' \
    -o coverage/lcov_filtered.info

Lo que saco de la cuenta y por qué:

Los archivos generados (freezed, json_serializable, la config de inyección) no los escribí yo. Testearlos es testear al generador. Si freezed está roto, ese no es mi test.

Constantes y theme no tienen ramas. No hay una entrada que los haga comportarse distinto. Un test ahí solo confirma que una constante vale lo que vale.

DI y routing son cableado. Verificar que el contenedor resuelve una dependencia es testear el framework.

La regla, si tuviera que resumirla, es que excluyo lo que no puede tomar una decisión equivocada. Todo lo demás entra.

Y acá conviene decir algo incómodo antes de que lo diga otro: la lista de exclusiones es donde se puede hacer trampa. Puedo llegar al porcentaje que quiera moviendo esos patrones. Por eso no la toco cuando el número no me gusta. Si tengo que justificar por qué saqué algo, la respuesta no puede ser "porque no llegaba".

El test que ejecuta todo y no verifica nada

Este es el malentendido que hace que gente con experiencia desconfíe de la cobertura, y tienen razón en desconfiar.

lcov mide qué líneas se ejecutaron. No mide si comprobaste algo. Son cosas distintas y un modelo generando tests las confunde todo el tiempo.

test('aplica descuento', () {
  final result = calculator.applyDiscount(100, 0.2);
  expect(result, isA<double>());
});

Ese test ejecuta cada línea de applyDiscount. Cobertura: 100%. Verificaciones reales: cero. Si la fórmula estuviera invertida, precio * descuento en vez de precio * (1 - descuento), pasaría igual de verde.

test('aplica 20% de descuento', () {
  expect(calculator.applyDiscount(100, 0.2), 80.0);
});

test('rechaza descuentos mayores al 100%', () {
  expect(() => calculator.applyDiscount(100, 1.5), throwsArgumentError);
});

Misma cobertura de líneas. La diferencia no la mide ninguna herramienta. La mide una pregunta: ¿el test afirma algo, o solo ejecuta?

Los modelos escriben el primero por defecto. Es lo que mejor hacen: recorren el código, llegan al verde, suben el número. Cuando reviso un test generado no miro si el porcentaje subió, miro qué pasa si rompo la función a propósito. Si el test sigue pasando, el test no existe.

La capa de presentación no entra, y no es por pereza

Esta fue la decisión que más me costó y la que más defiendo.

Empecé escribiendo widget tests como todo el mundo. Dos cosas me hicieron parar.

La primera es tonta pero real: los expect no encontraban el widget. A veces sí, a veces no, según cómo hubiera quedado el árbol. Terminaba peleándome con los finders en vez de verificar comportamiento. Cualquiera que haya escrito widget tests en Flutter sabe de qué hablo.

La segunda es la que importa. En la app hay opciones que aparecen o no según el rol del usuario, y hay bastantes casos. Pero esa decisión no vive en el widget: vive en el BLoC. El widget solo pinta lo que el estado le dice. Entonces si testeo desde el widget que "el usuario X ve la opción Y", estoy verificando la misma regla que ya verifiqué en el BLoC, pero por una puerta más lenta, más frágil y que se rompe cuando alguien cambia un Padding.

No es que no testee la UI. Es que no testeo dos veces lo mismo, y elijo hacerlo donde el test es barato y estable.

(Lo que sí me falta, y lo digo porque un post honesto también dice dónde no llegó: golden tests para lo que sí es estable. Está en la lista. No lo hice todavía.)

Legacy: no persigo el pasado

En proyectos que arranco de cero apunto a cubrir toda la lógica. Pero también mantengo código heredado, sin tests, escrito por gente que ya no está.

Ahí no persigo el 100% hacia atrás. Sería meses escribiendo tests para código que no voy a tocar, y peor, tests que congelan la implementación actual en vez de proteger una conducta.

Lo que hago es más simple: el código viejo se queda como está, pero todo lo nuevo que le agrego entra con sus tests. La barra sube cada vez que toco ese módulo. No freno la entrega para cubrir el pasado, freno la sangría.

Es la diferencia entre "este proyecto tiene 100%" y "este proyecto no vuelve a perder cobertura". El segundo es alcanzable sin parar el mundo.

La caída

Y acá está la parte donde el número me mintió en la cara.

Mi suite corre sobre mocks. Toda la capa de datos está mockeada, así que los tests no hablan con un servidor: hablan con una versión congelada de lo que el servidor decía la última vez que escribí ese mock.

Tenemos un sistema de puntos por pedidos entregados. Un día, por un error del backend, un pedido entregado no cargó sus puntos y el campo vino null. Nunca antes había venido null. La app no lo aguantó, la web tampoco, y la página tiró error.

Todos mis tests en verde. Todos.

Y el motivo es casi humillante de tan simple: mi mock seguía devolviendo el mundo viejo. Yo tenía cobertura completa de una realidad que ya no existía. El test nunca vio un null porque yo nunca escribí un mock que lo devolviera, y no lo escribí porque en mi cabeza ese campo siempre venía.

Esa es la respuesta que le doy a cualquiera que dice que la cobertura no significa nada. Tenés razón, y acá está la prueba desde mi propia suite: el 100% significa que ejecutaste tus líneas contra los supuestos que vos mismo escribiste. Si tus supuestos están viejos, medís qué tan bien se testea tu imaginación.

La lección no fue "los mocks son malos". Los mocks los necesito. Fue que cobertura de líneas y cobertura de casos no son lo mismo, y que el caso nulo es el que más veces se me escapó. Desde entonces los nulos y los cambios de contrato son ciudadanos de primera clase en mis tests, no un pensamiento tardío.

test('maneja puntos nulos del backend', () {
  expect(() => calculator.applyDiscount(null, 0.2), throwsArgumentError);
});

Sigo sin tener una respuesta completa a esto, para ser honesto. Un test de contrato contra la respuesta real sería lo correcto, pero hoy no lo tengo armado. Por ahora lo que hago es asumir que cualquier campo puede venir nulo, aunque el backend jure que no.

Lo que en realidad estoy diciendo

La cobertura te dice dónde no miraste. No te dice que lo que miraste esté bien.

Un modelo te regala líneas ejecutadas gratis, todo el día. Lo que no te regala es decidir qué merece un test, cuál afirma algo de verdad, qué no vale la pena cubrir, y cuándo el número te está mintiendo.

Eso sigue siendo trabajo tuyo. Por ahora.

¿Vos qué excluís de tu cobertura, y por qué? Me interesa sobre todo si alguien resolvió bien el tema de los contratos de API contra mocks, porque yo todavía no.

Comments (0)

Sign in to join the discussion

Be the first to comment!