Los códigos mágicos son valores escritos directamente en el código sin explicar su significado. Pueden ser números, textos, abreviaturas, estados o combinaciones de datos que representan reglas de negocio.
En este tema reemplazaremos esos valores por constantes, enumeraciones y objetos de valor. El objetivo es hacer explícita la intención, reducir errores de escritura y facilitar cambios futuros.
Un código mágico es un valor que solo se entiende si conocemos el contexto completo. Por ejemplo:
if pedido["estado"] == "A":
total = total * 0.9
if total > 10000:
envio = 0
¿Qué significa "A"? ¿Por qué 10000? ¿Ese número aparece en otros lugares? El problema no es el valor en sí, sino la falta de intención visible.
Crea el archivo src/pedidos_estado.py:
def calcular_total_pedido(pedido):
total = 0
for item in pedido["items"]:
total = total + item["precio"] * item["cantidad"]
if pedido["cliente"] == "VIP":
total = total * 0.9
if pedido["estado"] == "C":
total = 0
elif pedido["estado"] == "P":
total = total + 500
if total >= 10000:
envio = 0
else:
envio = 1200
return round(total + envio, 2)
Hay varios valores difíciles de interpretar: "VIP", "C", "P", 0.9, 500, 10000 y 1200.
Crea tests/test_pedidos_estado.py:
from pedidos_estado import calcular_total_pedido
def test_calcula_pedido_vip_pendiente_con_envio():
pedido = {
"cliente": "VIP",
"estado": "P",
"items": [
{"precio": 3000, "cantidad": 2},
],
}
assert calcular_total_pedido(pedido) == 7100.0
def test_calcula_pedido_cancelado():
pedido = {
"cliente": "REG",
"estado": "C",
"items": [
{"precio": 20000, "cantidad": 1},
],
}
assert calcular_total_pedido(pedido) == 1200
Ejecuta:
python -m pytest tests/test_pedidos_estado.py
El primer paso es dar nombre a los valores numéricos y textos evidentes:
CLIENTE_VIP = "VIP"
ESTADO_CANCELADO = "C"
ESTADO_PENDIENTE = "P"
DESCUENTO_VIP = 0.9
RECARGO_PENDIENTE = 500
LIMITE_ENVIO_GRATIS = 10000
COSTO_ENVIO = 1200
Luego reemplazamos los valores directos por constantes. Este cambio no altera el comportamiento, pero mejora la lectura.
La función puede quedar así:
CLIENTE_VIP = "VIP"
ESTADO_CANCELADO = "C"
ESTADO_PENDIENTE = "P"
DESCUENTO_VIP = 0.9
RECARGO_PENDIENTE = 500
LIMITE_ENVIO_GRATIS = 10000
COSTO_ENVIO = 1200
def calcular_total_pedido(pedido):
total = 0
for item in pedido["items"]:
total = total + item["precio"] * item["cantidad"]
if pedido["cliente"] == CLIENTE_VIP:
total = total * DESCUENTO_VIP
if pedido["estado"] == ESTADO_CANCELADO:
total = 0
elif pedido["estado"] == ESTADO_PENDIENTE:
total = total + RECARGO_PENDIENTE
if total >= LIMITE_ENVIO_GRATIS:
envio = 0
else:
envio = COSTO_ENVIO
return round(total + envio, 2)
Después del cambio ejecuta python -m pytest tests/test_pedidos_estado.py.
La constante DESCUENTO_VIP = 0.9 puede confundir: ¿es el descuento o el factor que queda después del descuento? Un nombre más preciso es:
FACTOR_DESCUENTO_VIP = 0.9
También podríamos modelarlo como porcentaje:
PORCENTAJE_DESCUENTO_VIP = 0.10
total = total * (1 - PORCENTAJE_DESCUENTO_VIP)
Ambas opciones son válidas. Lo importante es que el nombre no mienta.
Cuando un campo puede tomar un conjunto limitado de valores, una enumeración puede ser más expresiva que strings sueltos.
from enum import Enum
class EstadoPedido(Enum):
CANCELADO = "C"
PENDIENTE = "P"
CONFIRMADO = "F"
La enumeración agrupa los estados posibles y reduce errores de escritura como "cancelado", "Cancelado" o "CAN".
Si el pedido todavía llega como diccionario con strings, podemos convertir el valor al enum dentro de la función:
def obtener_estado_pedido(pedido):
return EstadoPedido(pedido["estado"])
Luego usamos el enum en las comparaciones:
estado = obtener_estado_pedido(pedido)
if estado == EstadoPedido.CANCELADO:
total = 0
elif estado == EstadoPedido.PENDIENTE:
total = total + RECARGO_PENDIENTE
La interfaz externa sigue aceptando "C" y "P", pero el código interno trabaja con nombres claros.
Una versión intermedia puede ser:
from enum import Enum
class EstadoPedido(Enum):
CANCELADO = "C"
PENDIENTE = "P"
CONFIRMADO = "F"
CLIENTE_VIP = "VIP"
FACTOR_DESCUENTO_VIP = 0.9
RECARGO_PENDIENTE = 500
LIMITE_ENVIO_GRATIS = 10000
COSTO_ENVIO = 1200
def obtener_estado_pedido(pedido):
return EstadoPedido(pedido["estado"])
def calcular_total_pedido(pedido):
total = 0
for item in pedido["items"]:
total = total + item["precio"] * item["cantidad"]
if pedido["cliente"] == CLIENTE_VIP:
total = total * FACTOR_DESCUENTO_VIP
estado = obtener_estado_pedido(pedido)
if estado == EstadoPedido.CANCELADO:
total = 0
elif estado == EstadoPedido.PENDIENTE:
total = total + RECARGO_PENDIENTE
if total >= LIMITE_ENVIO_GRATIS:
envio = 0
else:
envio = COSTO_ENVIO
return round(total + envio, 2)
Ejecuta las pruebas para confirmar que la conversión no cambió el resultado.
Algunos valores tienen reglas propias. Por ejemplo, el dinero no es solo un número: puede requerir redondeo, evitar negativos o aplicar operaciones con criterio.
Podemos crear un objeto de valor simple con dataclass:
from dataclasses import dataclass
@dataclass(frozen=True)
class Dinero:
monto: float
def redondeado(self):
return round(self.monto, 2)
def sumar(self, otro_monto):
return Dinero(self.monto + otro_monto)
frozen=True hace que el objeto sea inmutable: una vez creado, no se modifican sus atributos.
No hace falta crear un objeto de valor para cada número. Tiene sentido cuando el valor representa un concepto importante y tiene reglas asociadas.
Una constante debe explicar la regla. Nombres como NUMERO_UNO, TEXTO_A o VALOR_MAXIMO suelen ser demasiado genéricos.
# Poco claro
VALOR_MAXIMO = 10000
# Más claro
LIMITE_ENVIO_GRATIS = 10000
El segundo nombre permite entender por qué existe ese número.
Al introducir Enum, un estado inválido produce ValueError. Podemos caracterizar ese comportamiento:
import pytest
from pedidos_estado import calcular_total_pedido
def test_falla_con_estado_desconocido():
pedido = {
"cliente": "REG",
"estado": "X",
"items": [],
}
with pytest.raises(ValueError):
calcular_total_pedido(pedido)
El comportamiento puede cambiar más adelante si decidimos devolver un error de dominio, pero eso ya sería otra modificación.
Crea el archivo src/suscripciones.py:
def calcular_precio_suscripcion(suscripcion):
precio = 0
if suscripcion["plan"] == "B":
precio = 5000
elif suscripcion["plan"] == "P":
precio = 9000
elif suscripcion["plan"] == "E":
precio = 15000
if suscripcion["periodo"] == "A":
precio = precio * 10
if suscripcion["estado"] == "C":
precio = 0
return precio
Realiza estas tareas:
Enum para plan, período y estado.python -m pytest después de cada grupo de cambios.Una versión refactorizada puede ser:
from enum import Enum
class Plan(Enum):
BASICO = "B"
PREMIUM = "P"
EMPRESA = "E"
class Periodo(Enum):
MENSUAL = "M"
ANUAL = "A"
class EstadoSuscripcion(Enum):
ACTIVA = "A"
CANCELADA = "C"
PRECIO_PLAN_BASICO = 5000
PRECIO_PLAN_PREMIUM = 9000
PRECIO_PLAN_EMPRESA = 15000
MESES_FACTURADOS_EN_ANUAL = 10
def obtener_precio_base(plan):
if plan == Plan.BASICO:
return PRECIO_PLAN_BASICO
if plan == Plan.PREMIUM:
return PRECIO_PLAN_PREMIUM
if plan == Plan.EMPRESA:
return PRECIO_PLAN_EMPRESA
return 0
def calcular_precio_suscripcion(suscripcion):
plan = Plan(suscripcion["plan"])
periodo = Periodo(suscripcion["periodo"])
estado = EstadoSuscripcion(suscripcion["estado"])
precio = obtener_precio_base(plan)
if periodo == Periodo.ANUAL:
precio = precio * MESES_FACTURADOS_EN_ANUAL
if estado == EstadoSuscripcion.CANCELADA:
precio = 0
return precio
Los códigos externos siguen siendo cortos, pero el código interno ahora usa nombres explícitos.
Antes de continuar, verifica que puedes explicar estos puntos:
Enum.En este tema reemplazamos códigos mágicos por constantes, enumeraciones y objetos de valor. Al dar nombre a números y textos importantes, el código comunica mejor sus reglas y reduce errores por valores mal escritos.
En el próximo tema moveremos responsabilidades entre funciones, clases y módulos para mejorar la organización del código.