Tienes un 90% de cobertura de código y aun así te llamaron a las 2 a.m.
Las unit tests pasaron. CI estaba verde. El bug llegó a producción de todos modos. La cobertura no mintió, pero tampoco dijo la verdad. Midió qué líneas se ejecutaron, no qué comportamientos se verificaron realmente.
La mayoría de los equipos lo descubren a la mala. Escriben cientos de unit tests, ven que la insignia de cobertura se pone verde y asumen que la fortaleza es segura. La fortaleza tiene paredes. Simplemente no tiene techo.
Las unit tests solo prueban lo que imaginas que puede salir mal
Las unit tests validan tus suposiciones sobre tu código. El problema es que los bugs no les importan tus suposiciones.
Considera una función de precios simple:
def calculate_total(items, tax_rate):
subtotal = sum(item["price"] * item["quantity"] for item in items)
tax = subtotal * tax_rate
return round(subtotal + tax, 2)
Un conjunto típico de unit tests parece sólido:
def test_calculate_total_with_tax():
items = [{"price": 10.00, "quantity": 2}]
assert calculate_total(items, 0.08) == 21.60
def test_calculate_total_empty_cart():
assert calculate_total([], 0.08) == 0.00
Ambas pasan. La cobertura es del 100%. La función se envía.
Luego un cliente en Japón hace checkout con tres artículos a ¥100, ¥100 y ¥100. La tasa de impuestos es 0.10. El total esperado es ¥330. La función devuelve ¥330.00. Bien.
Un cliente en Suiza compra un artículo a CHF 12.35 con un IVA del 7.7%. Esperado: CHF 13.30. Real: CHF 13.30. Todavía bien.
Luego un cliente compra dos artículos a $0.01 cada uno en Oregón, donde la tasa de impuestos es 0.0. Esperado: $0.02. Real: $0.02. Pasa.
El bug aparece cuando un cliente en una jurisdicción con una tasa de impuestos de None (porque el servicio de impuestos devolvió un null para un código postal no reconocido) intenta hacer checkout. La función multiplica subtotal * None y lanza un TypeError. Tus unit tests nunca pasaron None como tasa de impuestos porque asumiste que siempre sería un float.
Esta es la limitación fundamental. Las unit tests ejercitan los caminos que pensaste en probar. Los bugs viven en los caminos que no probaste.
Los cuatro lugares a los que las unit tests no pueden llegar
Límites de integración
Las unit tests reemplazan las dependencias externas con mocks. Los mocks son educados. Hacen exactamente lo que les dices. Las APIs reales no son educadas.
Tu base de datos mock devuelve filas en milisegundos. Producción las devuelve en segundos, o hace timeout, o devuelve filas duplicadas por un read replica lag que no sabías que existía.
Tu HTTP client mock devuelve JSON limpio. El servicio real devuelve un 200 con un body vacío los martes.
Los mocks prueban tu código contra tus suposiciones sobre otros sistemas. Producción prueba tu código contra la realidad. Son diferentes suites de pruebas con diferentes tasas de éxito.
Bugs de estado y temporales
Las unit tests se ejecutan en aislamiento. Cada prueba obtiene un fresh state. Producción es un long-running process donde el estado se acumula, filtra e interactúa consigo mismo.
Un memory cache que evicta entries bajo carga. Un connection pool que se agota después de 10,000 requests. Una comparación de timestamp que falla cuando la prueba se ejecuta cruzando un límite de daylight saving. Estos bugs requieren tiempo, volumen o secuencia para manifestarse. Las unit tests no tienen ninguno de estos.
Concurrencia y race conditions
Dos usuarios actualizan el mismo registry simultáneamente. Una request lee un balance, otra lo debita, la primera escribe de vuelta el valor stale. El dinero desaparece. Tus unit tests se ejecutan secuencialmente en un solo thread. No pueden detectar esto.
Puedes escribir unit tests para locking primitives individuales. No puedes escribir una unit test que demuestre que todo tu sistema está race-free. El state space es demasiado grande y el timing demasiado non-deterministic.
El entorno mismo
Tus pruebas se ejecutan en Ubuntu 22.04 con Python 3.11, 4GB de RAM y sin reglas de firewall. Producción se ejecuta en Alpine Linux con Python 3.11, 512MB de RAM y un security group que droppea conexiones TCP inactivas después de 60 segundos.
El module socket se comporta de forma diferente. Los límites de mmap son más bajos. Los locale settings hacen que strftime formatee fechas de maneras que tu parser no espera. Estos no son bugs de código. Son bugs de contexto. Las unit tests no tienen contexto.
Por qué el porcentaje de cobertura induce a error
Las herramientas de cobertura miden la ejecución de líneas, no la calidad de las assertions. Una prueba puede ejecutar cada línea de una función y no verificar nada significativo.
def test_poor_coverage_quality():
result = calculate_total([{"price": 1.0, "quantity": 1}], 0.0)
# Executed 100% of lines. Verified almost nothing.
assert result is not None
Esta prueba te da un 100% de line coverage y cero confianza. Muchos equipos optimizan la metric porque es fácil de medir. La confianza es difícil de medir. Así que miden cobertura en su lugar y esperan que las dos correlacionen.
No lo hacen.
Qué probar en su lugar (o además)
Este no es un argumento en contra de las unit tests. Las unit tests son rápidas, deterministas y excelentes para verificar lógica algorítmica. Simplemente están incompletas.
Esto es lo que llena los vacíos sin convertir tu pipeline de CI en un pasivo de 45 minutos.
Prueba en los límites del sistema, no solo en los internals
En lugar de mockear la base de datos, escribe pruebas que golpeen una base de datos de prueba real. Estas son más lentas, así que ejecútalas selectivamente. Pero detectan el mismatch entre tus queries de ORM y el comportamiento real del query planner.
En lugar de mockear el HTTP client, levanta el servicio downstream en un container. Esto detecta schema drift, timeout behavior y retry logic que solo se activa ante fallas reales de conexión.
Agrega contract tests para servicios externos
Si no puedes ejecutar la dependencia real en CI, usa contract tests. Estos verifican que tus expectativas como consumer coincidan con el API schema real del provider.
Herramientas como Pact registran las interacciones entre tu servicio y sus dependencias. Si el provider cambia un field type o elimina un endpoint, el contract test falla antes de que el código se despliegue. No es tan bueno como integration testing, pero es mucho mejor que esperar que tus mocks sean precisos.
Usa property-based testing para edge cases
Herramientas de property-based testing como Hypothesis (Python) o fast-check (JavaScript) generan miles de inputs aleatorios y verifican que tus invariants se mantengan.
from hypothesis import given, strategies as st
@given(
st.lists(st.fixed_dictionaries({
"price": st.decimals(min_value=0, max_value=10000, places=2),
"quantity": st.integers(min_value=0, max_value=1000)
})),
st.one_of(st.none(), st.decimals(min_value=0, max_value=1, places=4))
)
def test_calculate_total_invariants(items, tax_rate):
if tax_rate is None:
with pytest.raises(TypeError):
calculate_total(items, tax_rate)
return
result = calculate_total(items, tax_rate)
assert result >= 0
assert result == round(result, 2)
Esta prueba habría detectado el bug de la tasa de impuestos None sin que tuvieras que pensar en escribir ese caso específico. Genera inputs que nunca considerarías: empty lists, giant lists, zero prices, maximum precision decimals. Encuentra los edges de tu lógica sin requerir que los imagines primero.
Monitorea producción como si fuera un entorno de pruebas
La suite de pruebas más honesta es el tráfico de producción. Si no puedes detectar un bug antes de que se envíe, detéctalo antes de que cause daño.
Usa feature flags para desplegar cambios al 1% de los usuarios primero. Observa error rates, latency percentiles y metrics de negocio. Una unit test te dice si el código se comporta como se espera en aislamiento. Un monitor de producción te dice si el código se comporta como se espera en la realidad.
Configura alerts sobre anomalías, no solo sobre hard failures. Un aumento del 5% en errores 500 después de un deploy es a menudo la única señal de que una race condition o un resource leak ha comenzado. Las unit tests nunca te mostrarán esto.
El trade-off honesto
Las unit tests son baratas, rápidas y buenas para los developer feedback loops. Las integration tests son caras, lentas y buenas para detectar los bugs que importan.
Necesitas ambas. La trampa es pensar que un 100% de cobertura de unit tests significa que puedes omitir el resto. Significa que has probado a fondo las partes fáciles. Las partes difíciles, las que te despiertan por la noche, viven donde tus pruebas no están mirando.
Empieza con unit tests para lógica y algoritmos. Agrega integration tests en cada límite del sistema. Usa property-based testing para encontrar los inputs que no se te ocurrieron. Monitorea producción para detectar lo que toda prueba se perdió.
La cobertura es una vanity metric. La única metric que importa es si duermes toda la noche.
Si estás intentando atrapar los bugs que tus pruebas se pierden, empieza mirando tus datos de error. Sentry te muestra qué se rompe en producción, con los stack traces y el contexto que tus unit tests nunca tuvieron.