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.
Crea una carpeta nueva:
mkdir tienda-testing
cd tienda-testing
Instala las herramientas necesarias:
python -m pip install pytest pytest-cov black ruff
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
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.
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
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"])
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
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.
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
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.
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)
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
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
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("")
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
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)
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.
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,
}
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.
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.
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.
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
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.
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
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 = []
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
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
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
conftest.py.Este proyecto es pequeño, pero se puede ampliar:
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
tmp_path permite probar archivos sin tocar carpetas reales del proyecto.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.