31. Diseño de código testeable

31.1 Introducción

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.

31.2 Qué significa código testeable

Un código es testeable cuando podemos verificar su comportamiento con pruebas claras, rápidas y repetibles.

Características habituales:

  • Tiene responsabilidades claras.
  • Recibe datos explícitos.
  • Devuelve resultados observables o modifica estado de forma clara.
  • No depende innecesariamente de recursos externos.
  • Permite reemplazar dependencias cuando hace falta.
  • Evita efectos secundarios ocultos.
El código testeable no es código escrito solo para pruebas; es código con límites y responsabilidades más claros.

31.3 Responsabilidad única y clara

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.

31.4 Separar la lógica pura

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.

31.5 Preferir entradas explícitas

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.

31.6 Preferir resultados observables

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.

31.7 Evitar efectos secundarios ocultos

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:

  • La lógica que decide qué debe ocurrir.
  • El efecto externo que ejecuta esa decisión.

31.8 Separar decisión de efecto

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.

31.9 Controlar dependencias

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.

31.10 Evitar crear dependencias rígidas

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.

31.11 Funciones pequeñas y enfocadas

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:

  • Preparar menos datos.
  • Usar aserciones más directas.
  • Diagnosticar fallas más rápido.
  • Elegir casos de prueba con mayor precisión.

No se trata de dividir por dividir, sino de separar comportamientos distintos.

31.12 Evitar demasiados parámetros

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.

31.13 Evitar estado global mutable

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.

31.14 Hacer explícito el 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.

31.15 Diseño orientado a casos de prueba

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:

¿Puedo verificar esta regla con datos simples y una aserción clara?

Si la respuesta es no, quizá la regla está mezclada con demasiados detalles externos.

31.16 Señales de código difícil de probar

  • Requiere mucha preparación para probar una regla simple.
  • Depende directamente de base de datos, red o archivos.
  • Usa variables globales modificables.
  • Mezcla varias responsabilidades.
  • No devuelve resultados observables.
  • Crea internamente dependencias difíciles de reemplazar.
  • Sus pruebas necesitan muchos mocks para casos simples.

31.17 Mejoras simples

Algunas mejoras pequeñas pueden aumentar mucho la testeabilidad:

  • Extraer una función de cálculo.
  • Pasar una fecha como parámetro.
  • Separar lectura de archivo y procesamiento.
  • Recibir una dependencia en lugar de crearla internamente.
  • Devolver un resultado en lugar de modificar estado oculto.
  • Crear objetos nuevos por prueba.

Estas mejoras no reemplazan un refactoring profundo, pero ayudan a escribir pruebas más claras.

31.18 Tabla de problemas y alternativas

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.

31.19 Lista de comprobación

Para evaluar si una unidad es testeable, revisa:

  • ¿Tiene una responsabilidad clara?
  • ¿Recibe datos y dependencias de forma explícita?
  • ¿Su comportamiento puede observarse?
  • ¿Evita efectos externos innecesarios?
  • ¿Permite reemplazar dependencias cuando hace falta?
  • ¿Puede probarse con datos simples?
  • ¿Una falla sería fácil de diagnosticar?

31.20 Qué debes recordar de este tema

  • El diseño del código afecta directamente la facilidad para probarlo.
  • El código testeable tiene responsabilidades claras y comportamiento observable.
  • Conviene separar lógica de efectos externos.
  • Las dependencias explícitas son más fáciles de reemplazar en pruebas.
  • El estado global mutable dificulta pruebas independientes.
  • No se trata de escribir código artificial, sino de reducir acoplamiento.
  • Si una prueba requiere demasiada preparación, el diseño puede estar avisando algo.

31.21 Conclusión

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.