Testing en Python con pytest: escribe tests que realmente funcionan

📅 Actualizado en marzo 2026 📊 Nivel: Intermedio ⏱️ 20 min de lectura

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.

Medallón de Sócrates de Atenas, filósofo griego maestro de Platón y fundador del método socrático de cuestionamiento
ἓν οἶδα ὅτι οὐδὲν οἶδα
«Solo sé que no sé nada»
Sócrates de Atenas · Filósofo griego · 470 a.C. – 399 a.C.
La frase más célebre de la filosofía occidental no es una confesión de ignorancia: es el fundamento de un método. Sócrates no afirmaba saber menos que los demás; afirmaba que los demás creían saber lo que en realidad no sabían, y esa ilusión de certeza era la fuente de todos sus errores. El método socrático es puro testing: haces preguntas precisas, provocas contradicciones, obligas al interlocutor a enfrentarse a los casos borde de sus propias afirmaciones. Un test bien escrito hace exactamente eso con tu código: no asume que funciona, lo interroga sistemáticamente. «¿Y si la lista llega vacía? ¿Y si el número es negativo? ¿Y si la API tarda más de lo esperado?» La función que no ha sido cuestionada así no es que funcione: es que todavía no has encontrado el caso en el que falla.

¿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.
Inspector de control de calidad revisando piezas en una línea de producción industrial con instrumentos de medición de precisión
El control de calidad en manufactura no es opcional: nadie compra un coche sin saber que ha pasado por pruebas de choque, emisiones y frenos. En el software, los tests son ese mismo proceso. La diferencia es que en manufactura el control de calidad es el paso final; en desarrollo moderno es continuo, automático, y se ejecuta con cada cambio. Un test que falla no es un problema: es información precisa sobre dónde está el defecto. La ausencia de tests es lo que convierte los bugs en sorpresas de producción. Fuente: Pexels (licencia libre).
📋 pytest vs unittest: Python incluye 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
💡 Nombres descriptivos: 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"
⚠️ Nunca uses 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)
Diagrama del ciclo TDD Red-Green-Refactor, pirámide de tests unitarios, de integración y end-to-end, y anatomía de un test pytest con arrange, act y assert
El mapa conceptual del testing en Python: el ciclo TDD que convierte los tests en guía del diseño, la pirámide que distribuye los esfuerzos correctamente entre tests rápidos y lentos, y la estructura AAA (Arrange-Act-Assert) que hace los tests legibles y mantenibles. Infografía: Ciberaula.

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()
Fichas de dominó en fila cayendo en cascada sobre una superficie de madera, efecto dominó de propagación
El efecto dominó ilustra exactamente por qué importa el aislamiento en los tests: si una función depende de una API externa que falla, ese fallo se propaga y tumba todos los tests que dependen de ella, aunque tu código esté perfectamente escrito. Los mocks son el mecanismo que evita esa cascada: sustituyen las fichas inestables (dependencias externas) por fichas que no caen nunca (objetos controlados), permitiendo que cada test verifique exclusivamente la lógica que le corresponde. Fuente: Pexels (licencia libre).
💡 Parchea donde se usa, no donde se define. Si en 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.
Ficha de referencia rápida de pytest en cuatro columnas: comandos de ejecución, asserts y matchers, fixtures y scope, y markers y cobertura
La referencia completa de pytest en una página: comandos de ejecución con sus flags más útiles, asserts nativos y helpers de pytest, fixtures con sus scopes y el patrón yield para teardown, y markers para clasificar y filtrar tests con cobertura integrada. Ficha de referencia: Ciberaula.

❓ 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.

Depende de tu flujo de trabajo y del tipo de código. TDD (Test-Driven Development) propone escribir el test primero, ver que falla (Red), escribir el mínimo código para que pase (Green) y refactorizar (Refactor). La ventaja es que obliga a pensar en la interfaz pública antes de la implementación: si no sabes cómo vas a testear algo, normalmente es porque el diseño está acoplado o la función hace demasiadas cosas. Sin embargo, TDD puro tiene una curva de aprendizaje alta y puede ser frustrante en exploración inicial. Un enfoque pragmático: escribe tests cuando la lógica empieza a estabilizarse, antes de considerar una función "terminada". El test más valioso es el que escribes justo después de encontrar un bug: reproduce el bug primero, luego corrígelo. Eso garantiza que el bug no vuelva nunca.
La cobertura del 100% es un objetivo peligroso si se convierte en una métrica de vanidad. Puedes tener 100% de cobertura y aun así tener bugs, si tus tests no comprueban los casos borde correctos. La cobertura mide que las líneas se ejecutan, no que el comportamiento sea correcto. Un umbral de 80-85% es razonable para la mayoría de proyectos: cubre la lógica crítica sin obsesionarse con código trivial (getters/setters, configuración). Lo que sí merece cobertura del 100% es la lógica de negocio central: cálculos financieros, reglas de validación, transformaciones de datos. El código de interfaz (CLI, API endpoints) puede tener menos cobertura si hay tests de integración que lo cubren a nivel superior.
unittest es el framework de testing incluido en la librería estándar de Python. Funciona, pero requiere heredar de unittest.TestCase, usar métodos como self.assertEqual() y self.assertRaises(), y hay mucho boilerplate. pytest puede ejecutar los mismos tests de unittest sin cambios, pero su propia sintaxis es mucho más limpia: funciones sueltas, assert nativo de Python, y mensajes de error que muestran los valores reales en lugar de un simple "AssertionError". La diferencia de productividad es real: pytest tiene fixtures más potentes, parametrize integrado, plugins como pytest-cov y pytest-mock, y una salida mucho más legible. El único motivo para preferir unittest hoy es si tu proyecto ya tiene una base grande de tests con unittest y no quieres migrar, o si no puedes instalar dependencias externas.
Un mock es un objeto falso que simula el comportamiento de una dependencia real (una API externa, una base de datos, el sistema de archivos, el reloj del sistema) sin invocarla realmente. Úsalo cuando el test dependería de un recurso externo que: (1) no siempre está disponible (API de terceros, servicios en la nube), (2) tiene efectos secundarios que no quieres en tests (enviar emails reales, escribir en producción), (3) es lento (conexiones de red, consultas pesadas a BD), o (4) su comportamiento es difícil de controlar en tests (fechas actuales, números aleatorios). La regla: si para ejecutar el test necesitas internet, una base de datos real o esperar segundos, probablemente necesitas un mock. Pero no mockees lo que puedes probar directamente: mockear demasiado produce tests que comprueban el mock en lugar del código real.
La convención más extendida es una carpeta tests/ en la raíz del proyecto, con una estructura que espeja el código fuente: si tienes src/utils/calculos.py, el test va en tests/utils/test_calculos.py. Cada archivo de test empieza por test_ y cada función de test también. Para proyectos grandes, separa tests unitarios (tests/unit/) de tests de integración (tests/integration/) y tests end-to-end (tests/e2e/). Usa markers de pytest para poder ejecutarlos selectivamente: @pytest.mark.unit, @pytest.mark.integration, @pytest.mark.slow. En el CI/CD, ejecuta los tests unitarios en cada commit (rápidos, sin dependencias externas) y los de integración en el pipeline de merge o despliegue. Un conftest.py en la raíz de tests/ centraliza las fixtures compartidas por todos los archivos.
Valora este artículo

💬 Foro de discusión

¿Tienes dudas sobre Testing en Python con pytest: escribe tests que realmente funcionan? Comparte tu pregunta con la comunidad.

¿Tienes cuenta? o comenta como invitado ↓

Todavía no hay mensajes. ¡Sé el primero en participar!