3. Qué entendemos por unidad de código

3.1 Introducción

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.

3.2 Una definición práctica

En pruebas unitarias, podemos usar esta definición:

Una unidad de código es una parte del programa con una responsabilidad identificable, que puede ejecutarse y verificarse mediante entradas, salidas, cambios de estado o errores esperados.

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.

3.3 Unidad no significa siempre una sola línea o una sola función

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.

3.4 Funciones como unidades

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.

3.5 Métodos como unidades

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.

3.6 Clases como unidades

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.

3.7 Módulos como unidades

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.

3.8 Responsabilidad clara

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:

  • Calcular el total de una compra.
  • Validar si una edad permite registrarse.
  • Convertir un texto a un formato normalizado.
  • Determinar si un pedido puede aprobarse.
  • Agregar un ítem a un carrito y actualizar su estado.

Ejemplos de responsabilidades demasiado mezcladas:

  • Leer un archivo, validar datos, guardar en base de datos y enviar un correo.
  • Procesar un pago, actualizar stock, generar factura y notificar al usuario en una sola función.
  • Construir una pantalla, consultar una API y calcular reglas de negocio en el mismo método.

Cuanto más clara sea la responsabilidad, más clara suele ser la prueba.

3.9 Entradas y salidas

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.

3.10 Unidad y aislamiento

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:

  • Bases de datos reales.
  • APIs externas.
  • Archivos del sistema.
  • Reloj del sistema o fechas actuales.
  • Variables globales modificables.
  • Servicios de correo, pagos o notificaciones.

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.

3.11 Ejemplo de unidad difícil de probar

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.

3.12 Unidad pública y detalles privados

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.

3.13 Tamaño adecuado de una unidad

No existe una medida universal para decidir el tamaño exacto de una unidad. Sin embargo, hay señales prácticas:

  • Podemos explicar su responsabilidad en una frase breve.
  • Podemos preparar los datos de prueba sin demasiado esfuerzo.
  • Podemos ejecutar la unidad sin iniciar toda la aplicación.
  • Podemos verificar el resultado con una o pocas aserciones relacionadas.
  • Si falla la prueba, sabemos dónde empezar a buscar.

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.

3.14 Unidad demasiado pequeña

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.

3.15 Unidad demasiado grande

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:

  • Su nombre es genérico, como procesar, manejar o ejecutar_todo.
  • Modifica muchos objetos distintos.
  • Depende de base de datos, red, archivos y reloj al mismo tiempo.
  • Necesita una preparación extensa para probar un caso simple.
  • Contiene varias reglas de negocio no relacionadas.

Cuando una unidad tiene estas características, puede ser necesario dividir responsabilidades antes de escribir pruebas claras.

3.16 Tabla comparativa

Elemento Puede ser unidad Comentario
Función de cálculo Suele ser una unidad muy clara.
Método de validación Ideal si tiene reglas observables.
Clase con responsabilidad única 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.

3.17 Cómo elegir la unidad a probar

Antes de escribir una prueba, conviene hacer una pausa y elegir bien la unidad. Algunas preguntas útiles son:

  • ¿Qué comportamiento quiero verificar?
  • ¿Qué función, método, clase o módulo contiene ese comportamiento?
  • ¿Puedo ejecutar esa parte sin levantar toda la aplicación?
  • ¿Qué entradas necesito preparar?
  • ¿Qué salida, estado o error espero observar?
  • ¿La prueba fallará por el comportamiento que me interesa o por una dependencia externa?

Estas preguntas ayudan a evitar pruebas confusas. Una prueba unitaria empieza con una unidad bien elegida.

3.18 Ejemplo completo de elección

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.

3.19 Qué debes recordar de este tema

  • Una unidad de código es una parte del programa con una responsabilidad identificable.
  • Una unidad puede ser una función, un método, una clase o un módulo.
  • El tamaño no se define solo por líneas de código, sino por responsabilidad y capacidad de prueba.
  • Conviene probar comportamientos observables, no detalles internos innecesarios.
  • Las unidades con dependencias externas suelen ser más difíciles de probar de forma unitaria.
  • Una unidad demasiado grande dificulta el diagnóstico cuando una prueba falla.
  • Una unidad demasiado pequeña puede llevar a pruebas que no aportan valor.

3.20 Conclusión

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.