En el tema anterior vimos que una prueba unitaria verifica una unidad pequeña de código. Ahora necesitamos responder una pregunta más práctica: ¿para qué escribimos una prueba unitaria?
La respuesta no es simplemente "para tener pruebas" ni "para cumplir con una métrica". Una prueba unitaria debe aportar información útil. Debe ayudarnos a saber si una parte del programa cumple una regla, si una modificación rompió algo importante o si el comportamiento esperado está expresado de forma clara.
Comprender los objetivos de una prueba unitaria es fundamental porque evita escribir pruebas mecánicas, frágiles o sin valor. Una buena prueba tiene una intención definida.
El objetivo principal de una prueba unitaria es verificar que una unidad de código se comporta como esperamos en una situación concreta.
Por ejemplo:
Estas preguntas son concretas. Una prueba unitaria valiosa no intenta demostrar que "todo funciona"; se concentra en un comportamiento específico.
Un objetivo importante es comprobar el comportamiento observable de la unidad. Esto significa verificar lo que la unidad hace desde el punto de vista de quien la usa: qué devuelve, qué estado modifica o qué error produce.
No conviene probar detalles internos que podrían cambiar sin alterar el comportamiento real. Si una prueba depende demasiado de cómo está escrita la función por dentro, se vuelve frágil y puede fallar aunque el sistema siga funcionando correctamente.
def calcular_total(precio, impuesto):
return precio + (precio * impuesto / 100)
def test_calcular_total_con_impuesto_del_21_por_ciento():
assert calcular_total(1000, 21) == 1210
Esta prueba verifica el resultado esperado. No le importa si internamente la función usa una variable intermedia, una fórmula directa o una pequeña función auxiliar. Mientras el comportamiento sea correcto, la prueba debe pasar.
Uno de los grandes objetivos de las pruebas unitarias es encontrar errores cerca del momento en que se introducen. Cuando una prueba falla inmediatamente después de cambiar una función, el problema suele estar en ese cambio reciente.
Esto reduce el tiempo de búsqueda. Es distinto descubrir un error en una prueba unitaria pequeña que descubrirlo semanas después en una pantalla completa, con base de datos, sesión de usuario, red, permisos y varias reglas combinadas.
Las pruebas unitarias acortan la distancia entre causa y síntoma. Esa cercanía facilita el diagnóstico.
Una regresión ocurre cuando algo que funcionaba correctamente deja de funcionar después de un cambio. Las regresiones son muy comunes en software: corregimos una regla, agregamos una condición, refactorizamos un módulo o cambiamos una dependencia, y sin querer rompemos un comportamiento existente.
Una prueba unitaria ayuda a proteger comportamientos importantes para que no se pierdan accidentalmente.
def aplicar_descuento(precio, porcentaje):
return precio - (precio * porcentaje / 100)
def test_aplicar_descuento_no_modifica_precio_con_descuento_cero():
assert aplicar_descuento(500, 0) == 500
def test_aplicar_descuento_del_20_por_ciento():
assert aplicar_descuento(500, 20) == 400
Si alguien modifica la función y rompe alguno de esos casos, las pruebas lo indican. La suite de pruebas actúa como memoria automática de comportamientos que el sistema debe conservar.
Una prueba unitaria bien escrita también cumple una función de documentación. No documenta con una explicación larga, sino con un ejemplo concreto que se puede ejecutar.
Consideremos una regla simple: un usuario puede acceder a una promoción si es cliente activo y tiene al menos 100 puntos.
def puede_acceder_a_promocion(cliente_activo, puntos):
return cliente_activo and puntos >= 100
def test_cliente_activo_con_100_puntos_puede_acceder_a_promocion():
assert puede_acceder_a_promocion(True, 100) == True
def test_cliente_inactivo_no_puede_acceder_a_promocion():
assert puede_acceder_a_promocion(False, 200) == False
Al leer estas pruebas, una persona entiende parte de la regla de negocio. Además, puede ejecutarlas para comprobar que la regla sigue vigente.
El software cambia constantemente. Se agregan funcionalidades, se corrigen defectos, se optimiza rendimiento y se refactoriza código. Sin pruebas, cada cambio importante genera incertidumbre: no sabemos qué se rompió hasta probar manualmente muchas cosas o hasta que alguien reporte un problema.
Las pruebas unitarias no eliminan todo riesgo, pero aumentan la confianza. Si después de modificar una unidad ejecutamos sus pruebas y todas pasan, tenemos evidencia de que los comportamientos cubiertos siguen funcionando.
Esta confianza es especialmente importante al refactorizar. Refactorizar significa mejorar la estructura interna del código sin cambiar su comportamiento observable. Las pruebas unitarias ayudan a comprobar que ese comportamiento se mantiene.
Otro objetivo de las pruebas unitarias es revelar cuándo una unidad es difícil de usar o de verificar. Si cuesta mucho escribir una prueba, puede ser una señal de que la unidad tiene demasiadas responsabilidades o depende de demasiados recursos externos.
Por ejemplo, una función que calcula un importe es fácil de probar si recibe números y devuelve un número. Pero si además lee una base de datos, consulta una API, revisa la fecha del sistema y escribe en un archivo, deja de ser una unidad simple.
En ese sentido, las pruebas unitarias funcionan como una presión positiva sobre el diseño: favorecen funciones, clases y módulos con responsabilidades claras.
La depuración manual es útil, pero puede volverse lenta si dependemos de ella para verificar cada cambio. Ejecutar la aplicación, navegar hasta una pantalla, cargar datos y observar el resultado puede tomar bastante tiempo.
Una prueba unitaria permite comprobar una regla directamente. Si queremos saber si una función calcula bien una comisión, no necesitamos iniciar toda la aplicación. Podemos ejecutar una prueba que llame a esa función con datos controlados.
Esto no significa que nunca debamos probar manualmente. Significa que muchas verificaciones pequeñas y repetibles pueden automatizarse a nivel unitario, dejando la exploración manual para otros objetivos.
Una unidad de código no debe probarse solo con el caso más cómodo. Otro objetivo de las pruebas unitarias es cubrir situaciones representativas:
Ejemplo con una validación de edad:
def puede_registrarse(edad):
return edad >= 18
def test_edad_17_no_puede_registrarse():
assert puede_registrarse(17) == False
def test_edad_18_puede_registrarse():
assert puede_registrarse(18) == True
def test_edad_19_puede_registrarse():
assert puede_registrarse(19) == True
Los casos 17, 18 y 19 son valiosos porque rodean el límite de la regla. En temas posteriores veremos técnicas más formales para elegir estos casos.
Cuando una prueba unitaria falla, debería señalar un problema bastante específico. Esa es una de sus ventajas frente a pruebas más grandes: el área de búsqueda es menor.
Si falla una prueba llamada test_edad_18_puede_registrarse, sabemos que debemos revisar la regla que determina la mayoría de edad o los datos usados por esa prueba. En cambio, si falla un flujo completo de registro, el error podría estar en la interfaz, el formulario, la validación, el servicio, la base de datos o la navegación.
Por eso conviene que cada prueba unitaria tenga una intención clara y un nombre descriptivo. Cuando falla, debe ayudarnos a entender qué comportamiento se rompió.
No todo lo que se puede medir o automatizar es un buen objetivo para una prueba unitaria. Algunos objetivos suelen producir pruebas de baja calidad:
La cobertura de código indica qué partes del código fueron ejecutadas por las pruebas. Puede ser una métrica útil, pero no debe confundirse con calidad de pruebas.
Una prueba puede ejecutar una línea de código y aun así no verificar correctamente el resultado. Por ejemplo:
def test_prueba_sin_verificacion_real():
calcular_total(1000, 21)
Esta prueba aumenta la ejecución de código, pero no comprueba si el total es correcto. Una versión con mejor objetivo sería:
def test_calcular_total_con_impuesto_del_21_por_ciento():
assert calcular_total(1000, 21) == 1210
La cobertura será estudiada con más detalle en otro curso. En este curso la usaremos como apoyo, no como sustituto del criterio para elegir buenos casos.
El objetivo de la prueba puede cambiar según la unidad que estamos verificando:
| Tipo de unidad | Objetivo habitual de la prueba | Ejemplo |
|---|---|---|
| Función de cálculo | Comprobar el valor devuelto. | Calcular un impuesto o descuento. |
| Validador | Aceptar datos válidos y rechazar datos inválidos. | Validar una edad, un correo o una contraseña. |
| Clase con estado | Verificar cambios de estado después de una operación. | Depositar dinero en una cuenta. |
| Regla de negocio | Comprobar decisiones según condiciones. | Aprobar o rechazar un pedido. |
| Transformador de datos | Verificar que la salida tenga la forma esperada. | Convertir un registro a un diccionario. |
Una prueba tiene un buen objetivo cuando podemos responder con claridad estas preguntas:
Si no podemos responder estas preguntas, probablemente la prueba necesita ser reformulada, dividida o eliminada.
Veamos una prueba poco recomendable:
def test_usuario():
usuario = crear_usuario("Ana", 20)
assert usuario.nombre == "Ana"
assert usuario.edad == 20
assert usuario.puede_registrarse() == True
assert usuario.obtener_categoria() == "adulto"
Esta prueba mezcla varias ideas: creación del usuario, almacenamiento de datos, regla de registro y categoría. Si falla, tendremos que mirar cuál de todas esas expectativas se rompió.
Una alternativa más clara es separar objetivos:
def test_crear_usuario_guarda_nombre_y_edad():
usuario = crear_usuario("Ana", 20)
assert usuario.nombre == "Ana"
assert usuario.edad == 20
def test_usuario_mayor_de_edad_puede_registrarse():
usuario = crear_usuario("Ana", 20)
assert usuario.puede_registrarse() == True
Ahora cada prueba comunica mejor su intención. Si una falla, el diagnóstico será más directo.
Las pruebas unitarias no solo sirven a la computadora. También sirven a las personas que leen y mantienen el código.
Una prueba con buen nombre y buen ejemplo comunica una regla de manera precisa. Puede ayudar a un nuevo integrante del equipo a entender cómo debe comportarse una función o qué casos son importantes para una regla de negocio.
Por eso conviene escribir pruebas pensando en quien las leerá después. Una prueba clara ahorra explicaciones y reduce malentendidos.
Escribir pruebas unitarias no consiste en acumular archivos de prueba ni en aumentar una métrica sin analizar su utilidad. Una prueba unitaria debe tener un objetivo claro: verificar una regla, proteger un comportamiento, documentar un ejemplo importante o dar confianza para modificar código.
Cuando una prueba tiene una intención concreta, se vuelve más fácil de leer, más fácil de mantener y más útil cuando falla.
En el próximo tema estudiaremos con mayor precisión qué entendemos por unidad de código, porque elegir bien la unidad es una condición importante para escribir buenas pruebas unitarias.