30. Caso práctico integrador: análisis y mejora de un proyecto Python con code smells

30.1 Objetivo del tema

Este tema integra lo trabajado durante el curso. Partiremos de un pequeño proyecto Python con code smells intencionales, escribiremos pruebas de caracterización, ejecutaremos herramientas de calidad, diagnosticaremos problemas y aplicaremos mejoras pequeñas y seguras.

El objetivo no es dejar un proyecto perfecto, sino practicar un flujo profesional: entender, proteger, mejorar y verificar.

Objetivo práctico: analizar y mejorar un proyecto Python con problemas reales de legibilidad, duplicación, condicionales, errores, acoplamiento y pruebas insuficientes.

30.2 Crear el proyecto integrador

Desde una carpeta de trabajo, crea un proyecto nuevo:

mkdir calidad_integrador
cd calidad_integrador
python -m venv .venv

Activa el entorno virtual. En Windows PowerShell:

.venv\Scripts\Activate.ps1

En Linux o macOS:

source .venv/bin/activate

30.3 Instalar herramientas

python -m pip install pytest black isort ruff mypy

Crea la estructura:

mkdir src
mkdir tests
New-Item src\pedidos.py
New-Item tests\test_pedidos.py
New-Item pyproject.toml
New-Item README.md

En Linux o macOS:

mkdir src tests
touch src/pedidos.py tests/test_pedidos.py pyproject.toml README.md

30.4 Configurar pyproject.toml

[project]
name = "calidad-integrador"
version = "0.1.0"
requires-python = ">=3.10"

[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]

[tool.black]
line-length = 88
target-version = ["py310"]

[tool.isort]
profile = "black"
line_length = 88

[tool.ruff]
line-length = 88
target-version = "py310"

[tool.ruff.lint]
select = ["E", "F", "B", "SIM", "I", "C901"]

[tool.ruff.lint.mccabe]
max-complexity = 6

[tool.mypy]
python_version = "3.10"
check_untyped_defs = true
warn_unused_configs = true

30.5 Código inicial con smells

En src/pedidos.py, escribe:

import os

historial = []


def proc(items, cliente, pais, guardar, mostrar):
    total = 0
    for x in items:
        try:
            if x["cant"] > 0:
                total = total + x["precio"] * x["cant"]
        except Exception:
            pass

    if cliente == "vip":
        total = total - total * 0.15
    else:
        if cliente == "regular":
            total = total - total * 0.05

    if pais == "AR":
        total = total + total * 0.21
    else:
        if pais == "UY":
            total = total + total * 0.22
        else:
            total = total + total * 0.19

    if total < 10000:
        total = total + 1500

    total = round(total, 2)
    historial.append(total)

    if guardar:
        with open("ultimo_pedido.txt", "w", encoding="utf-8") as archivo:
            archivo.write(str(total))

    if mostrar:
        print("total", total)

    return total

30.6 Primer diagnóstico

Antes de modificar, registra smells observados:

  • Nombres poco claros: proc, items, x, cant.
  • Import no usado: os.
  • Estado global mutable: historial.
  • Excepción genérica silenciada.
  • Valores mágicos.
  • Condicionales anidados.
  • Banderas booleanas: guardar y mostrar.
  • Cálculo mezclado con archivo y consola.

30.7 Pruebas de caracterización

En tests/test_pedidos.py, agrega:

from pedidos import proc


def test_cliente_vip_argentina():
    items = [
        {"precio": 3000, "cant": 2},
        {"precio": 1500, "cant": 1},
    ]

    assert proc(items, "vip", "AR", False, False) == 9213.75


def test_cliente_regular_uruguay():
    items = [
        {"precio": 1000, "cant": 1},
    ]

    assert proc(items, "regular", "UY", False, False) == 2659.0


def test_ignora_item_invalido_por_comportamiento_actual():
    items = [
        {"precio": 1000, "cant": 2},
        {"precio": 5000},
    ]

    assert proc(items, "nuevo", "CL", False, False) == 3880.0

Ejecuta:

python -m pytest

30.8 Ejecutar herramientas

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

Es esperable que Ruff marque problemas como import no usado, excepción genérica o complejidad. No corrijas todo de golpe.

30.9 Mejora 1: nombres y constantes

Primer cambio seguro: renombrar y extraer constantes. Mantén un alias temporal si las pruebas todavía importan proc.

DESCUENTO_VIP = 0.15
DESCUENTO_REGULAR = 0.05
IMPUESTOS = {"AR": 0.21, "UY": 0.22}
IMPUESTO_PREDETERMINADO = 0.19
LIMITE_ENVIO_GRATIS = 10000
COSTO_ENVIO = 1500


def calcular_total_pedido(productos, tipo_cliente, pais, guardar, mostrar):
    total = 0
    for producto in productos:
        try:
            if producto["cant"] > 0:
                total += producto["precio"] * producto["cant"]
        except Exception:
            pass
    ...


proc = calcular_total_pedido

Ejecuta pruebas después del cambio.

30.10 Mejora 2: separar cálculo

Extrae funciones de reglas:

def calcular_subtotal(productos):
    subtotal = 0
    for producto in productos:
        try:
            if producto["cant"] > 0:
                subtotal += producto["precio"] * producto["cant"]
        except Exception:
            pass
    return subtotal


def obtener_descuento(tipo_cliente):
    if tipo_cliente == "vip":
        return DESCUENTO_VIP
    if tipo_cliente == "regular":
        return DESCUENTO_REGULAR
    return 0


def obtener_impuesto(pais):
    return IMPUESTOS.get(pais, IMPUESTO_PREDETERMINADO)


def aplicar_envio(total):
    if total < LIMITE_ENVIO_GRATIS:
        return total + COSTO_ENVIO
    return total

30.11 Mejora 3: función principal más clara

def calcular_total_pedido(productos, tipo_cliente, pais, guardar, mostrar):
    subtotal = calcular_subtotal(productos)
    descuento = obtener_descuento(tipo_cliente)
    impuesto = obtener_impuesto(pais)

    total = subtotal * (1 - descuento)
    total = total * (1 + impuesto)
    total = aplicar_envio(total)
    total = round(total, 2)

    historial.append(total)

    if guardar:
        guardar_total(total)
    if mostrar:
        mostrar_total(total)

    return total

Aún hay efectos secundarios, pero ya están más visibles.

30.12 Mejora 4: separar archivo y consola

def guardar_total(total, ruta="ultimo_pedido.txt"):
    with open(ruta, "w", encoding="utf-8") as archivo:
        archivo.write(str(total))


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

Ahora el cálculo puede separarse del guardado y la presentación en una siguiente iteración.

30.13 Mejora 5: eliminar banderas booleanas

Una alternativa más clara es que el cálculo no reciba guardar ni mostrar.

def calcular_total_pedido(productos, tipo_cliente, pais):
    subtotal = calcular_subtotal(productos)
    descuento = obtener_descuento(tipo_cliente)
    impuesto = obtener_impuesto(pais)

    total = subtotal * (1 - descuento)
    total = total * (1 + impuesto)
    total = aplicar_envio(total)
    return round(total, 2)

Luego una función de orquestación decide si guarda o muestra.

30.14 Mejora 6: validar en lugar de silenciar

El comportamiento anterior ignoraba datos inválidos. Si el negocio decide que eso no es aceptable, este cambio ya sería funcional y debe hacerse separado.

def validar_producto(producto):
    if "precio" not in producto:
        raise ValueError("Falta el precio")
    if "cant" not in producto:
        raise ValueError("Falta la cantidad")
    if producto["cant"] <= 0:
        raise ValueError("La cantidad debe ser positiva")

Este cambio requiere actualizar pruebas porque modifica el comportamiento frente a datos inválidos.

30.15 Versión final orientativa

DESCUENTO_VIP = 0.15
DESCUENTO_REGULAR = 0.05
IMPUESTOS = {"AR": 0.21, "UY": 0.22}
IMPUESTO_PREDETERMINADO = 0.19
LIMITE_ENVIO_GRATIS = 10000
COSTO_ENVIO = 1500


def calcular_subtotal(productos):
    return sum(
        producto["precio"] * producto["cant"]
        for producto in productos
        if producto["cant"] > 0
    )


def obtener_descuento(tipo_cliente):
    if tipo_cliente == "vip":
        return DESCUENTO_VIP
    if tipo_cliente == "regular":
        return DESCUENTO_REGULAR
    return 0


def obtener_impuesto(pais):
    return IMPUESTOS.get(pais, IMPUESTO_PREDETERMINADO)


def aplicar_envio(total):
    if total < LIMITE_ENVIO_GRATIS:
        return total + COSTO_ENVIO
    return total


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

30.16 Actualizar pruebas

Cuando eliminas el alias y las banderas, actualiza las pruebas:

from pedidos import calcular_total_pedido


def test_cliente_vip_argentina():
    productos = [
        {"precio": 3000, "cant": 2},
        {"precio": 1500, "cant": 1},
    ]

    assert calcular_total_pedido(productos, "vip", "AR") == 9213.75

30.17 Verificación final

Ejecuta el flujo completo:

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

Si alguna herramienta falla, corrige el problema más pequeño posible y vuelve a ejecutar.

30.18 Documentar el resultado

En el README.md, registra una breve síntesis:

# Calidad Integrador

Proyecto de práctica para analizar y mejorar code smells en Python.

Mejoras realizadas:

- Nombres más expresivos.
- Constantes para reglas de negocio.
- Separación de subtotal, descuento, impuesto y envío.
- Eliminación de banderas booleanas en el cálculo principal.
- Pruebas de caracterización para proteger comportamiento.

30.19 Checklist final del caso

Antes de dar por terminado el caso, revisa:

  • Las pruebas pasan.
  • Las herramientas no reportan errores relevantes.
  • Los nombres expresan intención.
  • Las reglas de negocio tienen constantes o funciones claras.
  • No hay excepciones silenciadas sin decisión explícita.
  • El cálculo principal no mezcla consola ni archivos.
  • El diff puede explicarse como mejoras de estructura.

30.20 Cierre del curso

En este curso recorrimos criterios prácticos para mejorar la calidad de código en Python: nombres, estilo, herramientas, code smells, funciones, clases, dataclasses, type hints, módulos, pruebas y métricas.

La idea central es simple: el código de calidad no solo funciona hoy, también permite entender, cambiar y verificar mañana. Esa es la diferencia entre escribir una solución momentánea y construir software mantenible.