---
name: backend-async-postgres-testing
description: >
  Testing de código backend Python que usa asyncpg con context managers
  asíncronos (`async with conn.transaction()`). Cubre el gotcha crítico de
  AsyncMock que NO genera __aenter__/__aexit__ automáticamente, fixtures
  reusables para mock_conn, testing de race conditions con SELECT FOR UPDATE,
  y patrón de testing para servicios que envuelven INSERTs/UPDATEs en
  transactions. Cargar cuando se escriban tests de services que usan
  `async with conn.transaction():`, cuando un test "deba pasar" pero falla
  con `TypeError: object MagicMock is not async iterable`, o cuando se
  agregue transaction wrapping a un service que tenía tests previos rotos.
version: "1.0.0"
herramientasPermitidas: [Read, Grep]
exclusiones:
  - "No cargar para testing de SQLAlchemy async (AsyncSession) — esos usan `async with session.begin()` con un patrón distinto; cargar `fastapi-experto` que cubre el patrón con sessionmaker."
  - "No cargar para testing de endpoints HTTP — el cliente httpx no requiere mock de transaction; cargar `testing-python` o `fastapi-experto`."
  - "No cargar para testing de capa externa (S3, HTTP client) — esos requieren mock distinto (respx, moto); este skill es específico para BD asyncpg."
  - "No cargar para testing E2E con Postgres real — si la suite levanta BD con testcontainers o docker-compose, NO se mockea transaction; cargar `testing-python`."
evolvable: true
---

# Backend Async Postgres Testing

Patrones de mock para código que usa `async with conn.transaction()` de asyncpg.

## Cuándo NO cargar

- El stack es SQLAlchemy async (`AsyncSession`), no asyncpg raw — cargar `fastapi-experto`.
- Los tests son E2E con BD real (testcontainers, docker-compose con Postgres) — NO se mockea, cargar `testing-python`.
- El código no envuelve queries en `async with conn.transaction():` — los mocks simples de `AsyncMock` bastan.
- Tests de capa externa (S3, HTTP) — esos usan respx/moto, no este patrón.

## El gotcha: AsyncMock NO genera context managers async automáticamente

SIGM L-157 (2026-05-20). Tras introducir `async with conn.transaction():` en `service.py` para envolver SELECT-then-INSERT y prevenir race conditions, 3 tests existentes rompieron:

```
TypeError: 'AsyncMock' object does not support the asynchronous context manager protocol
```

El fixture original era:

```python
# MAL — AsyncMock NO implementa __aenter__/__aexit__
@pytest.fixture
def _mock_conn():
    conn = AsyncMock()
    conn.fetchrow = AsyncMock(return_value={"id": 1})
    conn.execute = AsyncMock(return_value="UPDATE 1")
    return conn
```

Al ejecutar:

```python
async with conn.transaction():   # ← falla aquí
    await conn.execute(...)
```

`AsyncMock()` retorna un MagicMock cuando se llama, pero ese MagicMock NO es un context manager async — falta `__aenter__` y `__aexit__`.

## Patrón correcto: configurar context manager explícitamente

```python
from unittest.mock import AsyncMock, MagicMock

@pytest.fixture
def _mock_conn():
    """Mock de asyncpg.Connection con transaction() configurado."""
    conn = AsyncMock()

    # Configurar transaction() para que retorne un context manager async
    transaction_cm = AsyncMock()
    transaction_cm.__aenter__ = AsyncMock(return_value=transaction_cm)
    transaction_cm.__aexit__ = AsyncMock(return_value=None)
    conn.transaction = MagicMock(return_value=transaction_cm)
    #     ↑ MagicMock — porque transaction() es sync (retorna el CM), no async

    # Métodos comunes pre-configurados
    conn.fetchrow = AsyncMock(return_value=None)
    conn.fetch = AsyncMock(return_value=[])
    conn.execute = AsyncMock(return_value="UPDATE 0")
    conn.executemany = AsyncMock(return_value=None)

    return conn
```

### Por qué `transaction` es `MagicMock` y no `AsyncMock`

`conn.transaction()` es una llamada sync que retorna un objeto context manager async. La firma real de asyncpg:

```python
def transaction(self, *, isolation: str = None, ...) -> Transaction:
    """Retorna un objeto Transaction (sync return)."""
```

Si pones `conn.transaction = AsyncMock(...)`, entonces `conn.transaction(...)` retorna un coroutine — y `async with <coroutine>` falla con `TypeError: 'coroutine' object does not support the asynchronous context manager protocol`.

Regla:
- `transaction` (el método): `MagicMock` (sync return).
- El objeto retornado por `transaction()`: `AsyncMock` con `__aenter__` y `__aexit__` configurados.

### Helper reutilizable

Para evitar repetir el setup en cada test, extraer a helper:

```python
# tests/_helpers/asyncpg_mocks.py
from unittest.mock import AsyncMock, MagicMock

def crear_mock_conn(*, fetchrow=None, fetch=None, execute_tag="UPDATE 0") -> AsyncMock:
    """Mock de asyncpg.Connection con transaction() configurado."""
    conn = AsyncMock()

    transaction_cm = AsyncMock()
    transaction_cm.__aenter__ = AsyncMock(return_value=transaction_cm)
    transaction_cm.__aexit__ = AsyncMock(return_value=None)
    conn.transaction = MagicMock(return_value=transaction_cm)

    conn.fetchrow = AsyncMock(return_value=fetchrow)
    conn.fetch = AsyncMock(return_value=fetch or [])
    conn.execute = AsyncMock(return_value=execute_tag)
    return conn
```

Uso:

```python
async def test_crear_valuacion_vigente(monkeypatch):
    conn = crear_mock_conn(
        fetchrow={"id": uuid.uuid4(), "es_vigente": False},
        execute_tag="UPDATE 1",
    )
    service = ValuacionService(conn=conn)
    await service.crear_valuacion_vigente(predio_id, ejercicio, valor)

    # Verificar que se entró a la transaction
    conn.transaction.assert_called_once()
    # Verificar que se actualizó la fila vigente previa
    conn.execute.assert_any_call(
        "UPDATE valuacion SET es_vigente=false WHERE predio_id=$1 AND ejercicio=$2",
        predio_id, ejercicio,
    )
```

## Testing de race conditions con SELECT FOR UPDATE

Cuando el código real usa `SELECT FOR UPDATE`, el mock debe simular el lock acquired retornando el valor "lockeado" en el primer `fetchrow`:

```python
async def test_crear_valuacion_concurrente_serializada():
    """Simula 2 peticiones concurrentes: la 2da debe esperar al lock."""
    conn = crear_mock_conn()

    # 1ra petición ve la fila vacía
    conn.fetchrow.side_effect = [
        None,                                  # 1er SELECT FOR UPDATE: no hay vigente
        {"id": uuid.uuid4(), "version": 1},    # 2do SELECT FOR UPDATE: ya hay vigente (la 1ra creó)
    ]
    conn.execute.return_value = "INSERT 0 1"

    service = ValuacionService(conn=conn)
    await service.crear_valuacion_vigente(predio_id=p, ejercicio=2026, valor=100)
    await service.crear_valuacion_vigente(predio_id=p, ejercicio=2026, valor=200)

    # Solo el primer INSERT debe haberse ejecutado
    inserts = [call for call in conn.execute.call_args_list if "INSERT" in str(call)]
    assert len(inserts) == 1
```

Esta simulación NO valida que el lock realmente serialize en BD — eso requiere test E2E con Postgres real. Pero SÍ valida que el código de aplicación maneja correctamente el caso "la fila ya existe" cuando emerge del lock.

## Verificar la entrada a transaction

Cuando es importante verificar que el código envuelve correctamente las queries en `async with conn.transaction():`, agregar aserción:

```python
async def test_actualizar_estatus_dentro_de_transaction():
    conn = crear_mock_conn(fetchrow={"id": 1, "estatus": "PENDIENTE"})
    service = TramiteService(conn=conn)
    await service.aprobar_tramite(tramite_id=1)

    # Verificar que SELECT y UPDATE están dentro de la misma transaction
    conn.transaction.assert_called_once()
    # __aenter__ debe haberse llamado antes del primer fetchrow
    transaction_cm = conn.transaction.return_value
    assert transaction_cm.__aenter__.called
    assert transaction_cm.__aexit__.called
```

Si el código olvida envolver en transaction, `conn.transaction.assert_called_once()` falla — el test detecta el bug.

## Anti-patrones

- **Usar `AsyncMock` para `conn.transaction`** — retorna coroutine que no es context manager. Fix: `MagicMock(return_value=AsyncMock_con_aenter_aexit)`.
- **Compartir `_mock_conn` entre tests sin reset de side_effect** — un test configura `fetchrow.side_effect = [...]` y el siguiente recibe el iterador agotado. Fix: `@pytest.fixture` recrea conn por test (scope `function`, default).
- **Mockear `fetchrow.return_value = dict` cuando el código real esperaba un `Record`** — los `Record` de asyncpg permiten acceso por índice (`row[0]`) Y por nombre (`row["id"]`). El dict solo permite por nombre. Si el código real usa `row[0]`, el mock con dict falla con `KeyError: 0`. Fix: si el código usa acceso posicional, mockear con un objeto que soporte ambos accesos, o cambiar el código a acceso por nombre.
- **Olvidar mockear `__aexit__` con `return_value=None`** — si retorna otra cosa, la transaction parece haber fallado y el código puede invocar lógica de rollback que no esperabas. Fix: `__aexit__ = AsyncMock(return_value=None)` explícito.

## Gotchas / Errores comunes no obvios

- **`conn.transaction()` con `isolation=` o `readonly=` kwargs no se valida en mock**: el mock acepta cualquier kwarg. Si el código real espera `isolation="serializable"` y se cambia a `isolation="read committed"`, el test pasa pero el comportamiento cambia. Fix: aserción explícita `conn.transaction.assert_called_with(isolation="serializable")`.
- **`AsyncMock(side_effect=Exception)` dentro de transaction provoca llamada a `__aexit__` con exc_type/exc/tb**: para simular rollback, `__aexit__` recibe la excepción como args. Si tu mock tiene `__aexit__ = AsyncMock(return_value=None)`, la excepción se re-eleva. Si retorna `True`, la excepción se suprime (comportamiento normal de context manager). Fix: para tests de rollback, dejar `return_value=None` (deja que la excepción propague) y verificar con `pytest.raises(...)`.
- **`fetchrow.side_effect = [row, row]` se agota tras 2 llamadas**: en la 3ra llamada lanza `StopIteration`. Si el código real hace 3 fetchrows en distintas ramas, el test falla con error críptico. Fix: usar lista con suficientes elementos o `side_effect` callable que retorna según args (`lambda *args: row if args[0] == "SELECT..." else None`).
- **`MagicMock` (no `AsyncMock`) en `conn.transaction` permite `assert_called_with(*args)` natural** — `AsyncMock` también lo soporta, pero produce warnings de "coroutine never awaited" si el mock se invoca incorrectamente. La distinción importa para diagnóstico, no para corrección.
- **`pytest-asyncio` con `mode="auto"` puede no marcar el test como async si no detecta `async def`**: si el fixture es async pero el test es sync, el fixture nunca se await. Fix: explícito `@pytest.mark.asyncio` en el test, o usar `asyncio_mode = "auto"` en `pyproject.toml` y verificar que el test es `async def`.

## Referencias

| Tema | Recurso |
|------|---------|
| Patrón general de async testing en Python | `Skill("testing-python")` |
| Endpoints FastAPI testing con httpx | `Skill("fastapi-experto")` § Testing |
| Race conditions en PostgreSQL (lado código) | `Skill("postgresql-experto")` § SELECT-then-INSERT |
