30. Caso práctico integrador: suite automatizada completa para un proyecto Python

30.1 Objetivo del tema

En este último tema construiremos una suite automatizada completa para un pequeño proyecto Python. La idea es integrar estructura, fixtures, parametrización, datos externos, marcadores, reportes y comandos de ejecución.

El proyecto será simple a propósito: un módulo para calcular descuentos, validar cupones y generar recibos de compra.

Objetivo práctico: terminar el curso con una suite organizada, ejecutable y mantenible, similar a la que se usaría como base en un proyecto real.

30.2 Alcance del caso práctico

Automatizaremos pruebas para estas funcionalidades:

  • Calcular el precio final de un producto con descuento.
  • Validar cupones disponibles.
  • Generar un recibo de compra en texto.
  • Guardar el recibo en un archivo temporal durante la prueba.
  • Ejecutar pruebas por categoría: rápidas, regresión y archivos.
  • Generar reportes de ejecución.

30.3 Estructura final del proyecto

Crea esta estructura de carpetas:

tienda/
├── app/
│   ├── __init__.py
│   ├── descuentos.py
│   ├── cupones.py
│   └── recibos.py
├── tests/
│   ├── data/
│   │   └── descuentos.csv
│   ├── conftest.py
│   ├── test_descuentos.py
│   ├── test_cupones.py
│   └── test_recibos.py
├── pytest.ini
├── pyproject.toml
└── run_tests.py

30.4 Crear el módulo de descuentos

Crea el archivo app/descuentos.py:

def calcular_precio_final(precio, descuento):
    if precio < 0:
        raise ValueError("El precio no puede ser negativo")

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

    return precio - (precio * descuento / 100)

30.5 Crear el módulo de cupones

Crea el archivo app/cupones.py:

CUPONES_VALIDOS = {
    "DESC10": 10,
    "DESC20": 20,
    "ENVIOGRATIS": 0,
}


def normalizar_cupon(codigo):
    return codigo.strip().upper()


def existe_cupon(codigo):
    codigo_normalizado = normalizar_cupon(codigo)
    return codigo_normalizado in CUPONES_VALIDOS


def obtener_descuento(codigo):
    codigo_normalizado = normalizar_cupon(codigo)

    if codigo_normalizado not in CUPONES_VALIDOS:
        raise ValueError("Cupón inválido")

    return CUPONES_VALIDOS[codigo_normalizado]

30.6 Crear el módulo de recibos

Crea el archivo app/recibos.py:

from pathlib import Path


def generar_recibo(nombre_cliente, producto, precio_final):
    return (
        f"Cliente: {nombre_cliente}\n"
        f"Producto: {producto}\n"
        f"Total: {precio_final:.2f}\n"
    )


def guardar_recibo(ruta, contenido):
    ruta = Path(ruta)
    ruta.write_text(contenido, encoding="utf-8")
    return ruta

30.7 Configurar pytest.ini

Crea el archivo pytest.ini para registrar marcadores y opciones comunes:

[pytest]
testpaths = tests
python_files = test_*.py
python_functions = test_*
addopts = -ra
markers =
    rapido: pruebas rápidas de la suite
    regresion: pruebas importantes para detectar regresiones
    archivos: pruebas que trabajan con archivos temporales

30.8 Configurar pyproject.toml

Crea el archivo pyproject.toml con una configuración mínima del proyecto:

[project]
name = "tienda"
version = "1.0.0"
description = "Proyecto de práctica para automatización de pruebas"
requires-python = ">=3.10"

[tool.pytest.ini_options]
minversion = "7.0"

30.9 Crear fixtures compartidas

Crea el archivo tests/conftest.py:

import pytest


@pytest.fixture
def cliente():
    return {
        "nombre": "Ana",
        "email": "ana@example.com",
    }


@pytest.fixture
def producto():
    return {
        "nombre": "Teclado",
        "precio": 100,
    }


@pytest.fixture
def recibo_base(cliente, producto):
    return {
        "cliente": cliente["nombre"],
        "producto": producto["nombre"],
        "total": 90,
    }

30.10 Probar descuentos con parametrización

Crea el archivo tests/test_descuentos.py:

import pytest

from app.descuentos import calcular_precio_final


@pytest.mark.rapido
@pytest.mark.parametrize(
    "precio, descuento, esperado",
    [
        (100, 0, 100),
        (100, 10, 90),
        (100, 100, 0),
        (250, 20, 200),
    ],
    ids=["sin_descuento", "diez_por_ciento", "descuento_total", "precio_mayor"],
)
def test_calcular_precio_final_devuelve_total_esperado(precio, descuento, esperado):
    assert calcular_precio_final(precio, descuento) == esperado


@pytest.mark.regresion
@pytest.mark.parametrize("precio", [-1, -50])
def test_calcular_precio_final_rechaza_precios_negativos(precio):
    with pytest.raises(ValueError, match="precio"):
        calcular_precio_final(precio, 10)


@pytest.mark.regresion
@pytest.mark.parametrize("descuento", [-1, 101])
def test_calcular_precio_final_rechaza_descuentos_invalidos(descuento):
    with pytest.raises(ValueError, match="descuento"):
        calcular_precio_final(100, descuento)

30.11 Probar cupones

Crea el archivo tests/test_cupones.py:

import pytest

from app.cupones import existe_cupon, obtener_descuento


@pytest.mark.rapido
@pytest.mark.parametrize(
    "codigo",
    ["DESC10", "desc10", " DESC10 "],
    ids=["mayusculas", "minusculas", "con_espacios"],
)
def test_existe_cupon_reconoce_codigos_validos(codigo):
    assert existe_cupon(codigo) is True


@pytest.mark.rapido
def test_existe_cupon_devuelve_false_si_el_codigo_no_existe():
    assert existe_cupon("NOEXISTE") is False


@pytest.mark.regresion
def test_obtener_descuento_devuelve_porcentaje_del_cupon():
    assert obtener_descuento("DESC20") == 20


@pytest.mark.regresion
def test_obtener_descuento_lanza_error_si_el_cupon_es_invalido():
    with pytest.raises(ValueError, match="Cupón inválido"):
        obtener_descuento("NOEXISTE")

30.12 Probar recibos y archivos temporales

Crea el archivo tests/test_recibos.py:

import pytest

from app.recibos import generar_recibo, guardar_recibo


@pytest.mark.rapido
def test_generar_recibo_incluye_cliente_producto_y_total(recibo_base):
    recibo = generar_recibo(
        recibo_base["cliente"],
        recibo_base["producto"],
        recibo_base["total"],
    )

    assert "Cliente: Ana" in recibo
    assert "Producto: Teclado" in recibo
    assert "Total: 90.00" in recibo


@pytest.mark.archivos
def test_guardar_recibo_crea_archivo_temporal(tmp_path):
    contenido = generar_recibo("Ana", "Teclado", 90)
    ruta = tmp_path / "recibo.txt"

    archivo = guardar_recibo(ruta, contenido)

    assert archivo.exists()
    assert archivo.read_text(encoding="utf-8") == contenido

30.13 Agregar datos externos desde CSV

Crea el archivo tests/data/descuentos.csv:

precio,descuento,esperado
100,0,100
100,10,90
100,100,0
250,20,200

Luego puedes leer esos datos desde una prueba:

import csv
from pathlib import Path

import pytest

from app.descuentos import calcular_precio_final


def cargar_descuentos():
    ruta = Path(__file__).parent / "data" / "descuentos.csv"

    with ruta.open(encoding="utf-8", newline="") as archivo:
        lector = csv.DictReader(archivo)
        return [
            (float(fila["precio"]), float(fila["descuento"]), float(fila["esperado"]))
            for fila in lector
        ]


@pytest.mark.rapido
@pytest.mark.parametrize("precio, descuento, esperado", cargar_descuentos())
def test_calcular_precio_final_con_datos_csv(precio, descuento, esperado):
    assert calcular_precio_final(precio, descuento) == esperado

30.14 Ejecutar la suite completa

Desde la terminal, en la raíz del proyecto, ejecuta:

python -m pytest

Si todo está correcto, la suite debe finalizar sin errores.

30.15 Ejecutar por marcador

Para ejecutar solo las pruebas rápidas:

python -m pytest -m rapido

Para ejecutar la regresión:

python -m pytest -m regresion

Para ejecutar las pruebas que trabajan con archivos:

python -m pytest -m archivos

30.16 Ejecutar por archivo o expresión

Para ejecutar solo las pruebas de cupones:

python -m pytest tests/test_cupones.py

Para ejecutar pruebas cuyo nombre contiene una palabra:

python -m pytest -k descuento

30.17 Crear un script de ejecución

Crea el archivo run_tests.py:

import subprocess
import sys


COMANDOS = {
    "completa": ["python", "-m", "pytest"],
    "rapida": ["python", "-m", "pytest", "-m", "rapido"],
    "regresion": ["python", "-m", "pytest", "-m", "regresion"],
    "archivos": ["python", "-m", "pytest", "-m", "archivos"],
    "tiempos": ["python", "-m", "pytest", "--durations=10"],
}


def main():
    objetivo = sys.argv[1] if len(sys.argv) > 1 else "completa"

    if objetivo not in COMANDOS:
        print("Objetivo inválido")
        print("Opciones:", ", ".join(COMANDOS))
        return 1

    resultado = subprocess.run(COMANDOS[objetivo], check=False)
    return resultado.returncode


if __name__ == "__main__":
    raise SystemExit(main())

Ejemplos de uso:

python run_tests.py completa
python run_tests.py rapida
python run_tests.py regresion
python run_tests.py tiempos

30.18 Generar reportes

Para obtener un reporte JUnit XML:

python -m pytest --junitxml=reports/resultados.xml

Si instalaste el complemento pytest-html, puedes generar un reporte HTML:

python -m pytest --html=reports/reporte.html --self-contained-html
Guarda los reportes en una carpeta separada para no mezclarlos con el código fuente ni con los datos de prueba.

30.19 Diagnosticar una falla

Cuando una prueba falla, ejecuta primero el archivo más pequeño posible:

python -m pytest tests/test_descuentos.py -vv

Si necesitas ver salidas impresas durante la ejecución:

python -m pytest tests/test_descuentos.py -s

Si quieres revisar las pruebas más lentas:

python -m pytest --durations=10

30.20 Checklist final de la suite

Antes de considerar finalizada la automatización, revisa:

  • La suite completa se ejecuta con python -m pytest.
  • Los nombres de pruebas explican el comportamiento esperado.
  • Las pruebas principales tienen datos representativos.
  • Los casos inválidos están cubiertos.
  • Las fixtures simplifican la preparación sin ocultar demasiado.
  • Los marcadores permiten ejecutar subconjuntos útiles.
  • Los archivos temporales se crean con tmp_path.
  • Los reportes se generan fuera de las carpetas de código.
  • Los comandos frecuentes están documentados o automatizados.

30.21 Ejercicio integrador

Extiende el proyecto con una nueva regla: si el precio final es mayor o igual a 500, el cliente recibe envío gratis.

Implementa una función nueva, por ejemplo calcular_envio(precio_final), y automatiza pruebas para estos escenarios:

  • Precio final menor a 500: el envío cuesta 50.
  • Precio final igual a 500: el envío cuesta 0.
  • Precio final mayor a 500: el envío cuesta 0.
  • Precio final negativo: se lanza un error.

30.22 Posible solución del ejercicio

Una implementación posible en app/descuentos.py:

def calcular_envio(precio_final):
    if precio_final < 0:
        raise ValueError("El precio final no puede ser negativo")

    if precio_final >= 500:
        return 0

    return 50

Y sus pruebas:

import pytest

from app.descuentos import calcular_envio


@pytest.mark.regresion
@pytest.mark.parametrize(
    "precio_final, esperado",
    [
        (499, 50),
        (500, 0),
        (800, 0),
    ],
    ids=["menor_a_quinientos", "igual_a_quinientos", "mayor_a_quinientos"],
)
def test_calcular_envio_devuelve_costo_esperado(precio_final, esperado):
    assert calcular_envio(precio_final) == esperado


@pytest.mark.regresion
def test_calcular_envio_rechaza_precio_final_negativo():
    with pytest.raises(ValueError, match="precio final"):
        calcular_envio(-10)

Ejecuta la regresión:

python -m pytest -m regresion

30.23 Cierre del curso

En este curso construiste una base completa para automatizar pruebas con Python: estructura de proyecto, configuración, ejecución, selección de pruebas, fixtures, parametrización, datos externos, archivos temporales, reportes, diagnóstico y mantenimiento.

El siguiente paso natural es aplicar esta forma de trabajo en proyectos más grandes, combinándola con calidad de código, refactoring, cobertura, pruebas de APIs, pruebas web, CI/CD y rendimiento.