Guía de Pytest en Producción
Dominá pytest para codebases de producción: fixtures avanzados, plugins, markers personalizados, tests parametrizados, ejecución paralela con pytest-xdist e integración con CI.
Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.
Introducción
Pytest es el framework de testing para Python más popular, pero la mayoría de los equipos solo usan lo básico. Los codebases de producción necesitan más que assert statements — necesitan fixtures reusables, markers personalizados para organizar tests, ejecución paralela para velocidad, plugins para funcionalidad compartida e integración con CI que provea feedback accionable. A continuación: todo lo que necesitás para correr pytest a escala: desde fixture factories y patrones de conftest hasta paralelización con xdist y desarrollo de plugins personalizados.
Fundamentos de Fixtures
Fixtures básicos
# tests/conftest.py — Fixtures compartidos
import pytest
from myapp.db import Database
from myapp.models import User
@pytest.fixture
def db():
"""Provee una conexión a la base de datos de test."""
db = Database("postgresql://localhost/test_db")
db.create_tables()
yield db
db.drop_tables()
db.close()
@pytest.fixture
def user(db):
"""Crea un usuario de test en la base de datos."""
return User.create(db, email="test@example.com", name="Test User")
Scopes de fixtures
# tests/conftest.py — Scopes de fixtures controlan el lifecycle
import pytest
@pytest.fixture(scope="function")
def function_scoped():
"""Creado y destruido para cada test (default)."""
return {"count": 0}
@pytest.fixture(scope="class")
def class_scoped():
"""Creado una vez por test class."""
return {"count": 0}
@pytest.fixture(scope="module")
def module_scoped():
"""Creado una vez por module."""
return {"count": 0}
@pytest.fixture(scope="session")
def session_scoped():
"""Creado una vez para toda la test session."""
return {"count": 0}
# Jerarquía de scopes: session > module > class > function
# Fixtures de scope más alto se crean primero y se destruyen último
Fixture factories con closures
# tests/conftest.py — Dynamic fixture factories
import pytest
from myapp.models import Order, Product
@pytest.fixture
def make_order(db):
"""Factory fixture: crea orders con data custom."""
created = []
def _make(**kwargs):
defaults = {
"product": Product.create(db, name="Widget", price=10.00),
"quantity": 1,
"status": "pending",
}
defaults.update(kwargs)
order = Order.create(db, **defaults)
created.append(order)
return order
yield _make
# Cleanup de todas las orders creadas
for order in created:
order.delete(db)
# Usage
def test_order_processing(db, make_order):
order = make_order(quantity=5, status="shipped")
assert order.total == 50.00
def test_multiple_orders(db, make_order):
o1 = make_order(quantity=2)
o2 = make_order(quantity=3)
assert Order.count(db) == 2
Parametrización de fixtures
# tests/test_calculator.py — Parametrizá fixtures
import pytest
@pytest.fixture(params=[
{"input": [1, 2], "expected": 3},
{"input": [0, 0], "expected": 0},
{"input": [-1, 1], "expected": 0},
{"input": [100, 200], "expected": 300},
])
def calc_case(request):
return request.param
def test_add(calc_case):
from myapp.calculator import add
result = add(*calc_case["input"])
assert result == calc_case["expected"]
Cadenas de dependencia entre fixtures
# tests/conftest.py — Fixtures dependiendo de fixtures
import pytest
from myapp.app import create_app
from myapp.db import Database
@pytest.fixture(scope="session")
def db():
db = Database("postgresql://localhost/test_db")
db.create_tables()
yield db
db.drop_tables()
@pytest.fixture(scope="session")
def app(db):
app = create_app(db, testing=True)
yield app
@pytest.fixture(scope="function")
def client(app):
"""Test client para HTTP requests."""
with app.test_client() as client:
yield client
@pytest.fixture(scope="function")
def auth_client(client, db):
"""Test client autenticado."""
from myapp.models import User
user = User.create(db, email="test@example.com", password="hashed")
token = user.generate_token()
client.headers["Authorization"] = f"Bearer {token}"
yield client
user.delete(db)
Markers Personalizados
Markers built-in
# tests/test_api.py — Usando markers built-in
import pytest
@pytest.mark.skip(reason="Not implemented yet")
def test_future_feature():
pass
@pytest.mark.skipif(
sys.platform == "win32",
reason="Unix-only feature"
)
def test_unix_feature():
pass
@pytest.mark.xfail(reason="Known bug #123")
def test_known_bug():
assert buggy_function() == expected
@pytest.mark.slow
def test_large_dataset():
data = load_large_dataset()
assert process(data) is not None
Markers personalizados
# pytest.ini — Registrá markers personalizados
[pytest]
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
integration: marks tests as integration tests
unit: marks tests as unit tests
smoke: marks tests as smoke tests
security: marks tests as security tests
requires_db: marks tests that need a database
requires_redis: marks tests that need Redis
# tests/test_orders.py — Usando markers personalizados
import pytest
@pytest.mark.unit
def test_order_calculation():
assert calculate_total(10, 2) == 20
@pytest.mark.integration
@pytest.mark.requires_db
def test_order_persistence(db, make_order):
order = make_order()
fetched = Order.get(db, order.id)
assert fetched.status == "pending"
@pytest.mark.smoke
def test_api_responds(client):
response = client.get("/health")
assert response.status_code == 200
@pytest.mark.security
def test_sql_injection_prevention(client):
response = client.get("/api/users?id=1; DROP TABLE users")
assert response.status_code == 400
Correr por marker
# Corré solo unit tests
pytest -m unit
# Corré todo excepto slow tests
pytest -m "not slow"
# Corré integration y security tests
pytest -m "integration or security"
# Corré smoke tests solo, pará en el primer failure
pytest -m smoke -x
# Corré tests que necesitan database pero no Redis
pytest -m "requires_db and not requires_redis"
Tests Parametrizados
Parametrización básica
# tests/test_string_utils.py — Tests parametrizados
import pytest
from myapp.utils import normalize_email
@pytest.mark.parametrize("input, expected", [
("User@Example.COM", "user@example.com"),
(" user@example.com ", "user@example.com"),
("USER+tag@example.com", "user+tag@example.com"),
("user.name@example.com", "user.name@example.com"),
("", ""),
(None, None),
])
def test_normalize_email(input, expected):
assert normalize_email(input) == expected
Parametrización con IDs
# tests/test_pagination.py — Casos parametrizados con nombre
import pytest
@pytest.mark.parametrize(
"page, per_page, expected_offset",
[
pytest.param(1, 10, 0, id="first-page"),
pytest.param(2, 10, 10, id="second-page"),
pytest.param(3, 20, 40, id="third-page-larger"),
pytest.param(10, 5, 45, id="tenth-page-small"),
]
)
def test_pagination_offset(page, per_page, expected_offset):
assert calculate_offset(page, per_page) == expected_offset
Parametrización multi-parámetro
# tests/test_validation.py — Parametrización compleja
import pytest
from myapp.validators import validate_password
@pytest.mark.parametrize("password, is_valid, errors_count", [
("Short1!", False, 1),
("NoDigitsHere!", False, 1),
("nouppercase1!", False, 1),
("NOLOWERCASE1!", False, 1),
("ValidPass1!", True, 0),
("AnotherValid123$", True, 0),
])
def test_password_validation(password, is_valid, errors_count):
result = validate_password(password)
assert result.is_valid == is_valid
assert len(result.errors) == errors_count
Parametrizá con fixtures (indirect)
# tests/test_api_endpoints.py — Parametrización indirecta
import pytest
@pytest.fixture
def api_client(request):
"""Creá client con auth level específico."""
auth_level = request.param
client = create_test_client(auth=auth_level)
return client
@pytest.mark.parametrize("api_client", ["admin", "user", "guest"], indirect=True)
def test_get_user_profile(api_client):
response = api_client.get("/api/profile")
assert response.status_code in [200, 403]
Ejecución Paralela con pytest-xdist
Ejecución paralela básica
# Corré tests en paralelo usando todos los CPU cores
pytest -n auto
# Corré con número específico de workers
pytest -n 4
# Corré con load balancing (default es load scope)
pytest -n 4 --dist loadscope
# Corré con loadfile distribution (per-file)
pytest -n 4 --dist loadfile
Fixtures compatibles con paralelización
# tests/conftest.py — Fixtures compatibles con xdist
import pytest
import uuid
from myapp.db import Database
@pytest.fixture(scope="session")
def db_url():
"""Cada worker obtiene su propia database."""
worker_id = os.environ.get("PYTEST_XDIST_WORKER", "gw0")
return f"postgresql://localhost/test_db_{worker_id}"
@pytest.fixture(scope="session")
def db(db_url):
db = Database(db_url)
db.create_tables()
yield db
db.drop_tables()
@pytest.fixture(scope="function")
def unique_email():
"""Generá email único por test para evitar colisiones."""
return f"test-{uuid.uuid4()}@example.com"
Controlar scope paralelo
# tests/conftest.py — Groupéa tests por class para xdist
import pytest
@pytest.fixture(scope="class")
def shared_state():
"""Shared dentro de una class — xdist corre class tests en mismo worker."""
return {"initialized": False}
class TestOrderFlow:
"""Todos los tests en esta class corren en el mismo worker."""
def test_create_order(self, shared_state, client):
shared_state["initialized"] = True
response = client.post("/api/orders", json={"item": "widget"})
assert response.status_code == 201
def test_view_order(self, shared_state, client):
assert shared_state["initialized"]
response = client.get("/api/orders/1")
assert response.status_code == 200
Plugins
conftest.py como plugin local
# tests/conftest.py — Patrones de plugin local
import pytest
import time
from pathlib import Path
@pytest.fixture(autouse=True)
def reset_state(db):
"""Auto-used fixture: corre para cada test sin explicit request."""
db.reset()
yield
db.cleanup()
def pytest_runtest_setup(item):
"""Hook: llamado antes de cada test."""
if "requires_db" in item.keywords:
db = item.funcargs.get("db")
if not db or not db.is_connected():
pytest.skip("Database not available")
def pytest_runtest_makereport(item, call):
"""Hook: capturá resultados de test para reporting."""
if call.when == "call":
outcome = "passed" if call.excinfo is None else "failed"
print(f"\n{item.nodeid}: {outcome}")
@pytest.fixture
def timer():
"""Medí el tiempo de ejecución del test."""
start = time.time()
yield
elapsed = time.time() - start
print(f"\nTest took {elapsed:.3f}s")
Custom assertion rewriting
# tests/conftest.py — Mensajes de assertion personalizados
import pytest
def pytest_assertrepr_compare(op, left, right):
"""Output de comparación de assertion personalizado."""
if op == "==" and isinstance(left, dict) and isinstance(right, dict):
missing = set(right.keys()) - set(left.keys())
if missing:
return [f"Missing keys: {missing}"]
for key in left:
if left[key] != right[key]:
return [
f"Key mismatch for '{key}':",
f" left: {left[key]}",
f" right: {right[key]}",
]
Crear un plugin reutilizable
# mypytestplugin/plugin.py — Plugin de pytest instalable
import pytest
import os
import tempfile
def pytest_addoption(parser):
"""Agregá custom command-line options."""
parser.addoption(
"--env",
action="store",
default="test",
help="Test environment: test, staging, prod"
)
parser.addoption(
"--retry",
action="store",
type=int,
default=0,
help="Number of retries for failed tests"
)
@pytest.fixture(scope="session")
def env(request):
return request.config.getoption("--env")
@pytest.fixture(scope="session")
def retry_count(request):
return request.config.getoption("--retry")
def pytest_configure(config):
"""Registrá markers y configurá."""
config.addinivalue_line("markers", "flaky: test may fail intermittently")
# setup.py o pyproject.toml
# [project.entry-points.pytest11]
# mypytestplugin = "mypytestplugin.plugin"
Organización de Tests
Estructura de directorios
tests/
├── conftest.py # Root conftest: fixtures compartidos
├── unit/
│ ├── conftest.py # Fixtures de unit tests
│ ├── test_models.py
│ ├── test_validators.py
│ └── test_utils.py
├── integration/
│ ├── conftest.py # Fixtures de integration (db, redis)
│ ├── test_api_orders.py
│ ├── test_api_users.py
│ └── test_db_queries.py
├── e2e/
│ ├── conftest.py # Fixtures de E2E (selenium, playwright)
│ ├── test_checkout_flow.py
│ └── test_user_registration.py
└── fixtures/
├── __init__.py
├── factories.py # Test data factories
└── mock_data.py # Static mock data
Test factories
# tests/fixtures/factories.py — Test data factories reusables
import factory
from myapp.models import User, Order, Product
class UserFactory(factory.Factory):
class Meta:
model = User
email = factory.Sequence(lambda n: f"user{n}@example.com")
name = factory.Faker("name")
is_active = True
class ProductFactory(factory.Factory):
class Meta:
model = Product
name = factory.Faker("word")
price = factory.Faker("pydecimal", left_digits=2, right_digits=2, positive=True)
class OrderFactory(factory.Factory):
class Meta:
model = Order
user = factory.SubFactory(UserFactory)
product = factory.SubFactory(ProductFactory)
quantity = 1
status = "pending"
Integración con CI
GitHub Actions
# .github/workflows/tests.yml — Pytest en GitHub Actions
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: test_db
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e ".[test]"
- run: pytest -n auto --cov=myapp --cov-report=xml -m "not slow"
- uses: codecov/codecov-action@v4
with:
file: ./coverage.xml
Configuración de coverage
# .coveragerc — Settings de coverage
[run]
source = myapp
omit =
myapp/__init__.py
myapp/migrations/*
myapp/tests/*
[report]
exclude_lines =
pragma: no cover
def __repr__
raise NotImplementedError
if __name__ == .__main__.:
@abstractmethod
fail_under = 80
show_missing = True
[html]
directory = htmlcov
Configuración de pytest
# pytest.ini — Configuración de pytest para producción
[pytest]
minversion = 8.0
testpaths = tests
addopts =
-v
--strict-markers
--strict-config
--tb=short
--cov=myapp
--cov-report=term-missing
--cov-report=html
--cov-fail-under=80
markers =
slow: marks tests as slow
integration: marks tests as integration tests
unit: marks tests as unit tests
smoke: marks tests as smoke tests
security: marks tests as security tests
requires_db: marks tests that need a database
requires_redis: marks tests that need Redis
flaky: marks tests that may fail intermittently
filterwarnings =
error
ignore::DeprecationWarning:myapp.legacy.*
Patrones Avanzados
Snapshot testing
# tests/test_api_snapshots.py — Snapshot testing con syrupy
import pytest
from myapp.app import create_app
@pytest.fixture
def snapshot_json(snapshot):
"""Custom snapshot fixture para JSON comparison."""
snapshot.snapshot_dir = "tests/snapshots"
return snapshot
def test_user_list(client, snapshot_json):
response = client.get("/api/users")
assert response.json == snapshot_json
# Updateá snapshots: pytest --snapshot-update
Property-based testing con Hypothesis
# tests/test_sort_property.py — Property-based testing
from hypothesis import given, strategies as st, settings
from myapp.sorting import quick_sort
@given(st.lists(st.integers(), min_size=0, max_size=100))
@settings(max_examples=500)
def test_sort_idempotent(items):
"""Sortear dos veces produce el mismo resultado."""
sorted_once = quick_sort(items)
sorted_twice = quick_sort(sorted_once)
assert sorted_once == sorted_twice
@given(st.lists(st.integers(), min_size=1))
def test_sort_preserves_length(items):
assert len(quick_sort(items)) == len(items)
@given(st.lists(st.integers(), min_size=1))
def test_sort_first_element_is_min(items):
sorted_items = quick_sort(items)
assert sorted_items[0] == min(items)
Mockeando servicios externos
# tests/test_external_api.py — Mockeá HTTP calls externos
import pytest
from unittest.mock import patch, Mock
from myapp.services import PaymentService
@patch("myapp.services.requests.post")
def test_payment_success(mock_post, db, make_order):
mock_post.return_value = Mock(
status_code=200,
json=lambda: {"id": "pay_123", "status": "succeeded"}
)
order = make_order(quantity=1)
result = PaymentService.charge(order, amount=10.00)
assert result.status == "succeeded"
mock_post.assert_called_once()
@patch("myapp.services.requests.post")
def test_payment_retry_on_timeout(mock_post, db, make_order):
mock_post.side_effect = [
TimeoutError("Connection timeout"),
Mock(status_code=200, json=lambda: {"status": "succeeded"}),
]
order = make_order()
result = PaymentService.charge(order, amount=10.00)
assert result.status == "succeeded"
assert mock_post.call_count == 2
Debuggeando Tests Fallidos
Flags útiles
# Corré solo los últimos tests que fallaron
pytest --lf
# Corré los tests que fallaron hasta que pasen
pytest --lf --ff
# Entrá al debugger en failure
pytest --pdb
# Mostrá variables locales en failure
pytest --showlocals
# Corré con máxima verbosity
pytest -vv -s
# Pará en el primer failure, mostrá traceback
pytest -x --tb=long
# Corré solo tests que matchean el pattern
pytest -k "test_order"
# Mostrá los 10 tests más lentos
pytest --durations=10
Usando breakpoints
# tests/test_debug.py — Debuggeando con breakpoints
import pytest
def test_complex_logic(db, make_order):
order = make_order(quantity=5)
result = process_order(order)
# Entrá al debugger si la assertion falla
assert result.status == "shipped"
assert result.tracking_number is not None
# Corré con: pytest -s --pdb tests/test_debug.py
# El flag -s deshabilita output capture para que puedas interactuar con pdb
Best Practices
-
For a deeper guide, see Measure Test Coverage with pytest-cov.
-
Usá
conftest.pypara fixtures compartidos — no importeés fixtures across modules -
Mantené unit tests rápidos — mockeá dependencias externas, usá in-memory databases
-
Usá markers consistentemente — habilita selective test runs en CI
-
Parametrizá en vez de copy-pastear — una test function, muchos inputs
-
Seteá
--strict-markers— capturá typos en marker names -
Usá
--cov-fail-underen CI — prevení coverage regression -
Aislá tests — ningún test debería depender de side effects de otro
-
Nombrá tests descriptivamente —
test_order_total_includes_taxnotest_order_1 -
Usá factory fixtures para objetos complejos — evitá long setup blocks en cada test
-
Corré tests rápidos primero — usá
-m "not slow"para feedback rápido en development
Common Mistakes
- Estado compartido entre tests: tests pasan individualmente pero fallan cuando corren juntos. Usá function-scoped fixtures o cleanup explícito.
- Tests que dependen del orden: test B depende de que test A corra primero. Cada test debería ser independiente.
- Over-mocking: mockear todo hace los tests brittle y testea los mocks, no el code. Mockeá solo en boundaries.
- No testing paralelo: test suite tarda 10 minutos porque corre serialmente. Agregá
pytest-xdisty-n auto. - Fixture scope demasiado broad: session-scoped mutable fixtures causan flaky tests. Usá function scope para data mutable.
- Missing
--strict-markers: typos en markers pasan silenciosamente. Siempre habilitá strict mode.
FAQ
¿Cuál es la diferencia entre conftest.py y archivos de test regulares?
Los archivos conftest.py contienen fixtures y hooks compartidos. Pytest los descubre automáticamente sin imports. Poné un conftest.py en cada directorio para scopear fixtures a ese directorio y sus subdirectorios.
¿Cómo corro tests en paralelo con pytest?
Instalá pytest-xdist y corré pytest -n auto. Usá --dist loadscope para groupéar tests por class o module, lo cual reduce el overhead de fixture setup.
¿Cuál es el mejor scope de fixture para database connections?
Usá scope="session" para la database connection (caro de crear) y scope="function" para test data (necesita isolation). Esto te da speed del session scope e isolation del function scope.
¿Cómo skipeo tests condicionalmente?
Usá pytest.mark.skipif con una condición, o usá pytest.skip() dentro de un fixture o test. Para skips que dependen del environment, usá un hook pytest_runtest_setup que checkee markers.
¿Cómo mido test coverage?
Instalá pytest-cov y corré pytest --cov=myapp --cov-report=term-missing. Agregá --cov-fail-under=80 para que CI falle cuando coverage baje de 80%.
Recursos Relacionados
Test-Driven Development (TDD) — A Practical Workflow
Learn TDD step by step: write a failing test, make it pass, refactor. Red-Green-Refactor with real examples in Python, JavaScript, and Java.
GuideSoftware Testing Strategy Guide
A practical guide to building a layered testing strategy with unit, integration, and end-to-end tests.
RecipeAPI Mocking for Testing
Build reliable tests by mocking external APIs with WireMock, MockServer, and MSW to eliminate flakiness and test edge cases.
GuideProperty-Based Testing Guide
Master property-based testing with Hypothesis (Python), fast-check (TypeScript), and QuickCheck principles. Generate test cases automatically, find edge cases, and shrink failures.
GuideTestcontainers: Real Dependencies in Integration Tests
Master Testcontainers for integration testing with real databases, message brokers, and APIs. Covers Java, Python, and Node.js with Docker-based test fixtures.