Testing en Python con pytest: escribe tests que realmente funcionan
- ¿Por qué hacer tests? El caso real, no el teórico
- Tu primer test con pytest
- Asserts: lo que pytest comprueba por ti
- Fixtures: datos de prueba reutilizables
- Parametrize: un test, muchos casos
- Mocking: aislar dependencias externas
- Cobertura de código con pytest-cov
- Organización y buenas prácticas
- Preguntas frecuentes
Hay dos tipos de desarrolladores: los que descubren los bugs en producción, con un cliente enfadado al teléfono, y los que los descubren en local, solos, antes de que el código salga del repositorio. La diferencia entre ambos no es el talento ni la experiencia: es si escriben tests o no. pytest es la herramienta que convierte esa diferencia en un hábito de cinco minutos por función.
En esta lección aprendemos a testear código Python de forma profesional: cómo escribir tests claros que documentan el comportamiento esperado, cómo reutilizar datos de prueba con fixtures, cómo cubrir múltiples casos con una sola función usando parametrize, cómo aislar dependencias externas con mocks y cómo medir qué porcentaje de tu código está realmente probado. Al final del módulo 7, tu código no solo funcionará: lo podrás demostrar.
¿Por qué hacer tests? El caso real, no el teórico
Los libros de programación explican el testing con argumentos abstractos sobre calidad del software y deuda técnica. Eso no convence a nadie. Lo que convence es haberlo vivido: cambias una función para añadir una funcionalidad nueva, aparentemente sin relación con lo anterior, y tres días después te enteras de que rompiste algo que funcionaba desde hace meses. Sin tests, eso pasa constantemente. Con tests, lo descubres en el momento exacto en que ejecutas pytest después del cambio.
Los tests también cumplen una función de documentación que ningún comentario cumple igual: muestran cómo se usa el código, qué entradas acepta, qué devuelve en casos normales y qué hace en casos extremos. Un test bien escrito es el ejemplo de uso más fiable que puede existir, porque es el único que falla visiblemente cuando queda desactualizado.
pip install pytest pytest-cov pytest-mock
# El caso más común que convierte a los escépticos del testing:
def calcular_descuento(precio: float, pct: float) -> float:
"""Devuelve el precio con el descuento aplicado."""
return precio * (1 - pct / 100)
# Seis meses después, alguien "mejora" la función:
def calcular_descuento(precio: float, pct: float) -> float:
"""Devuelve el precio con el descuento aplicado."""
descuento = precio * pct / 100 # ← cambio aparentemente inocente
return precio - descuento # ← mismo resultado... ¿o no?
# Con precios negativos (devoluciones), con pct=0, con pct=100...
# Sin tests, no lo sabes hasta que un cliente recibe una factura rara.
# Con tests, el fallo aparece en el segundo en que guardas el archivo.
unittest en la librería estándar, pero pytest es el estándar de facto en la industria por su sintaxis más limpia, mensajes de error más informativos y ecosistema de plugins. pytest puede ejecutar tests escritos para unittest sin modificarlos, así que aprenderlo no limita tu compatibilidad con proyectos existentes.
Tu primer test con pytest
La convención de pytest es simple: archivos que empiezan por test_ o terminan en _test.py, con funciones que empiezan por test_. Nada de clases, nada de herencia, nada de self. El assert nativo de Python es suficiente.
# ── src/calculadora.py ────────────────────────────────────────
def sumar(a: float, b: float) -> float:
return a + b
def dividir(a: float, b: float) -> float:
if b == 0:
raise ValueError("No se puede dividir entre cero")
return a / b
def es_primo(n: int) -> bool:
if n < 2:
return False
for i in range(2, int(n ** 0.5) + 1):
if n % i == 0:
return False
return True
# ── tests/test_calculadora.py ─────────────────────────────────
from src.calculadora import sumar, dividir, es_primo
import pytest
# ── Test básico: una función, una aserción ────────────────────
def test_sumar_positivos():
resultado = sumar(2, 3)
assert resultado == 5
def test_sumar_negativos():
assert sumar(-1, -1) == -2
def test_sumar_cero():
assert sumar(5, 0) == 5
# ── Testear excepciones con pytest.raises ─────────────────────
def test_dividir_entre_cero():
with pytest.raises(ValueError, match="No se puede dividir"):
dividir(10, 0)
def test_dividir_normal():
assert dividir(10, 2) == 5.0
# ── Floats: no compares con == ────────────────────────────────
def test_dividir_float():
assert dividir(1, 3) == pytest.approx(0.333, rel=1e-3)
# pytest.approx permite tolerancia relativa o absoluta
# assert 0.1 + 0.2 == 0.3 → FALLA por precisión float
# assert 0.1 + 0.2 == pytest.approx(0.3) → pasa
# ── Testear valores booleanos ──────────────────────────────────
def test_es_primo_true():
assert es_primo(7) is True
assert es_primo(13) is True
assert es_primo(2) is True # el único primo par
def test_es_primo_false():
assert es_primo(1) is False # caso borde: 1 no es primo
assert es_primo(0) is False
assert es_primo(9) is False # 9 = 3×3
# ── Ejecutar pytest ───────────────────────────────────────────
pytest # ejecuta todos los tests encontrados
pytest tests/test_calculadora.py # solo un archivo
pytest -v # verbose: muestra el nombre de cada test
pytest -v -k "primo" # solo tests cuyo nombre contiene "primo"
pytest -x # para al primer fallo (fail fast)
pytest --tb=short # traceback corto (más legible en CI)
pytest -q # quiet: solo el resumen final
# Salida típica con -v:
# tests/test_calculadora.py::test_sumar_positivos PASSED
# tests/test_calculadora.py::test_sumar_negativos PASSED
# tests/test_calculadora.py::test_dividir_entre_cero PASSED
# ...
# 8 passed in 0.12s
test_dividir_entre_cero es mejor que test_dividir_2. Cuando un test falla a las 2 de la mañana en un CI, el nombre es lo primero que lees. Que el nombre describa exactamente qué caso se está probando ahorra minutos de diagnóstico en el momento más inoportuno.
Asserts: lo que pytest comprueba por ti
Una de las ventajas más subestimadas de pytest es cómo mejora los mensajes de error de los asserts. Con unittest, un fallo muestra «AssertionError» y poco más. pytest hace introspección del assert: descompone la expresión, evalúa cada parte y muestra los valores reales que no coincidían. Eso convierte la depuración de tests en algo inmediato.
import pytest
# ── Comparaciones básicas ──────────────────────────────────────
assert valor == esperado # igualdad
assert valor != otro # desigualdad
assert valor > 0 # comparación numérica
assert valor is None # identidad (usa is, no ==, para None/True/False)
assert valor is not None
assert valor is True
assert valor is False
# ── Colecciones ────────────────────────────────────────────────
assert "clave" in diccionario
assert elemento in lista
assert elemento not in conjunto
assert len(lista) == 3
# ── Strings ────────────────────────────────────────────────────
assert "error" in mensaje.lower()
assert mensaje.startswith("Error:")
assert respuesta.strip() != ""
# ── Floats: siempre pytest.approx ─────────────────────────────
assert 0.1 + 0.2 == pytest.approx(0.3)
assert 3.14159 == pytest.approx(3.14, abs=0.01) # tolerancia absoluta ±0.01
assert 100.0 == pytest.approx(99.5, rel=0.01) # tolerancia relativa 1%
# ── Excepciones ────────────────────────────────────────────────
with pytest.raises(ValueError):
funcion_que_debe_lanzar_error()
with pytest.raises(ValueError, match="mensaje esperado"):
# match acepta expresiones regulares
dividir(5, 0)
# Capturar la excepción para inspeccionar sus atributos
with pytest.raises(ValueError) as exc_info:
dividir(5, 0)
assert "cero" in str(exc_info.value).lower()
# ── Warnings ───────────────────────────────────────────────────
import warnings
with pytest.warns(DeprecationWarning):
funcion_obsoleta()
# ── pytest.fail(): forzar un fallo con mensaje claro ──────────
def test_con_condicion_compleja():
resultado = calcular_algo()
if resultado is None:
pytest.fail("calcular_algo() devolvió None — debería devolver un dict")
assert resultado["status"] == "ok"
assertEqual o assertRaises fuera de unittest.TestCase. Son métodos de la clase base de unittest; en funciones pytest sueltas no existen. El assert nativo de Python es todo lo que necesitas, y pytest lo potencia con introspección automática.
Fixtures: datos de prueba reutilizables
Una fixture es una función que prepara el estado necesario para un test: crea objetos, inicializa bases de datos de prueba, genera datos de ejemplo, abre conexiones. El decorador @pytest.fixture convierte una función en una fixture que pytest inyecta automáticamente en los tests que la necesitan, simplemente incluyendo su nombre como parámetro.
# ── tests/conftest.py — fixtures compartidas por todos los archivos ──
import pytest
from src.carrito import Carrito
from src.usuario import Usuario
@pytest.fixture
def carrito_vacio():
"""Carrito de compra sin productos."""
return Carrito()
@pytest.fixture
def carrito_con_productos():
"""Carrito con tres productos para tests que necesitan estado inicial."""
c = Carrito()
c.agregar("manzana", precio=1.20, cantidad=3)
c.agregar("pan", precio=0.90, cantidad=2)
c.agregar("leche", precio=1.10, cantidad=1)
return c
@pytest.fixture
def usuario_admin():
return Usuario(nombre="Ana", rol="admin", activo=True)
@pytest.fixture
def usuario_basico():
return Usuario(nombre="Bob", rol="lector", activo=True)
# ── tests/test_carrito.py ─────────────────────────────────────
from src.carrito import Carrito
# pytest inyecta la fixture por nombre — no hay que importarla
def test_carrito_vacio_tiene_cero_items(carrito_vacio):
assert len(carrito_vacio.items) == 0
assert carrito_vacio.total() == 0.0
def test_agregar_producto(carrito_vacio):
carrito_vacio.agregar("manzana", precio=1.20, cantidad=2)
assert len(carrito_vacio.items) == 1
assert carrito_vacio.total() == pytest.approx(2.40)
def test_total_carrito_con_productos(carrito_con_productos):
# 1.20×3 + 0.90×2 + 1.10×1 = 3.60 + 1.80 + 1.10 = 6.50
assert carrito_con_productos.total() == pytest.approx(6.50)
def test_eliminar_producto(carrito_con_productos):
carrito_con_productos.eliminar("pan")
assert len(carrito_con_productos.items) == 2
# ── Fixtures con scope: controlar cuándo se crean y destruyen ──
@pytest.fixture(scope="module") # se crea una vez por módulo (archivo)
def conexion_bd_prueba():
"""BD SQLite en memoria: creada una vez, reutilizada en todos los tests del módulo."""
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE productos (id INT, nombre TEXT, precio REAL)")
conn.execute("INSERT INTO productos VALUES (1, 'manzana', 1.20)")
conn.execute("INSERT INTO productos VALUES (2, 'pan', 0.90)")
conn.commit()
yield conn # ← yield en lugar de return: el código DESPUÉS del yield
conn.close() # se ejecuta como teardown (limpieza) al finalizar el scope
# scope options:
# "function" (default) — nueva instancia por test
# "class" — nueva instancia por clase
# "module" — nueva instancia por archivo
# "session" — nueva instancia por ejecución completa de pytest
def test_consulta_bd(conexion_bd_prueba):
cursor = conexion_bd_prueba.execute("SELECT * FROM productos")
filas = cursor.fetchall()
assert len(filas) == 2
assert filas[0][1] == "manzana"
Parametrize: un test, muchos casos
Repetir la misma lógica de test con valores distintos es código duplicado. @pytest.mark.parametrize permite definir una lista de casos de prueba y ejecuta el test una vez por cada uno, con nombre individual para cada caso. Si alguno falla, sabes exactamente qué combinación de entradas lo causó.
import pytest
from src.calculadora import es_primo, calcular_descuento
from src.validador import validar_email, validar_password
# ── Parametrize básico ─────────────────────────────────────────
@pytest.mark.parametrize("n, esperado", [
(2, True), # el único primo par
(3, True),
(4, False), # 4 = 2×2
(7, True),
(9, False), # 9 = 3×3 — trampa clásica
(11, True),
(1, False), # caso borde: 1 no es primo por definición
(0, False), # caso borde: 0 tampoco
(-5, False), # negativo
])
def test_es_primo(n, esperado):
assert es_primo(n) is esperado
# Genera 9 tests individuales:
# test_es_primo[2-True] PASSED
# test_es_primo[4-False] PASSED
# ...
# ── Parametrize con múltiples parámetros ──────────────────────
@pytest.mark.parametrize("precio, pct, resultado", [
(100.0, 10, 90.0), # caso normal
(100.0, 0, 100.0), # sin descuento
(100.0, 100, 0.0), # descuento total
(50.0, 20, 40.0),
(0.0, 50, 0.0), # precio base cero
])
def test_calcular_descuento(precio, pct, resultado):
assert calcular_descuento(precio, pct) == pytest.approx(resultado)
# ── Parametrize con IDs descriptivos ──────────────────────────
@pytest.mark.parametrize("email, valido", [
("ana@ciberaula.com", True),
("usuario+tag@gmail.com",True),
("@sinusuario.com", False),
("sincoma", False),
("doble@@dominio.com", False),
("", False),
], ids=["email_normal", "con_tag", "sin_usuario", "sin_arroba", "doble_arroba", "vacio"])
def test_validar_email(email, valido):
assert validar_email(email) is valido
# En la salida: test_validar_email[email_normal] en lugar de test_validar_email[ana@...]
# ── Combinando parametrize con fixtures ───────────────────────
@pytest.mark.parametrize("descuento_extra", [0, 5, 10, 15])
def test_descuento_sobre_carrito(carrito_con_productos, descuento_extra):
# carrito_con_productos es una fixture; descuento_extra es parametrize
total_original = carrito_con_productos.total()
total_final = calcular_descuento(total_original, descuento_extra)
assert total_final <= total_original
# ── Casos que deben lanzar excepciones ────────────────────────
@pytest.mark.parametrize("password, exc_esperada", [
("", ValueError), # vacía
("abc", ValueError), # menos de 8 caracteres
("sinmayus1!", ValueError), # sin mayúsculas
])
def test_password_invalida(password, exc_esperada):
with pytest.raises(exc_esperada):
validar_password(password)
Mocking: aislar dependencias externas
Un test unitario debe ser rápido, repetible y sin efectos secundarios. Eso es imposible si tu función hace llamadas a APIs externas, escribe en base de datos o depende del reloj del sistema. El módulo unittest.mock (incluido en la librería estándar) permite sustituir esas dependencias por objetos falsos que controlas tú.
from unittest.mock import Mock, MagicMock, patch, call
import pytest
# ── Mock básico: sustituir un objeto por uno controlado ───────
mock_servicio = Mock()
# Configurar qué devuelve cuando se llama
mock_servicio.obtener_usuario.return_value = {
"id": 1, "nombre": "Ana", "email": "ana@ciberaula.com"
}
mock_servicio.obtener_usuario.side_effect = None # sin excepción
# Llamar al mock
usuario = mock_servicio.obtener_usuario(1)
assert usuario["nombre"] == "Ana"
# Verificar que se llamó correctamente
mock_servicio.obtener_usuario.assert_called_once_with(1)
mock_servicio.obtener_usuario.assert_called_with(1)
assert mock_servicio.obtener_usuario.call_count == 1
# ── patch: sustituir en contexto específico ───────────────────
# El patrón más común: parchear donde SE USA, no donde SE DEFINE
from src.notificador import enviar_bienvenida # usa requests.post internamente
def test_enviar_bienvenida_llama_api():
with patch("src.notificador.requests.post") as mock_post:
mock_post.return_value.status_code = 200
mock_post.return_value.ok = True
resultado = enviar_bienvenida("ana@ciberaula.com")
assert resultado is True
mock_post.assert_called_once()
args, kwargs = mock_post.call_args
assert "ana@ciberaula.com" in str(kwargs.get("json", {}))
# ── patch como decorador ──────────────────────────────────────
@patch("src.clima.requests.get")
def test_obtener_temperatura_madrid(mock_get):
mock_get.return_value.json.return_value = {
"current": {"temperature_2m": 18.5}
}
mock_get.return_value.ok = True
from src.clima import obtener_temperatura
temp = obtener_temperatura("Madrid")
assert temp == 18.5
mock_get.assert_called_once()
# ── side_effect: simular errores o comportamiento variable ────
from unittest.mock import patch
import requests
@patch("src.cliente_api.requests.get")
def test_reintento_en_timeout(mock_get):
from requests.exceptions import Timeout
from src.cliente_api import fetch_con_reintento
# Primera llamada lanza Timeout, segunda tiene éxito
mock_get.side_effect = [
Timeout("servidor tardó demasiado"),
Mock(ok=True, json=lambda: {"data": "ok"}),
]
resultado = fetch_con_reintento("https://api.ejemplo.com/datos")
assert resultado == {"data": "ok"}
assert mock_get.call_count == 2 # se llamó dos veces (1 fallo + 1 éxito)
# ── patch datetime para tests con fechas ─────────────────────
from unittest.mock import patch
import datetime
@patch("src.factura.datetime")
def test_fecha_factura_es_hoy(mock_dt):
fecha_fija = datetime.date(2026, 3, 8)
mock_dt.date.today.return_value = fecha_fija
from src.factura import crear_factura
factura = crear_factura(importe=100.0)
assert factura["fecha"] == "2026-03-08"
# ── pytest-mock: sintaxis más limpia con mocker fixture ───────
# pip install pytest-mock
def test_email_enviado(mocker):
mock_smtp = mocker.patch("src.mailer.smtplib.SMTP")
mock_smtp.return_value.__enter__.return_value.sendmail.return_value = {}
from src.mailer import enviar_email
enviar_email("ana@ciberaula.com", "Asunto", "Cuerpo del mensaje")
mock_smtp.assert_called_once()
src/notificador.py tienes import requests, parchea "src.notificador.requests", no "requests". Python ya ha resuelto el nombre en el módulo donde se importa; si parcheas el módulo original, el código que ya lo importó no ve el parche.
Cobertura de código con pytest-cov
Cobertura (coverage) mide qué porcentaje de las líneas de tu código se ejecutan durante los tests. Una cobertura baja es señal de que hay código que nunca se prueba. Una cobertura alta no garantiza ausencia de bugs, pero sí garantiza que el código se ha ejercitado. Es un indicador, no un objetivo en sí mismo.
pip install pytest-cov
# ── Ejecutar con cobertura ─────────────────────────────────────
pytest --cov=src # cobertura del directorio src/
pytest --cov=src --cov-report=term-missing # muestra las líneas no cubiertas
pytest --cov=src --cov-report=html # genera informe HTML en htmlcov/
pytest --cov=src --cov-fail-under=80 # falla si cobertura < 80%
# Salida típica de --cov-report=term-missing:
# Name Stmts Miss Cover Missing
# -------------------------------------------------------
# src/calculadora.py 18 2 89% 34-35
# src/carrito.py 31 0 100%
# src/validador.py 24 5 79% 41, 45-48
# -------------------------------------------------------
# TOTAL 73 7 90%
# Las líneas "Missing" son las que ningún test ejecuta todavía
# ── setup.cfg o pyproject.toml — configuración persistente ───
# setup.cfg:
[tool:pytest]
addopts = --cov=src --cov-report=term-missing --cov-fail-under=80
testpaths = tests
# pyproject.toml:
[tool.pytest.ini_options]
addopts = "--cov=src --cov-report=term-missing --cov-fail-under=80"
testpaths = ["tests"]
[tool.coverage.run]
omit = ["*/migrations/*", "*/config.py", "src/__init__.py"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover", # línea marcada explícitamente para excluir
"if __name__ == .__main__.:",
"raise NotImplementedError",
]
# ── pragma: no cover — excluir líneas de la cobertura ────────
def funcion_con_codigo_defensivo():
resultado = calcular()
if resultado is None: # pragma: no cover
raise RuntimeError("Esto nunca debería pasar")
return resultado
# Úsalo con moderación: cada pragma es una afirmación de que
# ese código es imposible de alcanzar. Si alguna vez falla en
# producción, habrás demostrado que la afirmación era errónea.
Organización y buenas prácticas
Un suite de tests mal organizado es tan problemático como no tener tests: si tarda demasiado, nadie lo ejecuta antes de hacer commit; si los nombres no son descriptivos, los fallos no aportan información; si hay demasiado acoplamiento entre tests, un cambio pequeño rompe decenas de ellos. La estructura correcta hace que el testing sea una herramienta que acelera el desarrollo, no una carga.
# ── Estructura de proyecto recomendada ────────────────────────
mi_proyecto/
├── src/
│ ├── __init__.py
│ ├── calculadora.py
│ ├── carrito.py
│ └── validador.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py ← fixtures compartidas (scope session/module)
│ ├── unit/
│ │ ├── test_calculadora.py
│ │ ├── test_carrito.py
│ │ └── test_validador.py
│ └── integration/
│ ├── test_api_externa.py
│ └── test_bd.py
├── pyproject.toml ← configuración pytest + coverage
└── .github/workflows/ci.yml ← GitHub Actions: pytest en cada push
# ── Markers: clasificar y filtrar tests ───────────────────────
import pytest
@pytest.mark.unit
def test_sumar():
assert 2 + 2 == 4
@pytest.mark.integration
def test_guardar_en_bd(conexion_bd_prueba):
...
@pytest.mark.slow
def test_procesar_dataset_grande():
...
# Registrar markers en pyproject.toml para evitar warnings:
# [tool.pytest.ini_options]
# markers = [
# "unit: tests unitarios (rápidos, sin dependencias externas)",
# "integration: tests de integración (pueden ser lentos)",
# "slow: tests que tardan más de 1 segundo",
# ]
# Ejecutar solo los rápidos (en cada commit):
# pytest -m "unit and not slow"
# Ejecutar todo (en CI antes de merge):
# pytest -m "unit or integration"
# ── conftest.py: fixtures de sesión y configuración global ────
import pytest
import sqlite3
@pytest.fixture(scope="session")
def bd_en_memoria():
"""Base de datos SQLite compartida en toda la sesión de tests."""
conn = sqlite3.connect(":memory:")
# Crear esquema
conn.executescript("""
CREATE TABLE usuarios (id INT PRIMARY KEY, nombre TEXT, email TEXT);
CREATE TABLE pedidos (id INT, usuario_id INT, importe REAL);
""")
yield conn
conn.close()
@pytest.fixture(autouse=True) # se aplica automáticamente a todos los tests
def limpiar_estado():
"""Resetea cualquier estado global antes de cada test."""
yield
# teardown: limpieza tras cada test
# ── Patrón AAA: Arrange - Act - Assert ────────────────────────
def test_aplicar_cupon_descuento(carrito_con_productos):
# Arrange — preparar el estado inicial
cupon = Cupon(codigo="VERANO20", descuento_pct=20)
# Act — ejecutar la acción bajo prueba
total_con_cupon = carrito_con_productos.aplicar_cupon(cupon)
# Assert — verificar el resultado
total_esperado = carrito_con_productos.total() * 0.80
assert total_con_cupon == pytest.approx(total_esperado)
# Regla: un test, una razón para fallar.
# Si un test tiene tres secciones Assert, probablemente son tres tests distintos.
❓ Preguntas frecuentes
❓ Preguntas frecuentes sobre Testing en Python con pytest: escribe tests que realmente funcionan
Las dudas más comunes respondidas de forma clara y directa.
💬 Foro de discusión
¿Tienes dudas sobre Testing en Python con pytest: escribe tests que realmente funcionan? Comparte tu pregunta con la comunidad.
Todavía no hay mensajes. ¡Sé el primero en participar!