Atlas

Python Testing: pytest, mocking, testing pyramid

Testear no es “escribir tests”: es decidir qué probar, a qué nivel y por qué, para que la suite te dé señal rápida y confiable sin volverse un lastre. Un backend senior distingue entre unit, integration y e2e no por dogma sino por su relación coste/velocidad/confianza; sabe cuándo un mock protege el aislamiento y cuándo esconde el bug que debías atrapar; entiende que coverage mide ejecución, no calidad; y trata la suite como parte del sistema —con su propio presupuesto de tiempo, su flakiness y su integración en CI. Esta lección cubre esos ejes con pytest como herramienta principal.

Teoría

Test types: unit, integration, e2e

Los tres tipos no son una jerarquía de “mejor a peor” sino un trade-off entre velocidad de feedback, coste de mantenimiento y confianza en que el sistema funciona de verdad.

La distinción clave no es “usa DB o no”, sino qué estás afirmando. Un test que mockea el repositorio para verificar que el servicio llama repo.save(order) con los argumentos correctos es un unit test aunque toque muchas clases. Un test que verifica que ese save realmente persiste y que un UNIQUE constraint rechaza duplicados es integration.

La testing pyramid (y el anti-patrón inverso)

        /\
       /  \    E2E         (pocos, lentos, frágiles)
      /----\
     /      \   Integration (moderados)
    /--------\
   /          \  Unit        (muchos, rápidos, baratos)
  /____________\

Regla práctica de reparto: ~70% unit, ~20% integration, ~10% e2e. No es sagrado, pero la forma importa: la mayoría del feedback debe venir de tests baratos y específicos.

El anti-patrón es el “ice cream cone” (o inverted pyramid): mayoría de E2E, pocos unit. Cada corrida tarda minutos, cada fallo te dice “algo rompió” sin decir dónde, y el flakiness de los E2E erosiona la confianza hasta que el equipo empieza a re-correr en verde por costumbre. El otro extremo tampoco sirve: solo unit tests produce suites donde todo pasa pero la app no arranca porque los componentes no se conectan —los mocks mintieron sobre cómo se comporta la dependencia real.

pytest: fixtures y scopes

Una fixture es el mecanismo de inyección de dependencias de pytest: provee setup, teardown y recursos compartidos (conexión a DB, cliente HTTP, datos de prueba). Se declara con @pytest.fixture y se inyecta pidiéndola por nombre como parámetro del test.

import pytest
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from app.main import app
from app.database import get_db, Base


@pytest.fixture(scope="session")
async def db_engine():
    # setup caro: se crea UNA vez para toda la sesión de tests
    engine = create_async_engine("postgresql+asyncpg://test:test@localhost/testdb")
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    yield engine                       # todo lo previo al yield = setup
    await engine.dispose()             # todo lo posterior = teardown, garantizado


@pytest.fixture(scope="function")
async def db_session(db_engine):
    # transacción nueva por test + rollback = aislamiento sin truncar tablas
    async with AsyncSession(db_engine) as session:
        async with session.begin():
            yield session
            await session.rollback()   # cada test arranca de un estado limpio


@pytest.fixture
async def client(db_session):
    # inyecta la sesión de test en FastAPI sin mockear a bajo nivel
    app.dependency_overrides[get_db] = lambda: db_session
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as c:
        yield c
    app.dependency_overrides.clear()

El yield es lo que hace potente a la fixture: lo anterior es setup, lo posterior es teardown que se ejecuta aunque el test falle o lance excepción (a diferencia de poner cleanup al final del test). Es superior a setUp/tearDown de unittest porque compone: una fixture pide otras como parámetros y pytest resuelve el grafo de dependencias.

Scopes (controlan cada cuánto se recrea la fixture): function (default, una por test) < class < module < package < session. La tensión de diseño: scope amplio = más rápido (no repites setup caro) pero riesgo de estado compartido entre tests → acoplamiento oculto. El patrón senior es engine a scope="session" (caro, inmutable) pero session/transacción a scope="function" (barato de rollback, garantiza aislamiento).

conftest.py: fixtures compartidas sin importar. pytest lo descubre automáticamente; una fixture en el conftest.py de la raíz está disponible en todo el proyecto, una en tests/integration/conftest.py solo en ese subárbol.

pytest: parametrización

@pytest.mark.parametrize corre el mismo test con múltiples inputs sin duplicar código. Cada tupla es un caso independiente con su propio reporte —si uno falla, los demás siguen corriendo.

@pytest.mark.parametrize("status_code,expected", [
    (200, True),
    (201, True),
    (404, False),
    (500, False),
], ids=["ok", "created", "not_found", "server_error"])
def test_is_success(status_code, expected):
    assert is_success(status_code) == expected

ids da nombres legibles en el output (test_is_success[not_found]) en vez de [404-False]. Se puede apilar parametrize para producto cartesiano, y usar pytest.param(..., marks=pytest.mark.xfail) para marcar un caso concreto como esperado-a-fallar. Para inputs que necesitan setup (no solo literales), existe la parametrización indirecta (indirect=True) que pasa el valor a una fixture.

Diferencia clave frente a un for dentro del test: con parametrize, un fallo no corta los demás casos y cada uno aparece como test separado; con un loop, el primer assert que falla aborta el resto y pierdes visibilidad.

pytest: markers

Los markers etiquetan tests para seleccionarlos, saltarlos o modificar su comportamiento.

import sys
import pytest

@pytest.mark.slow                       # marker custom, para filtrar
def test_full_import_pipeline():
    ...

@pytest.mark.skip(reason="pending refactor of the parser")
def test_legacy_format():
    ...

@pytest.mark.skipif(sys.platform == "win32", reason="uses fork()")
def test_multiprocessing_pool():
    ...

@pytest.mark.xfail(raises=NotImplementedError, strict=True)
def test_feature_not_ready():
    ...
# pyproject.toml
[tool.pytest.ini_options]
markers = [
    "slow: tests that take more than a second",
    "integration: requires a real database",
]
asyncio_mode = "auto"   # evita marcar cada test async con @pytest.mark.asyncio

Esto habilita separar la suite por velocidad en CI: los unit rápidos corren en cada push (-m "not slow and not integration"), los lentos solo en el pipeline pre-merge.

Test doubles: mock, stub, fake, spy — y dónde duele

“Mock” se usa coloquialmente para todo doble de prueba, pero la taxonomía (Meszaros/Fowler) importa porque cada uno afirma algo distinto:

En Python, unittest.mock cubre stub y mock. La pieza central es patch, y su regla de oro es “patchea donde se usa (se busca), no donde se define”:

# app/service.py
from app.email import send_email          # importado por REFERENCIA en este módulo

def notify(user):
    send_email(user.email, "welcome")

# tests/test_service.py
from unittest.mock import patch

def test_notify_sends_email():
    # CORRECTO: parchea el nombre en el namespace donde service lo busca
    with patch("app.service.send_email") as mock_send:
        notify(user)
        mock_send.assert_called_once_with(user.email, "welcome")

    # INCORRECTO: patch("app.email.send_email") NO afecta a service,
    # porque service ya tiene su propia referencia al objeto original

Herramientas del arsenal:

from unittest.mock import MagicMock, patch, call

mock = MagicMock()
mock.get_price.return_value = 50            # stub
mock.charge.side_effect = TimeoutError      # simular fallo
mock.fetch.side_effect = [1, 2, 3]          # respuestas sucesivas por llamada

# autospec: el mock respeta la firma real -> atrapa llamadas con args inválidos
with patch("app.service.send_email", autospec=True) as m:
    ...  # llamar send_email(1,2,3,4) revienta como el real, no pasa silencioso

Pros de mockear: aislamiento total → tests rápidos, deterministas, sin infraestructura; permite simular fallos difíciles de reproducir (timeout, disco lleno, 500 del tercero).

Contras y dónde duele:

Heurística senior: mockea en los bordes del sistema (3rd-party HTTP, cola, reloj) y prefiere fakes sobre mocks para colaboradores propios (una InMemoryRepository da más confianza y menos fragilidad que un MagicMock con cinco return_value). Cuantos más assert_called tenga un test, más acoplado está a la implementación.

Problemas comunes: execution time y flakiness

Una suite es código de producción: si es lenta o inestable, el equipo deja de confiar en ella y la ignora.

Execution time. El feedback loop lento mata la productividad. Palancas:

Flakiness (test que pasa/falla sin cambiar el código) es peor que un test rojo estable, porque envenena la señal. Causas y remedios:

CausaSíntomaRemedio
Tiempo realdatetime.now(), timeoutsCongelar el reloj (freezegun, inyectar un clock)
Orden de ejecuciónpasa solo, falla en suiteAislar estado; detectar con pytest-randomly (aleatoriza orden)
Estado compartidomutación de global/fixture ampliaScope function, rollback, sin singletons mutables
Concurrencia / racesfalla ~1 de N corridasEsperar por condición (poll), nunca sleep fijo
Aleatoriedadfalla con ciertos seedsFijar seed, o hypothesis para explorar a propósito
Dependencia de redfalla cuando el 3rd-party está lentoMockear el borde; vcrpy/responses para grabar respuestas

El sleep(2) es el olor #1 de flakiness: o es demasiado corto (falla bajo carga de CI) o demasiado largo (suite lenta). Reemplázalo por polling con timeout: reintenta la condición hasta que se cumpla o expire. Los reintentos automáticos (pytest-rerunfailures) son un curita de último recurso, no una cura —esconden flakiness real; úsalos con telemetría para medir la tasa, no para taparla.

TDD: pros y contras

Test-Driven Development = ciclo red → green → refactor: escribes un test que falla, el código mínimo para pasarlo, y luego refactorizas con la red de seguridad puesta.

Pros: fuerza pensar en el contrato/API antes que en la implementación; produce diseño más testeable (inyección de dependencias emerge naturalmente); garantiza que cada línea tiene un test que la justifica; da una red de seguridad instantánea para refactorizar; los tests documentan la intención.

Contras: curva de aprendizaje real; puede ser contraproducente cuando no sabes aún el diseño (spikes exploratorios, prototipos que vas a tirar); tienta a sobre-especificar con mocks → tests acoplados que castigan el refactor (paradójicamente lo contrario de lo que promete); en dominios de UI o data-science exploratorio el “test primero” es incómodo. No es dogma universal: el senior lo aplica donde el contrato es claro y el coste del bug alto, y hace spikes sin tests cuando explora.

Qué es un buen test

Criterios que hacen una suite confiable (por eso puedes confiar en el verde):

unittest vs pytest (y otros runners)

Aspectounittest (stdlib)pytest
EstiloClases que heredan de TestCase, self.assertEqualFunciones planas, assert nativo
Assertions~40 métodos (assertEqual, assertRaises, …)assert + introspección: muestra valores en el fallo
Setup/teardownsetUp/tearDown por métodoFixtures componibles con scopes y yield
ParametrizaciónVerboso (subTest) o loops@pytest.mark.parametrize de primera clase
FixturesHerencia de clasesInyección por dependencia, sin herencia
PluginsLimitadoEcosistema enorme (xdist, cov, asyncio, mock)
Descubrimientopython -m unittestAuto-discovery por convención

pytest gana por ergonomía: assert x == y con introspección muestra assert 3 == 4 con ambos valores, sin memorizar 40 asserts; las fixtures componen mejor que la herencia de TestCase; el ecosistema de plugins es decisivo. Ventaja clave: pytest corre suites de unittest sin cambios, así que migrar es incremental. unittest sigue siendo válido si no quieres dependencias externas (está en stdlib). nose/nose2 son legacy —evítalos en proyectos nuevos. Configuración en IDE: apuntar el test runner del IDE a pytest y marcar el directorio raíz para que resuelva imports.

Tests como guardianes de Quality Attributes

Más allá de correctitud funcional, los tests automatizan la verificación de atributos de calidad (performance, carga, resiliencia):

# locustfile.py — carga sobre el endpoint de órdenes
from locust import HttpUser, task, between

class OrderUser(HttpUser):
    wait_time = between(1, 3)

    @task
    def create_order(self):
        self.client.post("/api/v1/orders", json={"item": "widget", "quantity": 1})
# tox.ini
[tox]
envlist = py311, py312, py313, lint

[testenv]
deps = -r requirements-dev.txt
commands = pytest --cov=app {posargs}

[testenv:lint]
commands = ruff check .

Coverage: métrica y sus límites

Coverage mide qué líneas ejecutó la suite, no si los tests son significativos.

pytest --cov=app --cov-branch --cov-report=term-missing --cov-fail-under=80

Límites que un senior debe articular:

Integración con CI: reports, artifacts, notifications

CI convierte los tests de “corren en mi máquina” a gate de merge: un PR que baja coverage o rompe lint no entra. El pipeline debe hacer fast-fail (lo barato primero) y publicar evidencia.

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    services:                          # dependencias reales como containers
      postgres:
        image: postgres:16
        env: { POSTGRES_PASSWORD: test, POSTGRES_DB: testdb }
        options: >-
          --health-cmd pg_isready --health-interval 10s
          --health-timeout 5s --health-retries 5
      redis:
        image: redis:7
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install -r requirements.txt -r requirements-dev.txt
      - run: ruff check .                        # 1. lint: fast-fail barato
      - run: mypy app/                           # 2. type check
      - name: Tests
        env:
          DATABASE_URL: postgresql://postgres:test@localhost:5432/testdb
        run: pytest --cov=app --cov-branch --cov-fail-under=80 \
                    --junitxml=report.xml --cov-report=xml
      - name: Publish test report                # reports visibles en el PR
        if: always()                             # corre aunque los tests fallen
        uses: actions/upload-artifact@v4
        with: { name: test-results, path: report.xml }

Tres piezas que distinguen un pipeline maduro:

Testing infra con Docker y Docker Compose

Para integration/e2e necesitas dependencias reales reproducibles. Dos enfoques:

import pytest
from testcontainers.postgres import PostgresContainer

@pytest.fixture(scope="session")
def pg_url():
    with PostgresContainer("postgres:16") as pg:
        yield pg.get_connection_url()   # container arranca y muere con la fixture

Esto elimina el “en mi máquina no tengo Postgres 16”: el test es su propia infraestructura.

Advanced integration: storage, message bus, 3rd party, microservicios

Los casos que separan al senior. Cada dependencia externa exige una estrategia distinta:

Ejemplo del test de integración más valioso —idempotencia bajo concurrencia real, que ningún unit puede atrapar:

import asyncio
import pytest
from httpx import AsyncClient

@pytest.mark.asyncio
async def test_duplicate_order_rejected(client: AsyncClient):
    """Dos requests con el mismo Idempotency-Key: solo uno debe crear la orden."""
    payload = {"item": "widget", "quantity": 1}
    headers = {"Idempotency-Key": "test-key-001"}

    # dispara dos requests concurrentes que compiten por la misma llave
    r1, r2 = await asyncio.gather(
        client.post("/api/v1/orders", json=payload, headers=headers),
        client.post("/api/v1/orders", json=payload, headers=headers),
    )

    codes = sorted([r1.status_code, r2.status_code])
    # exactamente una creación; la otra es replay/conflict, nunca dos 201
    assert codes[0] == 201, "Al menos un request debe crear la orden"
    assert 201 not in codes[1:], "Se crearon órdenes duplicadas bajo carrera"

Ejercicios

Ejercicio 1 — Convertir tests duplicados a parametrize

Tienes esta batería repetitiva. Refactorízala a un solo test parametrizado con ids legibles, cubriendo también un caso de borde que falte.

def test_discount_regular():
    assert apply_discount(100, "regular") == 100

def test_discount_silver():
    assert apply_discount(100, "silver") == 90

def test_discount_gold():
    assert apply_discount(100, "gold") == 80
Solución
import pytest

@pytest.mark.parametrize("tier,expected", [
    ("regular", 100),
    ("silver", 90),
    ("gold", 80),
    ("unknown", 100),   # borde: tier desconocido no debe romper ni descontar
], ids=["regular", "silver", "gold", "unknown_tier"])
def test_apply_discount(tier, expected):
    assert apply_discount(100, tier) == expected

Cada caso corre y reporta por separado (test_apply_discount[gold]); un fallo no corta los demás. Añadir el caso unknown fuerza a decidir el comportamiento del default explícitamente —algo que las tres funciones originales ni tocaban.

Ejercicio 2 — Mockear una dependencia externa (correctamente)

app/service.py importa from app.gateway import charge_card y lo usa en process_payment. Escribe un test que verifique que ante un TimeoutError del gateway, process_payment devuelve "retry_later" sin propagar la excepción. ¿Qué target de patch usas y por qué?

Solución
from unittest.mock import patch
from app.service import process_payment

def test_process_payment_handles_gateway_timeout():
    # patchea el nombre en el namespace de service (donde se BUSCA),
    # NO app.gateway.charge_card (donde se define).
    with patch("app.service.charge_card", autospec=True) as mock_charge:
        mock_charge.side_effect = TimeoutError

        result = process_payment(order_id="O1", amount=100)

        assert result == "retry_later"
        mock_charge.assert_called_once_with(order_id="O1", amount=100)

Claves: (1) patch("app.service.charge_card") porque service importó la función por referencia —parchear app.gateway.charge_card no afectaría la referencia que service ya tiene. (2) autospec=True para que el mock respete la firma real y assert_called_once_with sea significativo. (3) side_effect = TimeoutError simula el fallo que sería imposible/frágil de provocar contra el gateway real.

Ejercicio 3 — Arreglar un test flaky

Este test falla ~1 de cada 10 corridas en CI. Diagnostica las dos causas y arréglalo.

import time
from datetime import datetime

def test_token_expires():
    token = create_token(ttl_seconds=1)
    time.sleep(1)                        # esperar a que expire
    assert token.is_expired(datetime.now())
Solución

Dos problemas: (1) sleep(1) con TTL de 1s es una carrera —bajo carga de CI el clock del test y el del token pueden desalinearse por milisegundos. (2) Depende de tiempo real, lo que lo hace no-determinista y lento.

Arréglalo inyectando/controlando el reloj en vez de dormir:

from datetime import datetime, timedelta, timezone

def test_token_expires():
    created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
    token = create_token(ttl_seconds=1, now=created_at)

    # antes de expirar
    assert not token.is_expired(created_at + timedelta(milliseconds=500))
    # después de expirar: avanzamos el reloj, no dormimos
    assert token.is_expired(created_at + timedelta(seconds=2))

Ahora es instantáneo, determinista y verifica ambos lados de la frontera. Si el código no acepta now inyectado, esa es la señal: hazlo testeable (inyección del clock) o usa freezegun para congelar datetime.now(). La regla: nunca sleep fijo para esperar tiempo lógico.

Ejercicio 4 — Diseñar el fixture de aislamiento de DB

Escribe una fixture db_session de scope function que garantice que cada test empieza con la DB limpia, sin truncar tablas y sin depender del orden de ejecución. Explica por qué el rollback es superior al truncate.

Solución
import pytest
from sqlalchemy.ext.asyncio import AsyncSession

@pytest.fixture(scope="function")
async def db_session(db_engine):     # db_engine es scope="session"
    async with AsyncSession(db_engine) as session:
        async with session.begin():          # abre transacción
            yield session
            await session.rollback()          # deshace TODO lo del test

Por qué rollback > truncate: (1) más rápido —deshacer una transacción es más barato que borrar y re-sembrar tablas. (2) aislamiento garantizado aunque el test falle o lance —el rollback ocurre en el teardown de la fixture, no depende de que el test llegue al final. (3) no depende del orden —cada test ve un estado limpio sin importar cuáles corrieron antes, así que la suite es robusta a paralelización (pytest-xdist) y aleatorización de orden. La única limitación: si el código bajo prueba hace su propio commit, el rollback externo no lo alcanza —ahí sí necesitas truncate o savepoints anidados.

Ejercicio 5 — Detectar por qué un test da falsa confianza

Este test pasa y reporta 100% coverage de calculate_total. Explica por qué no protege nada y arréglalo.

def test_calculate_total():
    order = Order(items=[Item(price=10, qty=2), Item(price=5, qty=1)])
    calculate_total(order)   # ejecuta la función... y ya
Solución

El test ejecuta calculate_total (por eso 100% coverage) pero no afirma nada: no hay assert. Si la fórmula estuviera mal (price * qty invertido, un + que debería ser *), el test seguiría verde. Coverage mide ejecución, no verificación —este es el ejemplo canónico de por qué coverage alto no implica calidad.

def test_calculate_total():
    order = Order(items=[Item(price=10, qty=2), Item(price=5, qty=1)])
    assert calculate_total(order) == 25    # 10*2 + 5*1

def test_calculate_total_empty_order():
    assert calculate_total(Order(items=[])) == 0   # borde

Un mutation test (mutmut) sobre la versión original habría “sobrevivido” a cualquier mutante —confirmando que el test no protege nada— mientras que sobre la versión corregida los mutantes mueren.

Preguntas tipo entrevista (EN)

Q1 — When would you mock a dependency, and when is mocking the wrong choice?

Q2 — patch("app.email.send_email") doesn’t affect the code under test. Why?

Q3 — A test passes locally but fails ~1 in 10 runs in CI. How do you approach it?

Q4 — Your service has 95% line coverage. Is it well tested?

Q5 — How do you test that a new release of a microservice doesn’t break its consumers?

Q6 — Why is a function-scoped transaction+rollback fixture better than truncating tables between tests?

Q7 — What makes a “good” unit test, and why can you trust a green suite?

Referencias