El diseño del código influye directamente en la facilidad para escribir pruebas unitarias. Algunas unidades se prueban con pocos datos y aserciones claras; otras requieren mucha preparación, dependencias difíciles y verificaciones frágiles.
Cuando el código es testeable, sus responsabilidades son claras, sus dependencias se pueden controlar y su comportamiento se puede observar.
En este tema veremos principios prácticos para escribir código más fácil de probar, sin convertir esto en un curso completo de diseño o refactoring.
Un código es testeable cuando podemos verificar su comportamiento con pruebas claras, rápidas y repetibles.
Características habituales:
Una unidad con una responsabilidad clara es más fácil de probar. Si una función calcula, valida, guarda en base de datos y envía correos, será difícil aislarla.
Ejemplo problemático:
def procesar_pedido(pedido):
validar_stock_en_base_de_datos(pedido)
total = calcular_total(pedido["items"])
cobrar_tarjeta(pedido["tarjeta"], total)
guardar_pedido_en_base_de_datos(pedido)
enviar_correo_confirmacion(pedido["email"])
return total
Esta función mezcla muchas responsabilidades. Probar solo el cálculo requiere atravesar stock, pago, base de datos y correo.
Podemos extraer la lógica de cálculo a una unidad simple.
def calcular_total(items):
return sum(item["precio"] * item["cantidad"] for item in items)
def test_calcular_total():
items = [
{"precio": 100, "cantidad": 2},
{"precio": 50, "cantidad": 1}
]
assert calcular_total(items) == 250
Ahora el cálculo puede probarse sin base de datos, pago ni correo. La función tiene una responsabilidad concreta.
Una unidad es más testeable cuando recibe los datos que necesita en lugar de buscarlos en variables globales o recursos externos.
Menos testeable:
configuracion = {"impuesto": 21}
def calcular_total(precio):
return precio + (precio * configuracion["impuesto"] / 100)
Más testeable:
def calcular_total(precio, impuesto):
return precio + (precio * impuesto / 100)
La segunda versión permite probar distintos impuestos sin modificar estado global.
Una prueba necesita observar algo: un valor de retorno, un cambio de estado o una interacción relevante. Si una unidad oculta todos sus efectos, probarla será difícil.
def normalizar_nombre(nombre):
return nombre.strip().title()
def test_normalizar_nombre():
assert normalizar_nombre(" ana ") == "Ana"
La función devuelve el resultado, lo que facilita verificarla. Si solo modificara una variable global oculta, la prueba sería menos clara.
Un efecto secundario ocurre cuando una unidad cambia algo fuera de su resultado directo: archivo, base de datos, variable global, red, reloj, etc.
Los efectos secundarios no son siempre malos, pero si están mezclados con lógica de negocio dificultan las pruebas.
Conviene separar:
En lugar de enviar un correo dentro de una regla, podemos separar la decisión.
def debe_enviar_confirmacion(estado_pedido):
return estado_pedido == "aprobado"
def test_pedido_aprobado_debe_enviar_confirmacion():
assert debe_enviar_confirmacion("aprobado") == True
La prueba verifica la regla sin enviar correos reales. El envío real se prueba en otro nivel o con un doble de prueba.
El código testeable permite controlar sus dependencias. Esto puede lograrse pasando dependencias por parámetro o constructor.
def convertir_a_dolares(monto, servicio_cotizacion):
cotizacion = servicio_cotizacion.obtener_cotizacion()
return monto / cotizacion
La prueba puede pasar un stub con cotización fija. La función no queda atada a un servicio real.
Crear dependencias reales dentro de la unidad dificulta reemplazarlas.
def convertir_a_dolares(monto):
servicio = ServicioCotizacionReal()
cotizacion = servicio.obtener_cotizacion()
return monto / cotizacion
Esta versión obliga a usar el servicio real. La prueba unitaria queda atada a una dependencia externa.
Las funciones pequeñas no son automáticamente buenas, pero suelen ser más fáciles de entender y probar si tienen una responsabilidad clara.
Una función enfocada permite:
No se trata de dividir por dividir, sino de separar comportamientos distintos.
Una función con muchos parámetros puede ser difícil de probar y entender. A veces indica que la unidad está haciendo demasiado.
def calcular_precio(cliente, productos, cupon, fecha, impuesto, envio, moneda):
...
Puede que algunos conceptos merezcan objetos propios o funciones separadas. Por ejemplo, calcular subtotal, aplicar cupón y calcular envío podrían ser comportamientos distintos.
El estado global mutable dificulta pruebas independientes y repetibles.
contador = 0
def generar_id():
global contador
contador += 1
return contador
El resultado depende de cuántas veces se llamó antes. Para pruebas unitarias, conviene controlar o encapsular ese estado.
Una alternativa es encapsular el estado en un objeto nuevo por prueba.
class GeneradorId:
def __init__(self):
self.contador = 0
def generar(self):
self.contador += 1
return self.contador
def test_generar_id_inicia_en_uno():
generador = GeneradorId()
assert generador.generar() == 1
La prueba controla el estado inicial creando un generador nuevo.
No debemos escribir código artificial solo para satisfacer pruebas. Pero si una unidad es imposible de probar sin levantar todo el sistema, probablemente tiene un diseño demasiado acoplado.
Una pregunta útil es:
Si la respuesta es no, quizá la regla está mezclada con demasiados detalles externos.
Algunas mejoras pequeñas pueden aumentar mucho la testeabilidad:
Estas mejoras no reemplazan un refactoring profundo, pero ayudan a escribir pruebas más claras.
| Problema | Dificultad para probar | Alternativa |
|---|---|---|
| Lógica mezclada con base de datos. | Requiere entorno externo. | Separar lógica y persistencia. |
| Fecha actual directa. | Prueba cambia con el tiempo. | Pasar fecha como dato. |
| Servicio creado internamente. | No puede reemplazarse en prueba. | Inyectar dependencia. |
| Variable global mutable. | Dependencia de orden. | Encapsular estado o pasarlo explícitamente. |
| Función con muchas responsabilidades. | Preparación y diagnóstico difíciles. | Separar comportamientos. |
Para evaluar si una unidad es testeable, revisa:
Diseñar código testeable significa escribir unidades con límites claros, dependencias controlables y comportamiento observable. Esto no solo facilita las pruebas; también suele producir código más comprensible y mantenible.
Las pruebas unitarias son una herramienta de verificación, pero también revelan problemas de diseño. Si probar una unidad es demasiado difícil, conviene mirar su responsabilidad y sus dependencias.
En el próximo tema veremos qué probar y qué no probar en una unidad, para enfocar mejor el esfuerzo.