17. Datos globales, efectos secundarios y funciones difíciles de probar

17.1 Objetivo del tema

Una función es más fácil de entender y probar cuando recibe datos por parámetros y devuelve un resultado claro. En cambio, cuando lee variables globales, modifica estado externo, imprime, escribe archivos o consulta la fecha actual, su comportamiento depende de cosas que no se ven en la firma.

En este tema analizaremos datos globales, efectos secundarios y funciones difíciles de probar. Veremos cómo reducir dependencias ocultas y cómo separar cálculo puro de entrada, salida y estado externo.

Objetivo práctico: transformar funciones con dependencias ocultas en funciones más explícitas, predecibles y fáciles de probar.

17.2 Qué es un efecto secundario

Un efecto secundario ocurre cuando una función, además de devolver un valor, modifica o usa algo fuera de ella. Algunos efectos secundarios son necesarios, pero conviene ubicarlos y controlarlos.

Ejemplos de efectos secundarios:

  • Modificar una variable global.
  • Escribir un archivo.
  • Imprimir por consola.
  • Enviar una petición HTTP.
  • Leer la fecha u hora actual.
  • Modificar una lista recibida por parámetro.

17.3 Función pura y función con efecto secundario

Una función pura depende solo de sus parámetros y no modifica nada externo.

def calcular_total(precio, cantidad):
    return precio * cantidad

Una función con efecto secundario hace algo externo:

def mostrar_total(precio, cantidad):
    total = precio * cantidad
    print(f"Total: {total}")

La segunda función no es necesariamente mala, pero es menos flexible. Si queremos probar el cálculo, debemos lidiar con la salida por consola.

17.4 Smell: dato global mutable

Un dato global mutable puede cambiar desde muchos lugares. Eso dificulta saber en qué estado está el programa.

DESCUENTOS_USADOS = []


def aplicar_descuento(total, cliente):
    if cliente == "vip":
        DESCUENTOS_USADOS.append(cliente)
        return total * 0.85
    return total

La función calcula un descuento y además modifica una lista global. Ese estado compartido puede afectar pruebas o ejecuciones posteriores.

17.5 Pasar dependencias por parámetro

Una mejora simple es pasar el registro como dependencia explícita.

def aplicar_descuento(total, cliente, descuentos_usados):
    if cliente == "vip":
        descuentos_usados.append(cliente)
        return total * 0.85
    return total

Ahora la función sigue modificando una lista, pero la dependencia ya no está oculta. Quien llama decide qué lista usar.

17.6 Separar cálculo y registro

Aún mejor: separar el cálculo del registro.

def calcular_total_con_descuento(total, cliente):
    if cliente == "vip":
        return total * 0.85
    return total


def registrar_descuento(cliente, descuentos_usados):
    if cliente == "vip":
        descuentos_usados.append(cliente)

La función de cálculo queda pura y es más fácil de probar. La función de registro concentra el efecto secundario.

17.7 Smell: función que modifica argumentos

Modificar una lista o diccionario recibido por parámetro puede sorprender a quien llama.

def normalizar_productos(productos):
    for producto in productos:
        producto["nombre"] = producto["nombre"].strip().lower()
    return productos

La función devuelve la lista, pero también modifica la lista original. Si eso no está claramente esperado, puede generar errores difíciles de rastrear.

17.8 Devolver nuevos datos

Podemos crear nuevos diccionarios para evitar modificar la entrada.

def normalizar_productos(productos):
    return [
        {
            **producto,
            "nombre": producto["nombre"].strip().lower(),
        }
        for producto in productos
    ]

Ahora la función devuelve una nueva lista. La entrada original queda intacta.

17.9 Smell: lectura directa de fecha actual

Leer la fecha actual dentro de una función complica las pruebas porque el resultado cambia según el día.

from datetime import date


def crear_factura(total):
    return {
        "fecha": date.today().isoformat(),
        "total": total,
    }

La función es simple, pero no es completamente predecible.

17.10 Pasar la fecha como parámetro

Una alternativa es pasar la fecha desde afuera.

def crear_factura(total, fecha):
    return {
        "fecha": fecha.isoformat(),
        "total": total,
    }

La función ahora es fácil de probar:

from datetime import date


def test_crear_factura():
    factura = crear_factura(1500, date(2026, 5, 11))

    assert factura == {
        "fecha": "2026-05-11",
        "total": 1500,
    }

17.11 Smell: lectura y cálculo mezclados

Mezclar lectura de archivos con cálculo vuelve más difícil probar la regla de negocio.

def calcular_total_desde_archivo(ruta):
    with open(ruta, encoding="utf-8") as archivo:
        total = 0
        for linea in archivo:
            precio, cantidad = linea.strip().split(",")
            total += float(precio) * int(cantidad)
    return total

Para probar el cálculo, necesitamos crear archivos. Conviene separar parseo, lectura y cálculo.

17.12 Separar lectura, parseo y cálculo

def calcular_total_productos(productos):
    return sum(
        producto["precio"] * producto["cantidad"]
        for producto in productos
    )


def parsear_producto(linea):
    precio, cantidad = linea.strip().split(",")
    return {"precio": float(precio), "cantidad": int(cantidad)}


def leer_productos(ruta):
    with open(ruta, encoding="utf-8") as archivo:
        return [parsear_producto(linea) for linea in archivo]

Ahora calcular_total_productos se prueba sin archivos, y leer_productos concentra el acceso al sistema de archivos.

17.13 Aplicación sobre ventas_demo

En ventas_demo, la función principal debería recibir productos, tipo de cliente y país. No debería leer archivos, pedir datos por consola ni imprimir resultados.

def calcular_total_venta(productos, tipo_cliente, pais):
    subtotal = calcular_subtotal(productos)
    descuento = obtener_descuento(tipo_cliente)
    impuesto = obtener_impuesto(pais)
    total = subtotal * (1 - descuento) * (1 + impuesto)
    return round(aplicar_envio(total), 2)

Si necesitas mostrar el resultado, hazlo en otra función:

def mostrar_total(total):
    print(f"Total: {total}")

17.14 Constantes globales no son lo mismo que estado global

Una constante global con una regla estable suele ser aceptable.

COSTO_ENVIO = 1500

El problema aparece cuando el dato global cambia durante la ejecución.

total_ventas_procesadas = 0

Si una función modifica ese contador global, sus resultados dependen del orden de llamadas y las pruebas pueden interferirse entre sí.

17.15 Pruebas más simples

Una función sin efectos secundarios se prueba con entrada y salida.

def test_calcular_total_productos():
    productos = [
        {"precio": 1000, "cantidad": 2},
        {"precio": 500, "cantidad": 3},
    ]

    assert calcular_total_productos(productos) == 3500

No necesitamos archivos, consola, fecha actual ni variables globales. Esa simplicidad es una señal de buen diseño.

17.16 Ejercicio guiado

Mejora esta función separando cálculo y efecto secundario:

ventas_procesadas = []


def procesar_venta(productos):
    total = 0
    for producto in productos:
        total += producto["precio"] * producto["cantidad"]
    ventas_procesadas.append(total)
    print(f"Venta procesada: {total}")
    return total

Una mejora posible es:

def calcular_total(productos):
    return sum(
        producto["precio"] * producto["cantidad"]
        for producto in productos
    )


def registrar_venta(total, ventas_procesadas):
    ventas_procesadas.append(total)


def mostrar_venta(total):
    print(f"Venta procesada: {total}")

17.17 Ejercicio propuesto

Analiza esta función y hazla más fácil de probar:

from datetime import date

historial = []


def generar_resumen(productos):
    total = sum(
        producto["precio"] * producto["cantidad"]
        for producto in productos
    )
    resumen = f"{date.today().isoformat()} - Total: {total}"
    historial.append(resumen)
    print(resumen)
    return resumen

Realiza estas tareas:

  • Separa el cálculo del total.
  • Pasa la fecha como parámetro.
  • Evita modificar una lista global desde la función principal.
  • Separa la impresión por consola.
  • Agrega una prueba para el cálculo y otra para crear el resumen.

17.18 Verificación con herramientas

Después de reorganizar funciones, ejecuta:

python -m ruff check src tests
python -m black src tests
python -m pytest

Ruff puede detectar variables globales no usadas o imports sobrantes, pero no siempre detectará efectos secundarios problemáticos. Para eso hace falta revisión de diseño.

17.19 Lista de verificación

Antes de continuar, verifica que puedes hacer lo siguiente:

  • Distinguir una función pura de una función con efectos secundarios.
  • Reconocer datos globales mutables como smell.
  • Separar cálculo de impresión, archivos y fecha actual.
  • Evitar modificar argumentos si no es parte explícita del contrato.
  • Pasar dependencias por parámetro cuando mejora la claridad.
  • Escribir pruebas simples para funciones sin efectos secundarios.

17.20 Conclusión

En este tema vimos que los datos globales y los efectos secundarios ocultos hacen que una función sea más difícil de entender y probar. La solución no es eliminar todo efecto secundario, sino ubicarlo en lugares claros y mantener el cálculo lo más explícito posible.

En el próximo tema trabajaremos con diseño de clases: clases demasiado grandes, clases vacías y responsabilidades mezcladas.