unittest viene incluido con Python, así que hay proyectos que lo usan solo por eso. El problema es que su sintaxis te obliga a escribir clases, definir setUp y tearDown, y usar métodos como self.assertEqual para cada comparación. Funciona, pero es bastante verboso para lo que hace.
pytest va por otro camino: los tests son funciones normales y para comparar valores usas assert directamente.
# unittest
class TestUsuario(unittest.TestCase):
def test_nombre(self):
self.assertEqual(usuario.nombre, "Ana")
# pytest
def test_nombre():
assert usuario.nombre == "Ana"
La diferencia no es solo de estilo. Cuando un test falla, pytest te muestra los valores reales de la expresión completa, no solo un mensaje genérico de "assertion error". Eso facilita mucho el debug sin tener que añadir prints por todas partes.
Estructura básica de un test
pytest detecta automáticamente cualquier función cuyo nombre empiece por test_, así que no hace falta ninguna configuración especial para empezar. Si ejecutas pytest sin argumentos en la raíz del proyecto, descubre y corre todos los tests que encuentra.
Algunos flags que usarás mucho:
pytest tests/test_usuario.py -v: modo verbose, muestra el nombre de cada test mientras se ejecutapytest -k "login": ejecuta solo los tests cuyo nombre contiene "login"pytest -x: para en el primer fallo, sin seguir ejecutando el restopytest --tb=short: tracebacks más cortos, útil cuando tienes muchos fallos a la vez
Para proyectos medianos, lo habitual es tener un directorio tests/ en la raíz con los ficheros organizados por módulo: test_usuario.py, test_pedidos.py, etc.
Fixtures: el corazón de pytest
Las fixtures son funciones que preparan datos o recursos para los tests. Se declaran con el decorador @pytest.fixture y pytest las inyecta automáticamente en los tests que las necesitan, por nombre.
import pytest
from myapp import create_app
@pytest.fixture
def app():
app = create_app({"TESTING": True, "DATABASE": ":memory:"})
return app
@pytest.fixture
def cliente(app):
return app.test_client()
def test_home(cliente):
response = cliente.get("/")
assert response.status_code == 200
El test test_home pide la fixture cliente, que a su vez pide app. pytest resuelve las dependencias solo. No tienes que instanciar nada manualmente ni pasar objetos entre funciones.
Teardown con yield
Si la fixture necesita limpiar recursos después del test, usa yield en vez de return. El código que va después del yield se ejecuta cuando el test termina.
@pytest.fixture
def conexion_db():
conn = conectar_base_datos()
yield conn
conn.close() # esto se ejecuta siempre, aunque el test falle
Scope de fixtures: controlar el ciclo de vida
Por defecto, pytest crea una fixture nueva para cada test y la destruye al terminar. Eso está bien para la mayoría de casos, pero a veces es demasiado caro: si conectar a la base de datos tarda dos segundos y tienes 200 tests, no quieres hacerlo 200 veces.
El parámetro scope controla esto:
scope="function": el valor por defecto. Una instancia por test.scope="module": una sola instancia para todos los tests del fichero.scope="session": una sola instancia en toda la ejecución. Ideal para conexiones a BBDD o para levantar un servidor de pruebas.
@pytest.fixture(scope="session")
def db():
conn = conectar_base_datos()
yield conn
conn.close()
Con scope="session" la conexión se abre una vez y todos los tests la comparten. Ten en cuenta que si un test modifica datos, puede afectar a los siguientes, así que hay que tenerlo en cuenta al diseñar los tests.
@pytest.mark.parametrize: tests parametrizados
Cuando quieres probar la misma función con distintos valores de entrada, en vez de copiar y pegar el test varias veces usas @pytest.mark.parametrize.
@pytest.mark.parametrize("entrada, esperado", [
(1, 2),
(5, 10),
(0, 0),
(-3, -6),
])
def test_doble(entrada, esperado):
assert doble(entrada) == esperado
Esto genera cuatro tests independientes, cada uno con su propio nombre y su propio resultado. Si uno falla, los demás siguen ejecutándose.
Para cubrir combinaciones, puedes apilar decoradores:
@pytest.mark.parametrize("formato", ["json", "xml"])
@pytest.mark.parametrize("idioma", ["es", "en", "fr"])
def test_exportar(formato, idioma):
resultado = exportar(formato=formato, idioma=idioma)
assert resultado is not None
Ese código genera 6 tests (2 formatos × 3 idiomas) sin repetir nada.
conftest.py: fixtures compartidas entre ficheros
Si defines una fixture en conftest.py, está disponible para todos los tests del directorio donde está ese fichero y sus subdirectorios, sin necesidad de importarla. pytest la descubre solo.
La estructura típica en un proyecto mediano:
proyecto/
??? src/
? ??? myapp/
??? tests/
? ??? conftest.py ? fixtures globales (db, cliente, config)
? ??? test_usuarios.py
? ??? test_pedidos.py
? ??? api/
? ??? conftest.py ? fixtures específicas de los tests de API
? ??? test_endpoints.py
El conftest.py de la raíz de tests/ es el sitio natural para la conexión a la base de datos de pruebas, el cliente HTTP y cualquier configuración que necesiten varios módulos.
Tests async con pytest-asyncio
Si tu aplicación usa async/await (FastAPI, Starlette, código con asyncio), los tests también necesitan ser async. Para eso está pytest-asyncio.
pip install pytest-asyncio httpx
Un test básico de un endpoint FastAPI:
import pytest
import httpx
from myapp import app
@pytest.mark.asyncio
async def test_endpoint_usuarios():
async with httpx.AsyncClient(app=app, base_url="http://test") as client:
r = await client.get("/usuarios")
assert r.status_code == 200
assert isinstance(r.json(), list)
Si quieres evitar poner @pytest.mark.asyncio en cada test, añade esto en pyproject.toml:
[tool.pytest.ini_options]
asyncio_mode = "auto"
Con eso, cualquier función async def test_* se trata automáticamente como test asíncrono. Las fixtures async también funcionan igual que las síncronas.
Plugins esenciales en 2026
El ecosistema de plugins de pytest es amplio, pero hay un puñado que aparece en casi todos los proyectos serios:
- pytest-cov: cobertura de código.
pytest --cov=src --cov-report=htmlgenera un informe HTML con qué líneas se están probando y cuáles no. - pytest-asyncio: para tests con
async/await, como hemos visto. - pytest-mock: añade la fixture
mockerpara crear mocks sin tener que importarunittest.mocken cada fichero.mocker.patch("myapp.enviar_email")y listo. - pytest-xdist: ejecuta los tests en paralelo.
pytest -n autousa todos los cores disponibles y puede reducir el tiempo de ejecución a la mitad o más en suites grandes. - pytest-httpx: intercepta peticiones HTTP salientes en los tests para que no se hagan llamadas reales a APIs externas. Muy útil cuando tu código llama a servicios de terceros.
No hace falta instalarlos todos desde el principio. Lo normal es añadirlos según los necesites: pytest-cov casi siempre desde el día uno, pytest-xdist cuando la suite empieza a tardar demasiado.
Cómo encaja pytest en un proyecto real
Una suite de tests bien organizada tiene tres capas: tests unitarios para funciones concretas, tests de integración para verificar que los componentes funcionan juntos y tests de extremo a extremo para los flujos principales de la aplicación.
pytest no te obliga a seguir ninguna estructura concreta, pero tiene sentido separar esas capas en directorios distintos y ejecutarlas por separado según el contexto. Los unitarios en cada push, los de integración en el pipeline de CI, los end-to-end antes de cada despliegue.
Si te interesa profundizar en cómo testear herramientas de línea de comandos, tienes un ejemplo práctico en testing de herramientas CLI en Python: pytest en la práctica. Y si trabajas con agentes o sistemas multi-modelo, el artículo sobre testing de agentes Python en producción explica cómo abordar las partes más complicadas.
Imagen: Pexels / Nemuel Sereti
