W3docs

Pruebas unitarias en Python con pytest

Aprende pytest desde cero: assertions, fixtures, parametrización de tests y organización de suites con conftest.py.

pytest es el framework de pruebas más popular de Python. Permite escribir funciones de test pequeñas y legibles usando sentencias assert simples — sin necesidad de clases repetitivas — y a la vez escala hacia suites complejas con fixtures compartidos, parametrización y plugins.

Este capítulo cubre todo lo que necesitas para probar código Python con pytest: instalación, tu primer test, assertions y excepciones esperadas, fixtures, parametrize, organización de tests con conftest.py, opciones útiles de línea de comandos y los errores más comunes.

¿Por qué pytest?

Python incluye el módulo unittest, entonces ¿por qué usar pytest en su lugar?

Característicaunittestpytest
Sintaxis de testClase + métodoFunción simple
Assertionsself.assertEqual(a, b)assert a == b
FixturessetUp / tearDown@pytest.fixture (componible)
ParametrizarBucle manual@pytest.mark.parametrize
Ecosistema de pluginsMínimoMás de 1 000 plugins (coverage, mock, etc.)

pytest también ejecuta tests estilo unittest sin cambios, por lo que puedes adoptarlo de forma gradual.

Instalación

pytest no forma parte de la biblioteca estándar. Instálalo con pip dentro de un entorno virtual:

python -m venv .venv
source .venv/bin/activate     # Windows: .venv\Scripts\activate
pip install pytest

Verifica la instalación:

pytest --version
# pytest 8.x.x

Consulta Python pip si necesitas repasar la gestión de paquetes.

Tu primer test

pytest descubre los archivos de test automáticamente. Por defecto busca:

  • Archivos llamados test_*.py o *_test.py
  • Funciones cuyos nombres comienzan con test_

Crea math_utils.py con una función sencilla:

# math_utils.py

def add(a, b):
    return a + b

Ahora crea test_math_utils.py en el mismo directorio:

# test_math_utils.py
from math_utils import add

def test_add_positive_numbers():
    assert add(2, 3) == 5

def test_add_negative_numbers():
    assert add(-1, 1) == 0

def test_add_zeros():
    assert add(0, 0) == 0

Ejecuta los tests:

pytest test_math_utils.py

Salida:

collected 3 items

test_math_utils.py ...                                                 [100%]

3 passed in 0.01s

Cada punto representa un test que pasa. Un test fallido muestra F y presenta el diff completo de la assertion.

Assertions

pytest reescribe las sentencias assert simples en el momento de la recolección, de modo que los fallos muestran un diff detallado — sin necesidad de métodos de assertion especiales.

def test_assertion_diff():
    result = [1, 2, 4]
    expected = [1, 2, 3]
    assert result == expected   # pytest shows exactly where lists differ

Una salida de fallo tiene este aspecto:

AssertionError: assert [1, 2, 4] == [1, 2, 3]
  At index 2: 4 != 3

Comparaciones de punto flotante

Nunca compares flotantes con == — los errores de redondeo lo hacen poco fiable. Usa pytest.approx:

import pytest
import math

def circle_area(r):
    return math.pi * r * r

def test_circle_area():
    assert circle_area(5) == pytest.approx(78.53981633974483)

pytest.approx acepta una tolerancia opcional abs o rel:

assert 0.1 + 0.2 == pytest.approx(0.3, abs=1e-9)

Probar excepciones esperadas

Usa pytest.raises como gestor de contexto para afirmar que se lanza una excepción específica:

import pytest

def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

def test_divide_by_zero():
    with pytest.raises(ValueError, match="Cannot divide by zero"):
        divide(10, 0)

El argumento match es una expresión regular que se comprueba contra el mensaje de la excepción. Si la excepción no se lanza, pytest falla el test — garantizando que detectes regresiones donde el manejo de errores se elimina accidentalmente.

Consulta Python Try...Except para una visión más profunda del manejo de excepciones, y Lanzar excepciones para saber cómo lanzarlas intencionalmente.

Parametrize: ejecutar un test con múltiples entradas

@pytest.mark.parametrize permite ejecutar la misma lógica de test contra múltiples conjuntos de datos sin escribir un bucle:

import pytest
from math_utils import add

@pytest.mark.parametrize("a, b, expected", [
    (2,  3,  5),
    (-1, 1,  0),
    (0,  0,  0),
    (10, -5, 5),
])
def test_add(a, b, expected):
    assert add(a, b) == expected

pytest genera un caso de test separado para cada tupla y los reporta individualmente:

test_math_utils.py::test_add[2-3-5] PASSED
test_math_utils.py::test_add[-1-1-0] PASSED
test_math_utils.py::test_add[0-0-0] PASSED
test_math_utils.py::test_add[10--5-5] PASSED

Esto es mucho más limpio que un bucle manual — los fallos individuales quedan aislados y son fáciles de identificar.

Fixtures

Un fixture es una función decorada con @pytest.fixture que proporciona configuración compartida (y desmontaje opcional) para los tests. En lugar de repetir el código de configuración en cada test, declaras el fixture una vez y lo inyectas por nombre como parámetro del test.

Fixture básico

import pytest

class UserStore:
    def __init__(self):
        self.users = []

    def add_user(self, name):
        self.users.append(name)

    def count(self):
        return len(self.users)

@pytest.fixture
def store():
    return UserStore()

def test_empty_store(store):
    assert store.count() == 0

def test_add_user(store):
    store.add_user("Alice")
    assert store.count() == 1

pytest detecta que test_add_user tiene un parámetro llamado store, busca un fixture con ese nombre, lo llama y pasa el resultado. Cada test recibe una instancia nueva del fixture — los cambios en un test nunca se filtran a otro.

Fixtures con desmontaje (yield)

Usa yield dentro de un fixture para dividirlo en configuración (antes del yield) y desmontaje (después del yield). Esto garantiza que la limpieza siempre se ejecuta, incluso si el test falla:

import pytest
import tempfile
import os

@pytest.fixture
def temp_file():
    fd, path = tempfile.mkstemp(suffix=".txt")
    os.close(fd)
    yield path           # test receives the path here
    if os.path.exists(path):
        os.unlink(path)  # always runs after the test

def test_write_to_temp_file(temp_file):
    with open(temp_file, "w") as f:
        f.write("hello")
    with open(temp_file) as f:
        assert f.read() == "hello"

Alcance de los fixtures

Por defecto, los fixtures se crean y desmontan una vez por función de test. Puedes ampliar el alcance para reducir configuraciones costosas:

AlcanceSe crea una vez por
"function" (predeterminado)Cada función de test
"class"Cada clase de test
"module"Cada archivo de test
"session"Toda la ejecución de tests
@pytest.fixture(scope="session")
def database_connection():
    conn = create_db_connection()
    yield conn
    conn.close()

Usa el alcance "session" para recursos costosos como conexiones a bases de datos o procesos de servidor. Usa el alcance "function" (el predeterminado) para cualquier cosa que mute estado.

Fixtures integrados

pytest incluye varios fixtures integrados que puedes usar sin importar nada:

  • tmp_path — un pathlib.Path que apunta a un directorio temporal único para el test.
  • monkeypatch — reemplaza atributos, variables de entorno o entradas de diccionario durante la duración de un test y luego los revierte automáticamente.
  • capsys — captura la salida de stdout / stderr para que puedas hacer assertions sobre el texto impreso.
def greet(name):
    print(f"Hello, {name}!")

def test_greet_output(capsys):
    greet("World")
    captured = capsys.readouterr()
    assert captured.out == "Hello, World!\n"

Uso de monkeypatch

monkeypatch es la forma idiomática de reemplazar dependencias externas en los tests sin una biblioteca de mocking de terceros:

import time

def get_timestamp():
    return time.time()

def test_get_timestamp(monkeypatch):
    monkeypatch.setattr(time, "time", lambda: 1_000_000.0)
    assert get_timestamp() == 1_000_000.0

Después del test, time.time se restaura a su implementación original. Consulta Decoradores de Python si quieres entender cómo funciona @pytest.fixture bajo el capó.

Organizar tests con conftest.py

Cuando un fixture es necesario por tests en múltiples archivos, ponlo en conftest.py. pytest descubre los archivos conftest.py automáticamente y hace que sus fixtures estén disponibles para todos los tests en el mismo directorio y en los subdirectorios — sin necesidad de importarlo.

project/
├── conftest.py          # shared fixtures live here
├── test_users.py
├── test_orders.py
└── utils/
    ├── conftest.py      # fixtures scoped to this subdirectory
    └── test_helpers.py
# conftest.py
import pytest

@pytest.fixture
def admin_user():
    return {"name": "Admin", "role": "admin", "active": True}
# test_users.py  — no import needed; pytest injects admin_user automatically
def test_admin_is_active(admin_user):
    assert admin_user["active"] is True

Tests basados en clases

Puedes agrupar tests relacionados en una clase. A diferencia de unittest.TestCase, las clases de pytest no requieren herencia:

class TestCalculator:
    def test_add(self):
        assert 2 + 2 == 4

    def test_multiply(self):
        assert 3 * 4 == 12

    def test_subtract(self):
        assert 10 - 3 == 7

Las clases son útiles para agrupar tests que comparten una preocupación lógica. Evita las clases cuando la agrupación no tenga un beneficio real — las funciones planas son más simples.

Marks: omitir y etiquetar tests

El sistema de marks de pytest permite anotar tests con metadatos para una ejecución selectiva.

Omitir un test

import pytest
import sys

@pytest.mark.skip(reason="Not implemented yet")
def test_future_feature():
    assert False

@pytest.mark.skipif(sys.platform == "win32", reason="Linux only")
def test_linux_feature():
    assert True

Marks personalizados

Registra marks personalizados en pytest.ini (o pyproject.toml) para etiquetar tests por categoría:

# pytest.ini
[pytest]
markers =
    slow: marks tests as slow (deselect with -m "not slow")
    integration: marks integration tests
@pytest.mark.slow
def test_large_dataset():
    ...

Ejecutar solo los tests lentos:

pytest -m slow

Ejecutar todo excepto los tests lentos:

pytest -m "not slow"

Opciones útiles de línea de comandos

pytest                          # run all discovered tests
pytest test_math_utils.py       # run a specific file
pytest test_math_utils.py::test_add  # run one test by name
pytest -v                       # verbose: show each test name
pytest -x                       # stop on first failure
pytest --tb=short               # shorter traceback (default is long)
pytest -k "add"                 # run tests whose name contains "add"
pytest --lf                     # re-run only last-failing tests
pytest -q                       # quiet: minimal output

Cobertura de tests

Instala el plugin de cobertura para medir qué líneas ejercitan tus tests:

pip install pytest-cov
pytest --cov=math_utils --cov-report=term-missing

La salida agrega una columna de cobertura que muestra qué líneas no fueron ejecutadas:

Name            Stmts   Miss  Cover   Missing
---------------------------------------------
math_utils.py       2      0   100%

Apunta a una cobertura alta en la lógica de negocio crítica, pero no persigas el 100% — probar getters triviales a menudo añade ruido sin valor real.

Errores comunes

1. Fixture no encontrado. Si pytest reporta fixture 'foo' not found, verifica que el fixture esté en conftest.py o en el mismo archivo, y que la función esté decorada con @pytest.fixture.

2. Errores de importación en el momento de la recolección. Si pytest no puede importar tu módulo, falla antes de ejecutar cualquier test. Ejecuta python -c "import your_module" para diagnosticar.

3. Argumentos predeterminados mutables en fixtures. Al igual que las funciones de Python normales, los fixtures deben evitar argumentos predeterminados mutables. Usa el alcance "function" (el predeterminado) para cualquier fixture que construya un objeto mutable.

4. assert en funciones auxiliares. Si llamas a una función auxiliar desde un test y esa función contiene assert, asegúrate de que su nombre comience con assert_ (convención de pytest) para que pytest reescriba la assertion y muestre un mejor mensaje de error.

5. Mezclar unittest.TestCase y fixtures de pytest. pytest ejecuta tests de unittest.TestCase, pero no puedes inyectar fixtures de pytest en métodos de TestCase. Usa clases estilo pytest o los métodos de configuración de unittest — no ambos a la vez.

Práctica

Práctica
Which decorator marks a pytest function as a fixture?
Which decorator marks a pytest function as a fixture?
Práctica
What does pytest.approx() help you do in tests?
What does pytest.approx() help you do in tests?
Práctica
Where should you put fixtures that need to be shared across multiple test files?
Where should you put fixtures that need to be shared across multiple test files?
Was this page helpful?