Pruebas unitarias en Python con pytest: guía completa
Las pruebas unitarias verifican que cada función o clase de tu código funcione correctamente de forma aislada. pytest es el framework de testing más popular en Python: más sencillo que unittest, con mejor salida y una enorme cantidad de plugins.
1. Instalación y primera prueba
pip install pytest pytest-cov
# calculadora.py
def sumar(a, b):
return a + b
def dividir(a, b):
if b == 0:
raise ValueError("No se puede dividir entre cero")
return a / b
def es_primo(n):
if n < 2:
return False
for i in range(2, int(n**0.5) + 1):
if n % i == 0:
return False
return True
# test_calculadora.py
from calculadora import sumar, dividir, es_primo
import pytest
def test_sumar_positivos():
assert sumar(2, 3) == 5
def test_sumar_negativos():
assert sumar(-1, -1) == -2
def test_dividir_normal():
assert dividir(10, 2) == 5.0
def test_dividir_por_cero():
with pytest.raises(ValueError, match="No se puede dividir entre cero"):
dividir(10, 0)
def test_es_primo():
assert es_primo(2)
assert es_primo(7)
assert es_primo(13)
assert not es_primo(1)
assert not es_primo(9)
# Ejecutar todas las pruebas
pytest
# Modo verbose
pytest -v
# Solo un archivo
pytest test_calculadora.py
# Solo una función
pytest test_calculadora.py::test_sumar_positivos
2. Parametrize: múltiples casos con un solo test
import pytest
from calculadora import sumar, es_primo
# En vez de escribir un test por cada caso:
@pytest.mark.parametrize("a,b,esperado", [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
(100, 200, 300),
(1.5, 2.5, 4.0),
])
def test_sumar_parametrizado(a, b, esperado):
assert sumar(a, b) == esperado
@pytest.mark.parametrize("n,es_primo_esperado", [
(2, True),
(3, True),
(4, False),
(17, True),
(25, False),
(97, True),
])
def test_es_primo_parametrizado(n, es_primo_esperado):
assert es_primo(n) == es_primo_esperado
3. Fixtures: configuración compartida
import pytest
import tempfile
from pathlib import Path
# Fixture de alcance de función (por defecto)
@pytest.fixture
def archivo_temporal():
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
f.write("línea 1\nlínea 2\nlínea 3\n")
ruta = Path(f.name)
yield ruta # El test recibe la ruta aquí
ruta.unlink() # Limpieza después del test
def test_leer_archivo(archivo_temporal):
contenido = archivo_temporal.read_text()
assert "línea 1" in contenido
assert contenido.count("\n") == 3
# Fixture de alcance de módulo (se crea una vez por archivo)
@pytest.fixture(scope="module")
def conexion_bd():
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE usuarios (id INTEGER PRIMARY KEY, nombre TEXT)")
conn.execute("INSERT INTO usuarios VALUES (1, 'Ana')")
conn.commit()
yield conn
conn.close()
def test_buscar_usuario(conexion_bd):
cursor = conexion_bd.execute("SELECT nombre FROM usuarios WHERE id=1")
fila = cursor.fetchone()
assert fila[0] == "Ana"
4. Mocks con unittest.mock
import pytest
from unittest.mock import patch, MagicMock, call
# Función que depende de una API externa
def obtener_precio_btc(api_client):
respuesta = api_client.get("https://api.example.com/btc")
datos = respuesta.json()
return datos["precio"]
def test_obtener_precio_btc():
# Crear mock del cliente HTTP
mock_cliente = MagicMock()
mock_respuesta = MagicMock()
mock_respuesta.json.return_value = {"precio": 45000.0}
mock_cliente.get.return_value = mock_respuesta
precio = obtener_precio_btc(mock_cliente)
assert precio == 45000.0
mock_cliente.get.assert_called_once_with("https://api.example.com/btc")
# Parchear una función del módulo
import requests
def descargar_datos(url):
r = requests.get(url)
return r.json()
def test_descargar_datos():
with patch("requests.get") as mock_get:
mock_get.return_value.json.return_value = {"resultado": "ok"}
datos = descargar_datos("https://api.example.com/datos")
assert datos["resultado"] == "ok"
mock_get.assert_called_once_with("https://api.example.com/datos")
# Mock de método con side_effect (simular excepciones)
def test_error_de_red():
with patch("requests.get", side_effect=ConnectionError("Sin red")):
with pytest.raises(ConnectionError):
descargar_datos("https://api.example.com")
5. Pruebas de archivos y rutas
import pytest
from pathlib import Path
def procesar_imagen(ruta_entrada, ruta_salida):
"""Convierte a mayúsculas (simulación)."""
contenido = Path(ruta_entrada).read_text()
Path(ruta_salida).write_text(contenido.upper())
def test_procesar_imagen(tmp_path):
# tmp_path: fixture de pytest → carpeta temporal única por test
entrada = tmp_path / "entrada.txt"
salida = tmp_path / "salida.txt"
entrada.write_text("hola mundo")
procesar_imagen(str(entrada), str(salida))
assert salida.exists()
assert salida.read_text() == "HOLA MUNDO"
6. Marcadores y grupos de tests
import pytest
@pytest.mark.lento
def test_operacion_lenta():
import time
time.sleep(2)
assert True
@pytest.mark.skip(reason="Pendiente de implementar")
def test_sin_implementar():
pass
@pytest.mark.skipif(
condition=True,
reason="Solo en Linux"
)
def test_solo_linux():
pass
@pytest.mark.xfail(reason="Bug conocido #123")
def test_con_bug_conocido():
assert 1 == 2 # Falla esperada
# Ejecutar solo tests lentos
pytest -m lento
# Excluir tests lentos
pytest -m "not lento"
# Mostrar tests skipped
pytest -v --tb=short
7. Cobertura de código
# Instalar
pip install pytest-cov
# Ejecutar con cobertura
pytest --cov=mi_modulo --cov-report=term-missing
# Generar informe HTML
pytest --cov=mi_modulo --cov-report=html
# Abre htmlcov/index.html en el navegador
# pytest.ini o setup.cfg
[pytest]
addopts = --cov=mi_app --cov-report=term-missing --cov-fail-under=80
testpaths = tests
8. conftest.py: fixtures compartidas
# tests/conftest.py — se carga automáticamente por pytest
import pytest
from pathlib import Path
@pytest.fixture(scope="session")
def datos_prueba():
"""Cargados una vez para toda la sesión de tests."""
return {
"usuario": {"id": 1, "nombre": "Ana", "email": "ana@ejemplo.com"},
"producto": {"id": 10, "precio": 29.99},
}
@pytest.fixture
def carpeta_temporal(tmp_path):
"""Carpeta temporal con estructura de prueba."""
(tmp_path / "imagenes").mkdir()
(tmp_path / "documentos").mkdir()
(tmp_path / "imagenes" / "foto.jpg").write_bytes(b"FAKE_JPEG")
return tmp_path
9. Pruebas de clases
# procesador.py
class ProcesadorArchivos:
def __init__(self, extension_valida=".txt"):
self.extension_valida = extension_valida
self.archivos_procesados = []
def validar(self, nombre):
return nombre.endswith(self.extension_valida)
def procesar(self, nombre):
if not self.validar(nombre):
raise ValueError(f"Extensión no válida: {nombre}")
self.archivos_procesados.append(nombre)
return f"OK: {nombre}"
# test_procesador.py
import pytest
from procesador import ProcesadorArchivos
class TestProcesadorArchivos:
@pytest.fixture(autouse=True)
def setup(self):
self.proc = ProcesadorArchivos(".txt")
def test_validar_extension_correcta(self):
assert self.proc.validar("documento.txt")
def test_validar_extension_incorrecta(self):
assert not self.proc.validar("imagen.jpg")
def test_procesar_archivo_valido(self):
resultado = self.proc.procesar("nota.txt")
assert resultado == "OK: nota.txt"
assert "nota.txt" in self.proc.archivos_procesados
def test_procesar_archivo_invalido(self):
with pytest.raises(ValueError, match="Extensión no válida"):
self.proc.procesar("imagen.png")
10. Buenas prácticas
- Un assert por test (o varios del mismo concepto) para localizar fallos fácilmente.
- Nombres descriptivos:
test_sumar_devuelve_cero_con_negativos_igualeses mejor quetest1. - Tests independientes: cada test debe poder ejecutarse solo, sin depender del orden.
- No pongas lógica compleja en los tests: si el test es difícil de entender, refactoriza el código.
- Falla rápido: los tests deben ser rápidos; usa mocks para aislar dependencias externas.
- Cobertura ≥ 80% es un buen objetivo; 100% no siempre es práctico ni necesario.
- Ejecuta los tests en CI/CD con cada push para detectar regresiones automáticamente.
Conversiones relacionadas
Conversiones frecuentes del catálogo: