---
name: testing-python
description: Testing Python con pytest. Fixtures, mocking, parametrize, cobertura, factories, testing async y patrones de test doubles. TDD y testing de capas separadas.
version: "1.2.1"
herramientasPermitidas: [Read, Bash]
exclusiones:
  - "No cargar para tests de integración específicos de FastAPI (TestClient, AsyncClient con httpx) — esos tienen fixtures específicos del framework; cargar `fastapi-experto` que incluye la sección de testing con httpx."
  - "No cargar para tests de Django (pytest-django, `@pytest.mark.django_db`, factory-boy para modelos Django) — cargar `django-experto` que cubre el testing con el setup de settings y base de datos."
  - "No cargar para métricas de calidad del código (cobertura, complejidad ciclomática, análisis estático) — esas métricas se cubren en `checklist-calidad`; este skill es para escribir tests, no para analizar su cobertura."
  - "No cargar para evaluar la calidad de un test suite existente de un proyecto — para auditoría de tests cargar `checklist-calidad` o invocar `revisor-codigo-swl`."
evolvable: true  # default para skill estandar
evolved: true
evolved-from: "5.12.3"
evolved-at: "2026-04-25"
evolved-by: "aprender"
evolved-note: "2 gotchas nuevos: chdir vs __dirname y sanitizar-antes-de-truncar"
---
# Testing Python con pytest

## Cuándo NO cargar

- Los tests son de FastAPI con AsyncClient/httpx — cargar `fastapi-experto` para el contexto de fixtures de ese framework.
- Los tests son de Django con pytest-django y `@pytest.mark.django_db` — cargar `django-experto`.
- La tarea es auditar la cobertura existente — cargar `checklist-calidad` para métricas de calidad.

## Principios de testing

- **Un test verifica un solo comportamiento** — no múltiples cosas a la vez.
- **Los tests son documentación** — el nombre del test debe describir QUÉ se prueba.
- **Test doubles solo cuando es necesario** — no mockear lo que puedes usar real.
- **Tests rápidos > tests lentos** — unitarios son milisegundos; de integración, segundos.
- **Cobertura de líneas no es meta** — cobertura de comportamientos sí lo es.

---

## Estructura de proyecto de tests

```
tests/
├── conftest.py           # Fixtures compartidos globalmente
├── unit/                 # Tests unitarios (sin I/O)
│   ├── conftest.py
│   ├── test_factura_service.py
│   └── test_validaciones.py
├── integration/          # Tests con BD real (o en memoria)
│   ├── conftest.py
│   └── test_factura_api.py
└── e2e/                  # Tests end-to-end (opcional)
    └── test_flujo_facturacion.py
```

---

## Nomenclatura de tests

```python
# Patrón: test_<qué>_<condición>_<resultado_esperado>
def test_calcular_iva_monto_positivo_retorna_monto_correcto(): ...
def test_calcular_iva_monto_negativo_lanza_valor_error(): ...
def test_crear_factura_cliente_inactivo_lanza_error_negocio(): ...
```

---

## Fixtures

```python
# conftest.py
import pytest
from decimal import Decimal

@pytest.fixture
def monto_valido() -> Decimal:
    return Decimal("1000.00")

@pytest.fixture
def factura_data() -> dict:
    return {
        "folio": "F-001",
        "fecha": "2026-03-25",
        "subtotal": Decimal("1000.00"),
        "cliente_id": "cliente-123",
    }

# Fixture con scope para BD — se crea una vez por sesión
@pytest.fixture(scope="session")
def engine():
    from sqlalchemy import create_engine
    engine = create_engine("sqlite:///:memory:")
    Base.metadata.create_all(engine)
    yield engine
    Base.metadata.drop_all(engine)

@pytest.fixture
def db(engine):
    from sqlalchemy.orm import Session
    with Session(engine) as session:
        yield session
        session.rollback()  # Limpiar después de cada test
```

---

## Parametrize — probar múltiples casos

```python
import pytest
from decimal import Decimal

@pytest.mark.parametrize("monto,tasa,esperado", [
    (Decimal("100.00"), 0.16, Decimal("16.00")),
    (Decimal("0.00"),   0.16, Decimal("0.00")),
    (Decimal("999.99"), 0.16, Decimal("159.998")),
])
def test_calcular_iva(monto, tasa, esperado):
    resultado = calcular_iva(monto, tasa)
    assert resultado == pytest.approx(float(esperado), rel=1e-4)

# Parametrize con IDs descriptivos
@pytest.mark.parametrize("estatus,puede_cancelar", [
    pytest.param("borrador", True, id="borrador-puede-cancelar"),
    pytest.param("cancelada", False, id="cancelada-ya-cancelada"),
])
def test_factura_puede_cancelar(estatus, puede_cancelar):
    factura = Factura(estatus=estatus)
    assert factura.puede_cancelar() == puede_cancelar
```

---

## Mocking — reglas clave

- Mockear en el boundary: BD, APIs externas, filesystem, reloj.
- NUNCA mockear código propio — señal de acoplamiento excesivo.
- Usar `pytest-mock` (mocker) sobre `unittest.mock.patch` para mayor limpieza.
- `AsyncMock` para funciones async.

Para ejemplos completos de mocking, testing async y factories, ver [recursos/ejemplos-completos.md](recursos/ejemplos-completos.md).

---

## Factories con factory_boy — resumen

Usar factories sobre fixtures hardcodeados. Permiten sobreescribir solo lo relevante:

```python
# tests/factories.py — definir factories centralizados
class FacturaFactory(factory.Factory):
    class Meta:
        model = Factura
    estatus = "borrador"
    cliente = factory.SubFactory(ClienteFactory)

# En el test — solo los datos que importan
factura = FacturaFactory(estatus="pagada", total=Decimal("1000.00"))
```

Para ejemplos completos de factories con factory_boy, ver [recursos/ejemplos-completos.md](recursos/ejemplos-completos.md).

---

## Cobertura de tests

```bash
# Ejecutar con cobertura
pytest --cov=app --cov-report=term-missing --cov-report=html

# Requerir cobertura mínima (falla si no se alcanza)
pytest --cov=app --cov-fail-under=85
```

```toml
# pyproject.toml
[tool.coverage.run]
source = ["app"]
omit = ["app/migrations/*", "app/main.py", "*/tests/*"]

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "def __repr__",
    "if TYPE_CHECKING:",
    "raise NotImplementedError",
]
```

---

## Markers y organización

```python
# Registrar markers en pyproject.toml
# [tool.pytest.ini_options]
# markers = [
#   "slow: tests que tardan más de 1 segundo",
#   "integration: tests que requieren BD o red",
#   "unit: tests puramente unitarios",
# ]

@pytest.mark.slow
@pytest.mark.integration
async def test_proceso_completo_facturacion(): ...

# pytest -m "not integration"   # Ejecutar solo unitarios
# pytest -x                     # Parar al primer fallo
```

---

## Anti-patrones principales

- **Test que verifica demasiado**: un solo test con 10+ asserts sobre comportamientos distintos. Dividir en tests separados.
- **Lógica de negocio en tests**: duplicar if/else del código de producción en el test. Usar valores concretos con parametrize.
- **Sleep en tests**: NUNCA `time.sleep()`. Mockear el reloj con `freezegun`.

Para ejemplos detallados MAL vs BIEN de anti-patrones, ver [recursos/ejemplos-completos.md](recursos/ejemplos-completos.md).

## Gotchas / Errores comunes no obvios

- **`@pytest.fixture(scope="session")` con base de datos SQLAlchemy falla cuando un test modifica datos y el siguiente test los asume en el estado original**: el scope `session` significa que el fixture se crea una vez para toda la sesión de tests — si un test modifica la BD, los tests posteriores ven los datos modificados. Causa: `scope="session"` no hace rollback entre tests, a diferencia de `scope="function"`. Solución: usar `scope="function"` (default) para fixtures de BD que necesitan aislamiento, o envolver cada test en una transacción que se revierte con `db.rollback()` en el teardown del fixture.
- **`mock.patch` parcheado en el módulo de tests en lugar de en el módulo que lo usa**: el mock no tiene efecto porque la función ya fue importada en el módulo objetivo antes del patch. Causa: `mock.patch("tests.test_factura.calcular_iva")` parchea la referencia en el módulo de tests, pero `factura_service.py` ya importó `calcular_iva` directamente y sigue usando la original. Solución: patchear siempre en el lugar donde se usa la función: `mock.patch("factura_service.calcular_iva")` — el destino del patch debe ser la ruta del módulo que importó la función, no donde está definida.
- **`pytest-asyncio` marca el test como `async def` y pasa, pero el `await` dentro no se ejecuta**: el test parece correr sin errores pero la coroutine interna nunca se ejecuta. Causa: sin `@pytest.mark.asyncio` o sin `asyncio_mode = "auto"` en pytest.ini, pytest ejecuta la función async como síncrona — la coroutine se crea y se descarta sin ejecutar. Solución: agregar `@pytest.mark.asyncio` al test o configurar `asyncio_mode = "auto"` en `pytest.ini`; verificar con `pytest --tb=short -v` que el test no termina instantáneamente.
- **Factory Boy `SubFactory` genera objetos nuevos en cada test aunque el fixture del objeto padre ya existe**: la factory crea una instancia nueva del modelo relacionado en la BD aunque ya exista el objeto padre en el test. Causa: `factory.SubFactory(ClienteFactory)` siempre instancia un nuevo `Cliente` — no reutiliza el fixture del test. Solución: pasar el objeto padre existente al instanciar la factory: `FacturaFactory(cliente=cliente_existente)` — la factory sobreescribe el campo `cliente` con el objeto ya creado en lugar de crear uno nuevo.
- **`os.chdir()` (Python) o `process.chdir()` (Node) en tests no afecta módulos cargados con paths relativos basados en `__dirname`/`__file__`**: si un módulo calcula su ruta de datos al cargar con `path.resolve(__dirname, ...)` o `Path(__file__).parent`, los tests no pueden redirigir esa ruta cambiando el cwd — el path se evaluó al `require`/`import` y queda fijado. Caso real: test que cambia `process.chdir(tmpDir)` antes de llamar funciones que escriben a `.planning/evolucion/nudges.jsonl` pero `RUTA_NUDGES = path.resolve(__dirname, '..', '..', '.planning', ...)` apunta al proyecto real. Solución: dos opciones: (1) test de integración con backup/restore del archivo real (más simple cuando son pocos tests), o (2) refactor del módulo para aceptar override de ruta vía parámetro o variable de entorno (preferible si el módulo es muy testeable). Aplica también a Python con `pathlib.Path(__file__).parent`.
- **Sanitizar antes de truncar invalida assertions de longitud en tests**: un test que verifica `truncar('a'.repeat(300), 100).length === 100` falla porque `'a'.repeat(300)` matchea la regex de redact `\b[A-Za-z0-9_-]{32,}\b` y la función sanitiza primero produciendo `[REDACTED]` (10 chars) que no se trunca. Causa: el orden `sanitizar → truncar` reduce el texto antes de que truncar opere. Solución en tests: usar fixtures que NO triggeren patrones de redact (ej: texto con espacios cada N chars como `'palabra corta '.repeat(N)`); separar tests de sanitización y truncado en casos disjuntos. NO modificar la función para reordenar — sanitizar antes es correcto en producción.

## Refactorizar parsers: fixtures multi-formato ANTES del cambio

### SIEMPRE: tener fixtures de cada formato soportado antes de modificar un parser

**Cuándo aplicar**: antes de cambiar un regex, gramática o heurística que ya pasa tests para un formato A, y se quiere extender para cubrir un formato B distinto (ej. otro convertidor produce markdown con artefactos diferentes, u otro proveedor genera JSON con shape alternativa).

**Problema que previene**: al hacer un regex "más permisivo" para aceptar el formato B, es frecuente romper silenciosamente el formato A porque el match se solapa o el grupo captura la estructura equivocada. Sin un fixture explícito de A, la regresión no se detecta hasta producción.

**Regla operativa**:

1. Crear `tests/fixtures/[dominio]/[nombre]-[formato].ext` para CADA formato conocido **antes** de tocar el parser.
2. Escribir tests de conteo/identidad para AMBOS formatos (ej: "produce exactamente 13 IDs canónicos") ANTES del cambio.
3. Modificar el regex/parser.
4. Verificar que AMBOS tests siguen verdes. Si uno se rompe, revertir y acotar más el cambio.

```python
# BIEN — fixtures explícitos de cada formato soportado, test antes del fix
FIXTURE_CANONICO = Path("tests/fixtures/cedulas/cedula-formato-v1.md")
FIXTURE_REEXTRAIDO = Path("tests/fixtures/cedulas/cedula-formato-v2.md")

@pytest.mark.parametrize("fixture", [FIXTURE_CANONICO, FIXTURE_REEXTRAIDO])
def test_parser_produce_13_ids_canonicos(fixture):
    texto = fixture.read_text(encoding="utf-8")
    ids = [h.get("id") for h in extraer(texto)]
    assert len(ids) == 13
    assert "X.X.x" not in ids  # no debe caer al fallback legacy
```

**Beneficio medible**: tener fixtures explícitos de cada formato soportado permite aplicar un fix en un modo secundario sin romper el modo primario. Caso real: al extender un parser de markdown para un segundo convertidor con artefactos distintos, 31 tests nuevos pasaron con 0 regresión en 24 tests previos.

**Relacionado**: patrón "characterization test" de Michael Feathers — capturar el comportamiento actual como fixture byte-exact antes de refactorizar.



---

## Tests de idempotencia requieren 2 ejecuciones + diff del estado

### Regla

Para cualquier pipeline **resumable**, **reentrante** o **idempotente por diseño**
(walkers que marcan estado en cada paso, workers que dedupean por clave, jobs que
continúan donde se interrumpieron), el test unitario que pasa con 1 ejecución es
insuficiente. Se necesitan **2 ejecuciones consecutivas del mismo input** y un
assert sobre el **diff del estado**.

### Por qué

El bug más frecuente en pipelines resumables es que el dedupe solo considera
estado terminal (`estado == "ok"`), ignorando estados intermedios (`descubierto`,
`en_proceso`). En la segunda corrida, los ítems en estado intermedio se duplican
aunque el walker "sabe" que ya los vio. Este bug **nunca aparece** en tests
unitarios que solo verifican una corrida — se necesita la corrida N+1 para
observar la duplicación.

### Patrón canónico

```python
def test_walker_resumable_no_duplica_en_corridas_sucesivas(tmp_path):
    # Arrange — dataset con 100 archivos
    fuente = crear_fuente_con_100_archivos(tmp_path)
    manifest = tmp_path / "manifest.jsonl"

    # Act corrida 1
    walker = Walker(fuente=fuente, manifest=manifest)
    walker.ejecutar()
    manifest_despues_1 = manifest.read_text().splitlines()

    # Act corrida 2 (re-ejecución completa, sin reset)
    walker2 = Walker(fuente=fuente, manifest=manifest)
    walker2.ejecutar()
    manifest_despues_2 = manifest.read_text().splitlines()

    # Assert 1: la segunda corrida NO agrega entradas duplicadas
    diff = len(manifest_despues_2) - len(manifest_despues_1)
    assert diff == 0, (
        f"Corrida 2 agregó {diff} entradas. El dedupe está ignorando "
        f"algún estado intermedio. Manifest antes={len(manifest_despues_1)}, "
        f"después={len(manifest_despues_2)}."
    )

    # Assert 2: todas las entradas son únicas por su clave de dedupe (SHA)
    shas = [json.loads(l)["sha256"] for l in manifest_despues_2]
    assert len(shas) == len(set(shas)), "Hay SHAs duplicados en manifest"

    # Assert 3: el count final coincide con el dataset fuente
    assert len(manifest_despues_2) == 100
```

### Reglas

- **Dos corridas exactas** — mismo input, mismo código, diferente momento. No resetear estado entre corridas; eso simula el caso "pipeline interrumpido y retomado".
- **Assert sobre el DIFF**, no solo sobre el estado final. Un test que solo valida `len == 100` pasa aunque internamente haya 100 buenos + 50 duplicados si el dedupe corre al final.
- **Interrumpir una corrida a mitad** como variante avanzada: matar el proceso en un estado intermedio (`descubierto`, `en_proceso`) y verificar que la corrida 2 continúa sin duplicar ni perder items.
- **Dedupear por clave de contenido** (SHA256) no por clave secundaria (nombre, path, id secuencial) — ver también `patrones-python` "Caché por SHA256 en filesystem para idempotencia de pipelines costosos".
- **NO confiar en tests con mock del storage**: los bugs de idempotencia se manifiestan solo con I/O real al filesystem o BD. Usar `tmp_path` o base de datos in-memory, pero nunca mock del walker mismo.

### Anti-patrón

```python
# MAL — una sola corrida; el bug de dedupe parcial pasa desapercibido
def test_walker_procesa_100_archivos(tmp_path):
    walker = Walker(fuente=crear_100_archivos(tmp_path))
    walker.ejecutar()
    assert len(walker.manifest) == 100   # pasa aunque dedupe solo cubra estado "ok"
```

### Aplicabilidad

- Walkers de filesystem que marcan progreso en un manifest
- Workers con dead-letter queue que reintentan mensajes fallidos
- ETL con checkpoints parciales
- Migradores de datos con strategy `upsert` o `insert or ignore`
- Cualquier job que tolere interrupción y reanudación
