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.
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:
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.
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.
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.
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.
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.
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.
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.
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,
}
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.
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.
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}")
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í.
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.
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}")
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:
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.
Antes de continuar, verifica que puedes hacer lo siguiente:
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.