35. Caso práctico integrador: proyecto Python con suite completa de pruebas

35.1 Objetivo del tema

Este tema integra lo visto en el curso en un proyecto pequeño pero completo. Crearemos código de aplicación, pruebas unitarias, fixtures, parametrización, pruebas de errores, archivos temporales, mocks, cobertura y una rutina de calidad.

El proyecto será una tienda simple que maneja productos, carritos y exportación de pedidos.

Idea clave: una suite completa combina varios tipos de pruebas, pero cada prueba debe seguir siendo clara y enfocada.

35.2 Crear la carpeta del proyecto

Crea una carpeta nueva:

mkdir tienda-testing
cd tienda-testing

Instala las herramientas necesarias:

python -m pip install pytest pytest-cov black ruff

35.3 Crear la estructura

Crea carpetas y archivos:

mkdir src
mkdir src\tienda
mkdir tests
New-Item src\tienda\__init__.py -ItemType File
New-Item src\tienda\productos.py -ItemType File
New-Item src\tienda\carrito.py -ItemType File
New-Item src\tienda\exportador.py -ItemType File
New-Item tests\conftest.py -ItemType File
New-Item tests\test_productos.py -ItemType File
New-Item tests\test_carrito.py -ItemType File
New-Item tests\test_exportador.py -ItemType File

35.4 Configurar pytest

Crea pytest.ini en la raíz:

[pytest]
pythonpath = src
testpaths = tests
addopts = -ra
markers =
    integration: pruebas que usan recursos externos o flujos completos

Con esto pytest encuentra el paquete tienda dentro de src.

35.5 Crear el módulo productos

En src\tienda\productos.py escribe:

def crear_producto(codigo, nombre, precio, stock):
    if not codigo:
        raise ValueError("El código es obligatorio")
    if not nombre:
        raise ValueError("El nombre es obligatorio")
    if precio < 0:
        raise ValueError("El precio no puede ser negativo")
    if stock < 0:
        raise ValueError("El stock no puede ser negativo")

    return {
        "codigo": codigo,
        "nombre": nombre,
        "precio": precio,
        "stock": stock,
    }


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

    producto_con_descuento = producto.copy()
    producto_con_descuento["precio"] = producto["precio"] - (
        producto["precio"] * porcentaje / 100
    )
    return producto_con_descuento


def hay_stock(producto, cantidad):
    return producto["stock"] >= cantidad

35.6 Crear el módulo carrito

En src\tienda\carrito.py escribe:

def crear_carrito(cliente):
    if not cliente:
        raise ValueError("El cliente es obligatorio")

    return {
        "cliente": cliente,
        "items": [],
    }


def agregar_producto(carrito, producto, cantidad):
    if cantidad <= 0:
        raise ValueError("La cantidad debe ser mayor a cero")
    if producto["stock"] < cantidad:
        raise ValueError("Stock insuficiente")

    carrito["items"].append(
        {
            "codigo": producto["codigo"],
            "nombre": producto["nombre"],
            "precio": producto["precio"],
            "cantidad": cantidad,
        }
    )


def calcular_total(carrito):
    total = 0
    for item in carrito["items"]:
        total += item["precio"] * item["cantidad"]
    return total


def contar_items(carrito):
    return sum(item["cantidad"] for item in carrito["items"])

35.7 Crear el módulo exportador

En src\tienda\exportador.py escribe:

import json

from tienda.carrito import calcular_total


def generar_resumen(carrito):
    return {
        "cliente": carrito["cliente"],
        "cantidad_items": sum(item["cantidad"] for item in carrito["items"]),
        "total": calcular_total(carrito),
    }


def guardar_resumen(carrito, ruta):
    resumen = generar_resumen(carrito)

    with open(ruta, "w", encoding="utf-8") as archivo:
        json.dump(resumen, archivo, ensure_ascii=False, indent=2)

    return resumen


def enviar_resumen(carrito, cliente_http):
    resumen = generar_resumen(carrito)
    respuesta = cliente_http.post("/pedidos", json=resumen)
    return respuesta["ok"] is True

35.8 Crear fixtures compartidas

En tests\conftest.py escribe:

import pytest

from tienda.carrito import agregar_producto, crear_carrito
from tienda.productos import crear_producto


@pytest.fixture
def producto_teclado():
    return crear_producto("TEC-001", "Teclado", 30000, 10)


@pytest.fixture
def producto_mouse():
    return crear_producto("MOU-001", "Mouse", 12000, 5)


@pytest.fixture
def carrito_vacio():
    return crear_carrito("Ana")


@pytest.fixture
def carrito_con_productos(carrito_vacio, producto_teclado, producto_mouse):
    agregar_producto(carrito_vacio, producto_teclado, 1)
    agregar_producto(carrito_vacio, producto_mouse, 2)
    return carrito_vacio

Estas fixtures reducen repetición y preparan datos claros para varias pruebas.

35.9 Probar creación de productos

En tests\test_productos.py escribe:

import pytest

from tienda.productos import aplicar_descuento, crear_producto, hay_stock


def test_crear_producto_valido():
    producto = crear_producto("TEC-001", "Teclado", 30000, 10)

    assert producto["codigo"] == "TEC-001"
    assert producto["nombre"] == "Teclado"
    assert producto["precio"] == 30000
    assert producto["stock"] == 10

35.10 Probar validaciones con parametrización

Agrega en tests\test_productos.py:

@pytest.mark.parametrize(
    "codigo, nombre, precio, stock",
    [
        ("", "Teclado", 30000, 10),
        ("TEC-001", "", 30000, 10),
        ("TEC-001", "Teclado", -1, 10),
        ("TEC-001", "Teclado", 30000, -1),
    ],
)
def test_crear_producto_rechaza_datos_invalidos(codigo, nombre, precio, stock):
    with pytest.raises(ValueError):
        crear_producto(codigo, nombre, precio, stock)

Un mismo test cubre varias combinaciones inválidas sin duplicar código.

35.11 Probar descuentos

Agrega pruebas para comportamiento normal y errores:

@pytest.mark.parametrize(
    "porcentaje, esperado",
    [
        (0, 30000),
        (10, 27000),
        (100, 0),
    ],
)
def test_aplicar_descuento(producto_teclado, porcentaje, esperado):
    producto = aplicar_descuento(producto_teclado, porcentaje)

    assert producto["precio"] == esperado


@pytest.mark.parametrize("porcentaje", [-1, 101])
def test_aplicar_descuento_rechaza_porcentaje_invalido(
    producto_teclado, porcentaje
):
    with pytest.raises(ValueError):
        aplicar_descuento(producto_teclado, porcentaje)

35.12 Verificar que el descuento no modifica el original

También conviene probar que la función no cambia el diccionario recibido:

def test_aplicar_descuento_no_modifica_producto_original(producto_teclado):
    producto = aplicar_descuento(producto_teclado, 10)

    assert producto["precio"] == 27000
    assert producto_teclado["precio"] == 30000

35.13 Probar stock

Agrega:

@pytest.mark.parametrize(
    "cantidad, esperado",
    [
        (1, True),
        (10, True),
        (11, False),
    ],
)
def test_hay_stock(producto_teclado, cantidad, esperado):
    assert hay_stock(producto_teclado, cantidad) is esperado

35.14 Probar carrito vacío

En tests\test_carrito.py escribe:

import pytest

from tienda.carrito import (
    agregar_producto,
    calcular_total,
    contar_items,
    crear_carrito,
)


def test_crear_carrito():
    carrito = crear_carrito("Ana")

    assert carrito["cliente"] == "Ana"
    assert carrito["items"] == []


def test_crear_carrito_rechaza_cliente_vacio():
    with pytest.raises(ValueError):
        crear_carrito("")

35.15 Probar agregado de productos

Agrega en tests\test_carrito.py:

def test_agregar_producto(carrito_vacio, producto_teclado):
    agregar_producto(carrito_vacio, producto_teclado, 2)

    assert len(carrito_vacio["items"]) == 1
    assert carrito_vacio["items"][0]["codigo"] == "TEC-001"
    assert carrito_vacio["items"][0]["cantidad"] == 2

35.16 Probar errores al agregar productos

Agrega pruebas para cantidad inválida y falta de stock:

def test_agregar_producto_rechaza_cantidad_cero(
    carrito_vacio, producto_teclado
):
    with pytest.raises(ValueError):
        agregar_producto(carrito_vacio, producto_teclado, 0)


def test_agregar_producto_rechaza_stock_insuficiente(
    carrito_vacio, producto_teclado
):
    with pytest.raises(ValueError):
        agregar_producto(carrito_vacio, producto_teclado, 99)

35.17 Probar total e items

Usa la fixture carrito_con_productos:

def test_calcular_total(carrito_con_productos):
    assert calcular_total(carrito_con_productos) == 54000


def test_contar_items(carrito_con_productos):
    assert contar_items(carrito_con_productos) == 3

El total sale de un teclado de 30000 y dos mouse de 12000.

35.18 Probar generación de resumen

En tests\test_exportador.py escribe:

import json

from tienda.exportador import generar_resumen, guardar_resumen, enviar_resumen


def test_generar_resumen(carrito_con_productos):
    resumen = generar_resumen(carrito_con_productos)

    assert resumen == {
        "cliente": "Ana",
        "cantidad_items": 3,
        "total": 54000,
    }

35.19 Probar escritura de archivo temporal

Agrega una prueba usando tmp_path:

def test_guardar_resumen_crea_archivo(carrito_con_productos, tmp_path):
    ruta = tmp_path / "resumen.json"

    resumen = guardar_resumen(carrito_con_productos, ruta)

    assert ruta.exists()
    assert resumen["total"] == 54000

    contenido = json.loads(ruta.read_text(encoding="utf-8"))
    assert contenido["cliente"] == "Ana"
    assert contenido["cantidad_items"] == 3
    assert contenido["total"] == 54000

tmp_path crea una carpeta temporal aislada para la prueba.

35.20 Probar envío con un fake

Para no llamar a una API real, crea un cliente HTTP falso dentro de la prueba:

class ClienteHttpFake:
    def __init__(self):
        self.url = None
        self.payload = None

    def post(self, url, json):
        self.url = url
        self.payload = json
        return {"ok": True}


def test_enviar_resumen(carrito_con_productos):
    cliente = ClienteHttpFake()

    enviado = enviar_resumen(carrito_con_productos, cliente)

    assert enviado is True
    assert cliente.url == "/pedidos"
    assert cliente.payload["cliente"] == "Ana"
    assert cliente.payload["total"] == 54000

Este fake reemplaza una dependencia externa con un objeto simple y controlado.

35.21 Ejecutar la suite

Ejecuta:

python -m pytest

Deberías ver una salida similar a:

collected 24 items

tests/test_carrito.py .......
tests/test_exportador.py ...
tests/test_productos.py ..............

24 passed

El número exacto puede variar si agregas más pruebas.

35.22 Ejecutar una prueba específica

Durante el desarrollo puedes ejecutar un archivo o una prueba concreta:

python -m pytest tests/test_carrito.py
python -m pytest tests/test_carrito.py::test_calcular_total

35.23 Medir cobertura

Ejecuta:

python -m pytest --cov=tienda --cov-report=term-missing

La cobertura ayuda a detectar módulos o ramas sin pruebas. Recuerda que el porcentaje no reemplaza buenos asserts.

35.24 Agregar cobertura a pytest.ini

Si quieres medir cobertura por defecto, modifica pytest.ini:

[pytest]
pythonpath = src
testpaths = tests
addopts = -ra --cov=tienda --cov-report=term-missing
markers =
    integration: pruebas que usan recursos externos o flujos completos

35.25 Configurar calidad con pyproject.toml

Crea pyproject.toml:

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

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

[tool.ruff.lint]
select = ["E", "F", "I"]
ignore = []

35.26 Ejecutar rutina de calidad

Ejecuta las verificaciones principales:

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

Si necesitas aplicar formato:

python -m black src tests

35.27 Crear un script quality.ps1

Para repetir la rutina en Windows, crea quality.ps1:

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

Luego ejecuta:

.\quality.ps1

35.28 Integrar con tox

Crea un tox.ini si quieres automatizar pruebas y calidad en entornos aislados:

[tox]
envlist = py312, quality
skip_missing_interpreters = true

[testenv]
deps =
    pytest
    pytest-cov
commands =
    python -m pytest --cov=tienda --cov-report=term-missing {posargs}

[testenv:quality]
deps =
    black
    ruff
commands =
    python -m black --check src tests
    python -m ruff check src tests

Ejecuta todos los entornos:

python -m tox

35.29 Qué cubre esta suite

  • Creación de datos válidos.
  • Validaciones y excepciones esperadas.
  • Parametrización de casos similares.
  • Fixtures compartidas con conftest.py.
  • Cálculos de negocio.
  • Escritura de archivos temporales.
  • Aislamiento de dependencias externas con un fake.
  • Cobertura y comandos de calidad.

35.30 Mejoras posibles

Este proyecto es pequeño, pero se puede ampliar:

  • Agregar impuestos y descuentos por categoría.
  • Separar pruebas unitarias e integración con marcadores.
  • Guardar pedidos en una base de datos de prueba.
  • Agregar pruebas para entrada y salida por consola.
  • Crear una integración continua que ejecute calidad y pruebas.

35.31 Comandos usados en este tema

mkdir tienda-testing
cd tienda-testing
python -m pip install pytest pytest-cov black ruff
mkdir src
mkdir src\tienda
mkdir tests
New-Item src\tienda\__init__.py -ItemType File
New-Item src\tienda\productos.py -ItemType File
New-Item src\tienda\carrito.py -ItemType File
New-Item src\tienda\exportador.py -ItemType File
New-Item tests\conftest.py -ItemType File
New-Item tests\test_productos.py -ItemType File
New-Item tests\test_carrito.py -ItemType File
New-Item tests\test_exportador.py -ItemType File
python -m pytest
python -m pytest tests/test_carrito.py
python -m pytest tests/test_carrito.py::test_calcular_total
python -m pytest --cov=tienda --cov-report=term-missing
python -m black --check src tests
python -m ruff check src tests
python -m tox

35.32 Qué debes recordar de este tema

  • Una suite útil combina pruebas simples, enfocadas y repetibles.
  • Las fixtures evitan repetición y mejoran la preparación de datos.
  • La parametrización ayuda a cubrir variantes sin duplicar pruebas.
  • tmp_path permite probar archivos sin tocar carpetas reales del proyecto.
  • Los fakes y mocks ayudan a aislar dependencias externas.
  • Antes de compartir cambios, ejecuta formato, análisis estático y pruebas.

35.33 Conclusión del curso

En este curso recorrimos el proceso completo de testing en Python: preparación del entorno, unittest, pytest, aserciones, fixtures, parametrización, mocks, monkeypatch, archivos temporales, cobertura, configuración, tox y herramientas básicas de calidad.

El objetivo no es escribir muchas pruebas, sino escribir pruebas que ayuden a cambiar el código con confianza. Una suite clara, rápida y mantenible se convierte en una herramienta diaria de trabajo.