35. Proyecto final: pequeña biblioteca Python creada con el ciclo rojo, verde y refactor

35.1 Objetivo del tema

En este proyecto final construiremos una pequeña biblioteca Python aplicando TDD de principio a fin. El objetivo es integrar lo aprendido: elegir pruebas pequeñas, avanzar por reglas, refactorizar con la suite en verde y terminar con un módulo claro y reutilizable.

La biblioteca se llamará moneycalc y permitirá trabajar con importes, descuentos, impuestos y redondeo de forma simple.

35.2 Alcance del proyecto

La biblioteca tendrá estas funciones:

  • aplicar_descuento: aplica un porcentaje de descuento.
  • aplicar_impuesto: agrega un porcentaje de impuesto.
  • calcular_total: combina descuento e impuesto.
  • formatear_importe: devuelve un importe con dos decimales.
  • Validaciones para evitar importes y porcentajes inválidos.

Es una biblioteca pequeña, pero suficiente para practicar un ciclo completo.

35.3 Estructura del proyecto

Usaremos una estructura similar a la de un paquete real.

moneycalc-proyecto/
  pyproject.toml
  src/
    moneycalc/
      __init__.py
      calculos.py
  tests/
    test_calculos.py

En este tema asumimos que ya sabés crear el entorno virtual, instalar pytest y ejecutar python -m pytest.

35.4 Configuración mínima

Creamos un archivo de configuración simple.

Archivo a crear: pyproject.toml

[project]
name = "moneycalc"
version = "0.1.0"
description = "Biblioteca pequeña de cálculos monetarios creada con TDD"
requires-python = ">=3.11"

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

Esta configuración permite que las pruebas importen el paquete desde src.

35.5 Primera prueba: descuento básico

Empezamos por una regla pequeña y útil.

Archivo a crear: tests/test_calculos.py

from moneycalc import aplicar_descuento


def test_aplicar_descuento_porcentual():
    total = aplicar_descuento(importe=100, porcentaje=10)

    assert total == 90

Ejecutamos python -m pytest. La prueba debe fallar porque todavía no existe la función.

35.6 Implementación mínima del descuento

Creamos el paquete y la función.

Archivo a crear: src/moneycalc/__init__.py

from moneycalc.calculos import aplicar_descuento

Archivo a crear: src/moneycalc/calculos.py

def aplicar_descuento(importe, porcentaje):
    return importe - importe * porcentaje / 100

Ejecutamos la prueba y llegamos a verde.

35.7 Segunda prueba: descuento cero

Agregamos un borde simple: si el porcentaje es cero, el importe no cambia.

Archivo a modificar: tests/test_calculos.py

def test_descuento_cero_no_modifica_el_importe():
    total = aplicar_descuento(importe=100, porcentaje=0)

    assert total == 100

Esta prueba probablemente pase con el código actual, pero documenta un comportamiento base.

35.8 Tercera prueba: porcentaje inválido

Agregamos una validación.

Archivo a modificar: tests/test_calculos.py

import pytest


def test_no_permite_descuento_negativo():
    with pytest.raises(ValueError, match="El porcentaje debe estar entre 0 y 100"):
        aplicar_descuento(importe=100, porcentaje=-5)

35.9 Implementar validación de porcentaje

Agregamos una función auxiliar para validar.

Archivo a modificar: src/moneycalc/calculos.py

def aplicar_descuento(importe, porcentaje):
    validar_porcentaje(porcentaje)

    return importe - importe * porcentaje / 100


def validar_porcentaje(porcentaje):
    if porcentaje < 0 or porcentaje > 100:
        raise ValueError("El porcentaje debe estar entre 0 y 100")

Ejecutamos toda la suite.

35.10 Cuarta prueba: descuento mayor a 100

El mensaje dice que el porcentaje debe estar entre 0 y 100. Probamos el otro borde inválido.

Archivo a modificar: tests/test_calculos.py

def test_no_permite_descuento_mayor_a_cien():
    with pytest.raises(ValueError, match="El porcentaje debe estar entre 0 y 100"):
        aplicar_descuento(importe=100, porcentaje=120)

35.11 Quinta prueba: impuesto básico

Agregamos la segunda función pública de la biblioteca.

Archivo a modificar: tests/test_calculos.py

from moneycalc import aplicar_descuento, aplicar_impuesto


def test_aplicar_impuesto_porcentual():
    total = aplicar_impuesto(importe=100, porcentaje=21)

    assert total == 121

La prueba falla hasta que exportemos e implementemos la nueva función.

35.12 Implementar impuesto

Reutilizamos la validación de porcentaje.

Archivo a modificar: src/moneycalc/__init__.py

from moneycalc.calculos import aplicar_descuento, aplicar_impuesto

Archivo a modificar: src/moneycalc/calculos.py

def aplicar_impuesto(importe, porcentaje):
    validar_porcentaje(porcentaje)

    return importe + importe * porcentaje / 100

Ejecutamos python -m pytest. Si todo está verde, seguimos.

35.13 Sexta prueba: importe inválido

Ahora definimos que los importes no pueden ser negativos.

Archivo a modificar: tests/test_calculos.py

def test_no_permite_importe_negativo_en_descuento():
    with pytest.raises(ValueError, match="El importe no puede ser negativo"):
        aplicar_descuento(importe=-100, porcentaje=10)

35.14 Implementar validación de importe

Agregamos otra función auxiliar y la usamos en descuento.

Archivo a modificar: src/moneycalc/calculos.py

def aplicar_descuento(importe, porcentaje):
    validar_importe(importe)
    validar_porcentaje(porcentaje)

    return importe - importe * porcentaje / 100


def validar_importe(importe):
    if importe < 0:
        raise ValueError("El importe no puede ser negativo")

Luego agregamos una prueba equivalente para aplicar_impuesto y reutilizamos la misma validación.

35.15 Séptima prueba: cálculo total combinado

La función calcular_total aplicará primero descuento y luego impuesto.

Archivo a modificar: tests/test_calculos.py

from moneycalc import aplicar_descuento, aplicar_impuesto, calcular_total


def test_calcular_total_aplica_descuento_y_luego_impuesto():
    total = calcular_total(
        importe=100,
        descuento=10,
        impuesto=21
    )

    assert total == 108.9

El importe 100 con 10% de descuento queda 90. Luego 21% de impuesto queda 108.9.

35.16 Implementar cálculo combinado

Exportamos e implementamos la función reutilizando las anteriores.

Archivo a modificar: src/moneycalc/__init__.py

from moneycalc.calculos import (
    aplicar_descuento,
    aplicar_impuesto,
    calcular_total,
)

Archivo a modificar: src/moneycalc/calculos.py

def calcular_total(importe, descuento=0, impuesto=0):
    total = aplicar_descuento(importe, descuento)

    return aplicar_impuesto(total, impuesto)

La función principal queda expresiva porque delega reglas ya probadas.

35.17 Octava prueba: redondeo monetario

Agregamos una regla de presentación: formatear importes con dos decimales.

Archivo a modificar: tests/test_calculos.py

from moneycalc import (
    aplicar_descuento,
    aplicar_impuesto,
    calcular_total,
    formatear_importe,
)


def test_formatea_importe_con_dos_decimales():
    texto = formatear_importe(108.9)

    assert texto == "108.90"

35.18 Implementar formateo

Exportamos la función y usamos formato de cadena.

Archivo a modificar: src/moneycalc/__init__.py

from moneycalc.calculos import (
    aplicar_descuento,
    aplicar_impuesto,
    calcular_total,
    formatear_importe,
)

Archivo a modificar: src/moneycalc/calculos.py

def formatear_importe(importe):
    validar_importe(importe)

    return f"{importe:.2f}"

Ejecutamos la suite completa para comprobar el nuevo comportamiento.

35.19 Refactor: evitar duplicación de validaciones

Con varias funciones públicas, conviene revisar que todas validen de forma consistente.

Archivo final: src/moneycalc/calculos.py

def aplicar_descuento(importe, porcentaje):
    validar_importe(importe)
    validar_porcentaje(porcentaje)

    return importe - importe * porcentaje / 100


def aplicar_impuesto(importe, porcentaje):
    validar_importe(importe)
    validar_porcentaje(porcentaje)

    return importe + importe * porcentaje / 100


def calcular_total(importe, descuento=0, impuesto=0):
    total = aplicar_descuento(importe, descuento)

    return aplicar_impuesto(total, impuesto)


def formatear_importe(importe):
    validar_importe(importe)

    return f"{importe:.2f}"


def validar_importe(importe):
    if importe < 0:
        raise ValueError("El importe no puede ser negativo")


def validar_porcentaje(porcentaje):
    if porcentaje < 0 or porcentaje > 100:
        raise ValueError("El porcentaje debe estar entre 0 y 100")

35.20 Suite final sugerida

La suite debe cubrir reglas principales y bordes.

  • Descuento porcentual.
  • Descuento cero.
  • Porcentaje menor que 0.
  • Porcentaje mayor que 100.
  • Impuesto porcentual.
  • Importe negativo.
  • Total combinado.
  • Formateo con dos decimales.

35.21 Ejecutar y revisar

Ejecutamos la suite completa.

python -m pytest

Luego revisamos el estado del trabajo.

git status
git diff

Si todo está en verde y los cambios son coherentes, podemos crear un commit.

git add pyproject.toml src/moneycalc tests/test_calculos.py
git commit -m "Crea biblioteca moneycalc con TDD"

35.22 Criterios de finalización

El proyecto final se considera completo cuando:

  • La suite pasa completa con python -m pytest.
  • Cada función pública tiene pruebas de comportamiento normal y borde.
  • El código no contiene duplicación innecesaria.
  • Los nombres expresan reglas del dominio.
  • El paquete puede importarse desde las pruebas mediante moneycalc.

35.23 Extensiones opcionales

Si querés seguir practicando, agregá estas funcionalidades con TDD.

  1. Soportar moneda en formatear_importe, por ejemplo "$ 108.90".
  2. Agregar dividir_en_cuotas con redondeo a dos decimales.
  3. Agregar calcular_subtotal desde una lista de ítems.
  4. Rechazar cantidades menores o iguales a cero.
  5. Parametrizar pruebas para varios porcentajes.

35.24 Checklist final de TDD

  • Empecé por una prueba pequeña y concreta.
  • Vi fallar la prueba por la razón esperada.
  • Escribí el mínimo código para llegar a verde.
  • Refactoricé solo con la suite pasando.
  • Agregué reglas nuevas mediante pruebas nuevas.
  • Ejecuté la suite completa antes de cerrar.

35.25 Cierre del curso

TDD no es solamente escribir pruebas antes del código. Es una forma de pensar y construir: convertir incertidumbre en ejemplos ejecutables, avanzar en pasos pequeños, diseñar desde el comportamiento y mejorar el código con una red de seguridad.

Con este proyecto final integraste el ciclo rojo, verde y refactor en una biblioteca Python pequeña, verificable y lista para extenderse con nuevas reglas.