Para escribir buenas pruebas unitarias necesitamos aclarar qué significa unidad de código. La palabra unidad parece simple, pero en la práctica puede referirse a distintos elementos según el lenguaje, el diseño del programa y el objetivo de la prueba.
Una unidad puede ser una función pequeña, un método de una clase, una clase completa o un módulo con una responsabilidad clara. Lo importante es que podamos observar su comportamiento y verificarlo con datos controlados.
En este tema aprenderemos a reconocer unidades útiles para probar, a distinguir una unidad de una integración y a detectar señales de que una unidad está mezclando demasiadas responsabilidades.
En pruebas unitarias, podemos usar esta definición:
Esta definición evita limitar la unidad a un tamaño fijo. Una unidad no se define solamente por la cantidad de líneas, sino por su responsabilidad y por la posibilidad de probarla con claridad.
Una función de tres líneas puede ser una unidad. Una clase con varios métodos también puede serlo si representa un concepto coherente y sus operaciones forman parte de la misma responsabilidad.
Un error común es creer que una prueba unitaria siempre debe probar una sola línea de código o una única función mínima. Esa idea es demasiado rígida.
Una prueba unitaria debe ser pequeña y focalizada, pero puede ejercitar varias líneas internas si todas forman parte del mismo comportamiento. Cuando probamos una función, no probamos cada línea por separado: probamos qué hace la función ante un caso concreto.
def calcular_precio_final(precio, descuento, impuesto):
subtotal = precio - (precio * descuento / 100)
total = subtotal + (subtotal * impuesto / 100)
return total
def test_calcular_precio_final_con_descuento_e_impuesto():
assert calcular_precio_final(1000, 10, 21) == 1089
La prueba ejecuta varias líneas, pero verifica una unidad con una responsabilidad clara: calcular el precio final.
Las funciones suelen ser las unidades más fáciles de entender para comenzar. Reciben datos, realizan una operación y devuelven un resultado.
Ejemplo:
def es_correo_valido(correo):
return "@" in correo and "." in correo
def test_correo_con_arroba_y_punto_es_valido():
assert es_correo_valido("ana@example.com") == True
def test_correo_sin_arroba_no_es_valido():
assert es_correo_valido("ana.example.com") == False
La unidad es la función es_correo_valido. Las pruebas verifican dos comportamientos observables: aceptar un correo con estructura básica válida y rechazar uno sin arroba.
Las funciones puras, que dependen solo de sus entradas y no modifican estado externo, suelen ser ideales para practicar pruebas unitarias.
En programación orientada a objetos, muchas unidades aparecen como métodos. Un método puede devolver un valor, modificar el estado del objeto o validar una operación.
class Cuenta:
def __init__(self, saldo):
self.saldo = saldo
def depositar(self, importe):
self.saldo += importe
def test_depositar_incrementa_el_saldo():
cuenta = Cuenta(100)
cuenta.depositar(50)
assert cuenta.saldo == 150
En este caso, la unidad que nos interesa es el método depositar, aunque para probarlo necesitamos crear una instancia de Cuenta. La prueba verifica un cambio de estado observable: el saldo final.
A veces la unidad no es un método aislado, sino una clase completa que representa un concepto del dominio. Esto ocurre cuando el comportamiento importante surge de la combinación coherente de varios métodos.
class Carrito:
def __init__(self):
self.items = []
def agregar_item(self, precio):
self.items.append(precio)
def total(self):
return sum(self.items)
def test_carrito_calcula_total_de_sus_items():
carrito = Carrito()
carrito.agregar_item(100)
carrito.agregar_item(50)
assert carrito.total() == 150
La prueba usa dos métodos, pero el comportamiento probado pertenece a la unidad conceptual Carrito: acumular ítems y calcular el total. Sigue siendo una prueba unitaria si no estamos verificando la colaboración con una base de datos, una API u otro componente externo.
En algunos proyectos, un módulo agrupa funciones relacionadas. Si ese módulo tiene una responsabilidad clara, puede tratarse como una unidad desde el punto de vista de la prueba.
Por ejemplo, un módulo de conversión de moneda podría contener varias funciones auxiliares internas, pero exponer una función principal para convertir importes. La prueba puede enfocarse en esa operación pública.
def convertir_a_dolares(importe_en_pesos, cotizacion):
return importe_en_pesos / cotizacion
def test_convertir_a_dolares():
assert convertir_a_dolares(1000, 250) == 4
Si el módulo empieza a consultar servicios externos, leer archivos o combinar muchas reglas no relacionadas, probablemente ya no estamos ante una unidad simple.
Una unidad testeable suele tener una responsabilidad clara. Esto significa que podemos describir qué hace sin usar una lista larga de acciones mezcladas.
Ejemplos de responsabilidades claras:
Ejemplos de responsabilidades demasiado mezcladas:
Cuanto más clara sea la responsabilidad, más clara suele ser la prueba.
Para probar una unidad necesitamos alguna forma de interactuar con ella. En muchos casos, esto se hace mediante entradas y salidas.
| Elemento | Qué significa | Ejemplo |
|---|---|---|
| Entrada | Dato o condición que proporcionamos a la unidad. | Precio 1000 y descuento 10%. |
| Salida | Resultado observable producido por la unidad. | Total 900. |
| Estado inicial | Situación de un objeto antes de ejecutar la operación. | Cuenta con saldo 100. |
| Estado final | Situación esperada después de ejecutar la operación. | Cuenta con saldo 150 después de depositar 50. |
| Error esperado | Respuesta controlada ante una condición inválida. | Rechazar una extracción mayor al saldo. |
Si no podemos identificar qué entra, qué sale o qué cambio observable ocurre, será difícil escribir una prueba unitaria clara.
Una unidad se prueba mejor cuando podemos aislarla de recursos externos. Aislar no significa ignorar el resto del sistema para siempre, sino verificar primero el comportamiento propio de la unidad con la menor cantidad posible de dependencias.
Recursos que suelen complicar una prueba unitaria:
Cuando una prueba depende de esos elementos, puede volverse lenta, frágil o difícil de repetir. En esos casos conviene separar la lógica que queremos probar de las dependencias externas.
Veamos una función con muchas responsabilidades mezcladas:
def procesar_pedido(pedido):
validar_stock_en_base_de_datos(pedido)
total = calcular_total(pedido.items)
cobrar_tarjeta(pedido.tarjeta, total)
guardar_factura_en_base_de_datos(pedido, total)
enviar_correo_de_confirmacion(pedido.email)
return total
Esta función no es una unidad cómoda para una prueba unitaria. Combina validación, cálculo, pago, persistencia y correo. Si una prueba falla, la causa podría estar en muchas partes.
Una mejora sería separar la lógica de cálculo:
def calcular_total(items):
return sum(item.precio * item.cantidad for item in items)
Ahora calcular_total sí es una unidad clara. Podemos probarla con datos simples sin depender de pagos, correos ni base de datos.
En general, conviene probar el comportamiento público de una unidad. Esto significa usar las funciones, métodos o clases como las usaría el resto del código.
Probar directamente detalles privados puede volver las pruebas muy acopladas a la implementación. Si mañana reorganizamos el código internamente sin cambiar el comportamiento externo, no queremos que todas las pruebas fallen por cambios irrelevantes.
Esto no significa que nunca podamos probar una función auxiliar. Si esa función auxiliar contiene una regla importante y está diseñada como unidad independiente, puede tener sus propias pruebas. El punto es evitar pruebas que dependan de cada paso interno sin necesidad.
No existe una medida universal para decidir el tamaño exacto de una unidad. Sin embargo, hay señales prácticas:
Si necesitamos preparar una gran cantidad de objetos, configurar servicios externos y recorrer muchos pasos, probablemente estamos probando algo más grande que una unidad.
También existe el extremo opuesto: elegir unidades demasiado pequeñas y terminar probando detalles sin valor.
Por ejemplo, si una función solo devuelve una propiedad sin lógica, quizá no sea necesario escribir una prueba específica para ella. El esfuerzo de mantener esa prueba puede ser mayor que el beneficio.
class Usuario:
def __init__(self, nombre):
self.nombre = nombre
def obtener_nombre(self):
return self.nombre
En muchos casos, probar obtener_nombre de forma aislada no aporta demasiado. Conviene concentrar las pruebas unitarias en comportamientos con reglas, decisiones, transformaciones, cálculos o estados relevantes.
Una unidad demasiado grande suele tener muchas razones para fallar. Esto dificulta el diagnóstico y vuelve las pruebas más frágiles.
Señales de una unidad demasiado grande:
procesar, manejar o ejecutar_todo.Cuando una unidad tiene estas características, puede ser necesario dividir responsabilidades antes de escribir pruebas claras.
| Elemento | Puede ser unidad | Comentario |
|---|---|---|
| Función de cálculo | Sí | Suele ser una unidad muy clara. |
| Método de validación | Sí | Ideal si tiene reglas observables. |
| Clase con responsabilidad única | Sí | Puede probarse mediante sus métodos públicos. |
| Función que orquesta base de datos, pago y correo | Difícilmente | Probablemente corresponda a integración o requiera separar lógica. |
| Flujo completo de usuario | No | Eso pertenece a pruebas end-to-end. |
Antes de escribir una prueba, conviene hacer una pausa y elegir bien la unidad. Algunas preguntas útiles son:
Estas preguntas ayudan a evitar pruebas confusas. Una prueba unitaria empieza con una unidad bien elegida.
Supongamos que tenemos una regla: si una compra supera los 10000, se aplica un descuento del 15%; en caso contrario, no se aplica descuento.
La unidad adecuada podría ser una función de cálculo:
def calcular_descuento_por_monto(monto):
if monto > 10000:
return monto * 0.15
return 0
def test_compra_mayor_a_10000_recibe_descuento():
assert calcular_descuento_por_monto(12000) == 1800
def test_compra_de_10000_no_recibe_descuento():
assert calcular_descuento_por_monto(10000) == 0
No necesitamos probar esta regla desde una pantalla de checkout ni desde una base de datos. Podemos verificarla directamente en la unidad donde vive la lógica.
Entender qué es una unidad de código es clave para escribir buenas pruebas unitarias. La unidad debe tener una responsabilidad clara, poder ejecutarse con datos controlados y ofrecer algún comportamiento observable que podamos verificar.
Elegir bien la unidad evita pruebas lentas, frágiles o difíciles de interpretar. También nos ayuda a detectar cuándo el código necesita separar responsabilidades para ser más claro y testeable.
En el próximo tema compararemos las pruebas unitarias con las pruebas de integración y end-to-end para entender mejor qué debe cubrir cada nivel.