Una fuente frecuente de errores aparece cuando una función parece consultar información, pero además modifica datos. Ese efecto secundario puede sorprender a quien llama a la función y hacer que las pruebas sean más difíciles de escribir.
En este tema practicaremos la separación entre consultas y modificaciones. Una consulta devuelve información sin cambiar estado. Una modificación cambia estado, pero no debería esconder cálculos importantes detrás de un nombre ambiguo.
Un efecto secundario ocurre cuando una función cambia algo fuera de su resultado de retorno. Puede modificar una lista, un diccionario, un atributo, un archivo, una base de datos, la consola o una variable global.
Los efectos secundarios no son siempre malos. Guardar un pedido, enviar un email o registrar un pago requiere modificar algo. El problema aparece cuando el efecto está escondido en una función que parece ser solo una consulta.
Una forma práctica de pensar el diseño es separar:
Por ejemplo, calcular_total() debería calcular y devolver un total. En cambio, registrar_pago() puede modificar una factura. Si una función se llama obtener_total() y además cambia el pedido, el nombre oculta un riesgo.
Crea el archivo src/carrito.py:
def obtener_total(carrito):
total = 0
for item in carrito["items"]:
total = total + item["precio"] * item["cantidad"]
if carrito["cliente"] == "vip":
total = total * 0.9
carrito["total"] = round(total, 2)
carrito["procesado"] = True
return carrito["total"]
El nombre obtener_total sugiere una consulta, pero la función modifica el diccionario carrito agregando "total" y "procesado".
Primero capturamos el comportamiento actual. Crea tests/test_carrito.py:
from carrito import obtener_total
def test_obtener_total_devuelve_total_y_modifica_carrito_vip():
carrito = {
"cliente": "vip",
"items": [
{"precio": 1000, "cantidad": 2},
{"precio": 500, "cantidad": 1},
],
}
assert obtener_total(carrito) == 2250.0
assert carrito["total"] == 2250.0
assert carrito["procesado"] is True
def test_obtener_total_devuelve_total_regular():
carrito = {
"cliente": "regular",
"items": [
{"precio": 800, "cantidad": 3},
],
}
assert obtener_total(carrito) == 2400
assert carrito["total"] == 2400
assert carrito["procesado"] is True
Ejecuta:
python -m pytest tests/test_carrito.py
El primer refactoring será extraer una consulta pura: una función que calcule el total sin modificar el carrito.
def calcular_total(carrito):
total = 0
for item in carrito["items"]:
total = total + item["precio"] * item["cantidad"]
if carrito["cliente"] == "vip":
total = total * 0.9
return round(total, 2)
Todavía no eliminamos la función anterior. La hacemos delegar en la nueva consulta y conservar la modificación existente.
Podemos modificar obtener_total así:
def calcular_total(carrito):
total = 0
for item in carrito["items"]:
total = total + item["precio"] * item["cantidad"]
if carrito["cliente"] == "vip":
total = total * 0.9
return round(total, 2)
def obtener_total(carrito):
total = calcular_total(carrito)
carrito["total"] = total
carrito["procesado"] = True
return total
El comportamiento público de obtener_total sigue igual, pero ahora tenemos una función pura que podemos probar y reutilizar. Ejecuta python -m pytest tests/test_carrito.py.
Agregamos una prueba específica para la nueva función:
from carrito import calcular_total, obtener_total
def test_calcular_total_no_modifica_el_carrito():
carrito = {
"cliente": "vip",
"items": [
{"precio": 1000, "cantidad": 2},
],
}
assert calcular_total(carrito) == 1800.0
assert "total" not in carrito
assert "procesado" not in carrito
Esta prueba documenta una propiedad importante del nuevo diseño: calcular no debería modificar.
El nombre obtener_total ya no es honesto. Si una función modifica el carrito, conviene que el nombre lo diga. Podemos crear una función nueva:
def marcar_carrito_procesado(carrito):
total = calcular_total(carrito)
carrito["total"] = total
carrito["procesado"] = True
return total
Luego actualizamos las llamadas internas y las pruebas nuevas para usar marcar_carrito_procesado. Si necesitamos compatibilidad temporal, podemos dejar obtener_total como alias durante una migración.
Si otros módulos todavía llaman a obtener_total, podemos mantenerla temporalmente:
def obtener_total(carrito):
return marcar_carrito_procesado(carrito)
Esto conserva el comportamiento anterior, pero permite que el código nuevo use nombres más precisos. Más adelante, cuando todas las llamadas hayan migrado, se podrá eliminar la función vieja.
Una versión ordenada del módulo puede quedar así:
def calcular_total(carrito):
total = 0
for item in carrito["items"]:
total = total + item["precio"] * item["cantidad"]
if carrito["cliente"] == "vip":
total = total * 0.9
return round(total, 2)
def marcar_carrito_procesado(carrito):
total = calcular_total(carrito)
carrito["total"] = total
carrito["procesado"] = True
return total
def obtener_total(carrito):
return marcar_carrito_procesado(carrito)
Ahora distinguimos claramente la consulta pura y la modificación del diccionario.
Los efectos secundarios también aparecen cuando una función modifica una lista recibida como parámetro.
def obtener_items_confirmados(items):
for item in items:
if item["estado"] != "confirmado":
items.remove(item)
return items
El nombre parece una consulta, pero la función modifica la lista original. Una alternativa más clara es construir una nueva lista:
def obtener_items_confirmados(items):
confirmados = []
for item in items:
if item["estado"] == "confirmado":
confirmados.append(item)
return confirmados
Esta versión devuelve información sin alterar la colección recibida.
Podemos verificarlo con una prueba sencilla:
def test_obtener_items_confirmados_no_modifica_lista_original():
items = [
{"estado": "confirmado", "nombre": "A"},
{"estado": "pendiente", "nombre": "B"},
]
resultado = obtener_items_confirmados(items)
assert resultado == [{"estado": "confirmado", "nombre": "A"}]
assert items == [
{"estado": "confirmado", "nombre": "A"},
{"estado": "pendiente", "nombre": "B"},
]
Esta prueba protege contra regresiones: si alguien vuelve a modificar la lista original, la prueba fallará.
No toda mutación debe eliminarse. A veces necesitamos una función que cambie estado. Lo importante es que el nombre sea claro.
def aplicar_descuento_al_carrito(carrito, porcentaje):
carrito["descuento"] = porcentaje
carrito["total"] = carrito["total"] * (1 - porcentaje)
El nombre aplicar_descuento_al_carrito comunica una acción. No promete ser una consulta.
Crea el archivo src/inventario.py:
def obtener_disponible(producto, cantidad):
disponible = producto["stock"] >= cantidad
if disponible:
producto["stock"] = producto["stock"] - cantidad
producto["reservado"] = producto.get("reservado", 0) + cantidad
return disponible
Realiza estas tareas:
hay_stock_disponible que no modifique el producto.reservar_stock que sí modifique el producto.python -m pytest después de cada paso.Una versión más clara puede ser:
def hay_stock_disponible(producto, cantidad):
return producto["stock"] >= cantidad
def reservar_stock(producto, cantidad):
if not hay_stock_disponible(producto, cantidad):
return False
producto["stock"] = producto["stock"] - cantidad
producto["reservado"] = producto.get("reservado", 0) + cantidad
return True
def obtener_disponible(producto, cantidad):
return reservar_stock(producto, cantidad)
La función obtener_disponible queda como compatibilidad temporal. El código nuevo debería preferir hay_stock_disponible para consultar y reservar_stock para modificar.
Antes de continuar, verifica que puedes explicar estos puntos:
En este tema separamos consultas y modificaciones para reducir efectos secundarios ocultos. Vimos que una función puede modificar estado, pero debe expresarlo con claridad en su nombre y no mezclarse innecesariamente con cálculos reutilizables.
En el próximo tema trabajaremos con condicionales complejos y cómo reemplazarlos por funciones con nombres claros.