Una función puede tener el 100% de cobertura de pruebas unitarias y la página que la llama puede seguir devolviendo un 500 en producción. No es una contradicción. Las pruebas unitarias, de integración y end-to-end comprueban cosas distintas, y una suite cargada en una y floja en otra tiene un hueco específico y predecible.
Qué comprueban las pruebas unitarias, de integración y end-to-end
Toma una funcionalidad de registro: una función valida la entrada, escribe una fila en una base de datos y envía un correo de bienvenida. Tres pruebas pueden cubrirla, y cada una responde una pregunta distinta.
| Nivel | Pregunta que responde | Qué es real, qué se sustituye |
|---|---|---|
| Unitaria | ¿Esta función hace lo que debe? | Todo lo que rodea a la función se sustituye: sin base de datos real, sin red real |
| Integración | ¿Esta función y una dependencia real funcionan juntas? | La base de datos es real; las partes que siguen fuera del límite que pruebas (el servidor de correo) se sustituyen |
| End-to-end | ¿Funciona toda la petición, vista desde fuera? | Nada se sustituye. Una petición HTTP real llega a un proceso real en ejecución |
Una prueba unitaria te dice que la validación del formato del email rechaza lo que debe rechazar. Una prueba de integración te dice que la fila realmente llega a la base de datos con las columnas que crees que tiene. Una prueba end-to-end te dice que un cliente puede llamar al endpoint y recibir un 201. Ninguna puede sustituir a las otras.
Probar una funcionalidad de Flask en los tres niveles
Aquí está la funcionalidad: una función sign_up que valida un email, crea un usuario y envía un mensaje de bienvenida.
| |
sign_up recibe db y smtp_client como argumentos en vez de usar una conexión global. Esa decisión hace posibles las tres pruebas siguientes: en cada una le pasas algo distinto a esos dos parámetros. get_db() y get_smtp_client() leen su destino de una variable de entorno en cada llamada en lugar de fijarlo una vez, y eso es lo que permite que la prueba end-to-end de más abajo apunte la app en ejecución a una base de datos desechable y a un servidor de correo de prueba local sin tocar app.py.
Prueba unitaria: sustituye las dos dependencias
Una prueba unitaria aísla sign_up por completo. Ni la base de datos ni el cliente SMTP son reales: ambos son objetos Mock de la librería estándar de Python.
| |
Aquí entran en juego dos tipos de doble de prueba, y los nombres importan porque marcan expectativas distintas. db en la primera prueba es casi un dummy: se pasa porque la firma exige algo, y a la prueba solo le importa que nunca se toque. smtp_client en la segunda prueba es un mock en sentido estricto. La prueba define la expectativa (sendmail se llama con esta dirección) y falla si la realidad no coincide. Eso es verificación de comportamiento: compruebas que una llamada ocurrió, no qué devolvió.
Estas pruebas corren en milisegundos y nunca tocan disco ni un socket. Tampoco pueden decirte si INSERT INTO users coincide con las columnas reales de la tabla. db nunca ejecuta nada real, así que una errata en el SQL pasaría todas las pruebas unitarias que escribas.
Prueba de integración: una base de datos real, un servidor de correo falso
Una prueba de integración cierra ese hueco una dependencia a la vez. Aquí la base de datos es real, una conexión SQLite en memoria con el esquema real, mientras que el servidor de correo sigue sustituido. Esta vez es un fake: una implementación pequeña que funciona, en lugar de un doble con guion.
| |
FakeSMTP no tiene expectativas escritas de antemano como hacía smtp_client.sendmail.assert_called_once(). Mantiene una lista real, aunque diminuta, de lo que se envió, y la prueba la lee después. Esa es la diferencia entre un mock y un fake: a un mock le dices qué esperar antes de que corra el código; un fake se comporta como una versión más pequeña de la cosa real y lo inspeccionas después.
Esta prueba detecta un nombre de columna equivocado o una tabla que falta. No detecta un bug en el parseo de peticiones de Flask ni una URL equivocada en el decorador de la ruta, porque aquí nada pasa por HTTP.
Prueba end-to-end: una petición real contra un proceso en ejecución
Una prueba end-to-end elimina ambas sustituciones. Arranca la app de Flask real como un proceso aparte, le manda una petición HTTP real por un socket real, y comprueba el resultado como lo haría un cliente externo: volviendo a llamar a la API, no husmeando dentro de la base de datos.
| |
aiosmtpd sustituye al recolector de correo que ejecutarías en un entorno de staging: algo que realmente habla SMTP por un socket, para que la llamada real smtplib.SMTP(...) de la app tenga dónde conectarse. La prueba también se crea un archivo de base de datos desechable en lugar de reutilizar app.db: correr esta prueba dos veces contra un archivo compartido fallaría la segunda vez por la misma PRIMARY KEY que ya ocupa [email protected], justo el tipo de inestabilidad por estado residual del que habla el artículo más adelante. Nada en esta prueba sabe que existen sign_up o create_user. Solo conoce el contrato HTTP. Cambia el nombre de una ruta o la forma del JSON y esta es la prueba que falla, mientras las pruebas unitarias y de integración de arriba siguen pasando.
Instala las dependencias con pip install flask requests aiosmtpd pytest y corre las tres con pytest. Cuando algo se rompe, qué archivo falla te dice qué clase de bug tienes. Esa es la razón para escribir más de un nivel; el porcentaje de cobertura no lo es.
Pirámide de pruebas vs trofeo de pruebas
La forma clásica de una suite de pruebas es la pirámide de pruebas de Mike Cohn: una base ancha de pruebas unitarias rápidas, una capa más pequeña de pruebas de integración y una punta fina de pruebas end-to-end lentas y caras. Lo importante es la proporción, no el número. La mayoría de tus pruebas debería ser del tipo barato.
El trofeo de pruebas de Kent C. Dodds no está de acuerdo en dónde poner el peso. Su argumento: las pruebas unitarias aisladas dan una confianza fácil de conseguir, pero que no refleja cómo se usa el software de verdad, así que una suite hecha sobre todo de pruebas unitarias puede dejar pasar bugs reales en las costuras. El trofeo pone el análisis estático (linting, comprobación de tipos) en la base, una capa unitaria encima, las pruebas de integración como la parte más ancha, y una capa end-to-end fina arriba del todo. test_sign_up_persists_user_and_records_email de arriba es el tipo de prueba que el trofeo quiere en mayor cantidad.
| Pirámide (Cohn) | Trofeo (Dodds) | |
|---|---|---|
| Capa más grande | Unitaria | Integración |
| Capa base | Pruebas unitarias | Análisis estático (lint, tipos) |
| Pregunta que guía | ¿Cuánto cuesta escribir y correr esta prueba? | ¿Cuánto se parece esta prueba al uso real? |
| Punto débil que ataca | Suites end-to-end lentas y frágiles | Pruebas unitarias que pasan mientras las costuras están rotas |
No sigas ninguna de las dos formas al pie de la letra. Coinciden en el orden: las pruebas end-to-end son las más caras y deberían ser las menos, las unitarias son las más baratas. Dónde va la mayor parte de tu esfuerzo depende del historial de bugs de tu proyecto. Si la mayoría de los bugs son componentes portándose mal por su cuenta, inclínate hacia la pirámide. Si la mayoría son componentes que no se ponen de acuerdo sobre cómo hablarse, inclínate hacia el trofeo.
Por qué las pruebas end-to-end se vuelven inestables
Una suite con una pila creciente de pruebas end-to-end acaba ganándose una reputación: relanzas la que falla y pasa. Eso es inestabilidad, y viene de la misma propiedad que hace valiosas a las pruebas end-to-end. Tocan infraestructura real.
Las causas habituales son un sleep() fijo que es suficiente en tu portátil y demasiado corto en un runner de CI con carga, datos de prueba que deja una ejecución anterior y chocan con la siguiente, una llamada de red a algo fuera de tu control, y estado compartido entre pruebas que solo aparece cuando corren en otro orden o en paralelo. El time.sleep(0.5) de la prueba de arriba es del primer tipo; el archivo temporal que esa misma prueba crea para DB_PATH es una corrección directa para el segundo. Ninguno de estos es un bug en la funcionalidad. Son bugs en cómo la prueba trata la infraestructura real, un problema que las pruebas unitarias nunca tienen.
Relanzar hasta que se ponga en verde no es la solución; esconde el siguiente fallo real entre el ruido. Mantén la capa end-to-end delgada cubriendo las rutas críticas, no cada rama. Dale a cada prueba sus propios datos en vez de fixtures compartidos. Sustituye las esperas fijas por un sondeo de la condición que realmente necesitas, como que el puerto acepte conexiones. El tiempo de CI es el otro costo: si ya aceleraste el pipeline con caché de dependencias en GitHub Actions, una capa end-to-end que necesita tres relanzamientos para ponerse en verde devuelve buena parte de ese tiempo.
Qué prueba escribir a continuación
Si un bug acaba de escaparse a producción, pregúntate qué nivel lo habría detectado antes de añadir ninguna prueba. Un cálculo mal hecho o un caso límite sin gestionar en una función pide una prueba unitaria. Dos piezas de tu propio código que no están de acuerdo sobre una forma o un contrato, como la API devolviendo un campo que el frontend no espera o una consulta que asume una columna que se renombró, pide una prueba de integración. Una funcionalidad que pasa todas las pruebas pero falla para un usuario real suele estar falta de cobertura end-to-end de la ruta que ese usuario siguió.
Escribe primero la prueba unitaria. Es la señal más barata y la más rápida para decirte cuándo has roto la lógica. Añade una prueba de integración cuando dos piezas tuyas necesitan demostrar que están de acuerdo. Reserva las pruebas end-to-end para el puñado de flujos donde “funciona” solo significa algo visto desde fuera: registro, login, checkout, restablecer la contraseña.
Mantén esa separación y consigues una suite cuyos fallos significan algo. Eso importa más allá de la CI. Git bisect con git bisect run necesita un comando de prueba que falle solo cuando el código está realmente roto, y una prueba end-to-end inestable lo mandará al commit equivocado.