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.
- Unit: prueba una unidad de lógica (función, método, clase) aislada de I/O y colaboradores externos. Sub-segundo, determinista, y cuando falla apunta con precisión al culpable. Aquí van validaciones, cálculos, transformaciones, máquinas de estado, edge cases.
- Integration: prueba que dos o más componentes reales interactúan correctamente: tu servicio contra una DB real de test, el repo contra el ORM, el handler contra el cache. Atrapa lo que el unit no puede: queries SQL mal escritas, constraints que no disparan, serialización, contratos entre capas.
- E2E (end-to-end): levanta el sistema completo como lo usa un cliente (
docker-compose up+ requests HTTP reales que atraviesan rate limiter → handler → DB → response). Atrapa fallos de wiring y configuración, pero es lento, frágil y da poca información sobre dónde rompió.
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():
...
skip/skipif: excluyen incondicional o condicionalmente (versión de Python, OS, dependencia ausente).xfail: el test debería fallar (bug conocido, feature no implementada). Si pasa inesperadamente constrict=True, pytest lo reporta como XPASS fallando la suite —te obliga a quitar el marker cuando arreglas el bug. Distinto deskip:xfailsí ejecuta el test.- Markers custom (
@pytest.mark.slow): debes registrarlos en config para evitar warnings, y luego filtras conpytest -m "not slow".
# 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:
- Stub: devuelve respuestas predefinidas. No verifica interacciones. “Cuando te pregunten el precio, responde 50.”
- Mock: stub + verificación de comportamiento. Afirma que fue llamado de cierta forma. “Verifica que
payment.charge()se llamó exactamente una vez conamount=100.” - Fake: implementación real pero simplificada (una
InMemoryRepositorycon undicten vez de Postgres). Funciona de verdad, pero no es apta para producción. - Spy: envuelve el objeto real y registra cómo se usó, dejándolo funcionar.
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:
- Sobre-mockeo → tests que testean el mock, no el código. Si mockeas todo, verificas que tu código llama a los mocks como configuraste los mocks: tautología. Cero confianza real.
- Acoplamiento a la implementación.
assert_called_withafirma cómo se hizo el trabajo, no qué resultado dio. Refactorizas la implementación sin cambiar el comportamiento y el test rompe → tests frágiles que castigan el refactor. - Mocks mienten. Configuras
mock.query.return_value = [...]asumiendo una firma; la librería real cambia y tu mock sigue verde. El bug llega a producción. Sinautospec, un mock acepta cualquier llamada. - Mockear en integration tests derrota su propósito. Esos tests existen para verificar que los componentes reales conectan; mockear la DB ahí es como probar la cerradura con una foto de la llave.
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:
- Respeta la pirámide: el 70% debe ser sub-segundo. Un test que necesita Postgres para verificar una fórmula de descuento está mal ubicado.
- Scope de fixtures: setup caro a
session, no afunction. Levantar el engine 500 veces vs una. - Paralelización:
pytest-xdist(pytest -n auto) reparte tests entre procesos. Requiere aislamiento real (DB por worker, sin estado global compartido). - Selección: markers para correr solo lo rápido en desarrollo;
pytest --lf(last-failed) y--ff(failed-first) para iterar sobre lo que rompe.
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:
| Causa | Síntoma | Remedio |
|---|---|---|
| Tiempo real | datetime.now(), timeouts | Congelar el reloj (freezegun, inyectar un clock) |
| Orden de ejecución | pasa solo, falla en suite | Aislar estado; detectar con pytest-randomly (aleatoriza orden) |
| Estado compartido | mutación de global/fixture amplia | Scope function, rollback, sin singletons mutables |
| Concurrencia / races | falla ~1 de N corridas | Esperar por condición (poll), nunca sleep fijo |
| Aleatoriedad | falla con ciertos seeds | Fijar seed, o hypothesis para explorar a propósito |
| Dependencia de red | falla cuando el 3rd-party está lento | Mockear 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):
- Independiente: no depende del orden ni de otro test. Debe pasar corrido solo, en paralelo, o aleatorizado.
- Atómico (un check por test): un test verifica un comportamiento. Con múltiples asserts no relacionados, el primero que falla oculta los demás y el nombre deja de describir qué se rompió.
- Determinista: mismo input → mismo resultado, siempre. Sin tiempo real, red, ni aleatoriedad no controlada.
- Rápido: para que se corra a menudo.
- Legible / mantenible: patrón Arrange-Act-Assert; el test es documentación ejecutable. Baja complejidad ciclomática: sin
if/for/tryen el test —lógica en el test significa que el test necesita tests. - Falla primero (y con mensaje claro): verifica que el test puede fallar. Un test que nunca falla (assert trivial, mock que se auto-confirma) da falsa confianza. Cuando falla debe decir qué esperaba vs qué obtuvo.
- Testea comportamiento, no implementación: afirma resultados observables (return, estado, efecto), no pasos internos. Así sobrevive al refactor.
unittest vs pytest (y otros runners)
| Aspecto | unittest (stdlib) | pytest |
|---|---|---|
| Estilo | Clases que heredan de TestCase, self.assertEqual | Funciones planas, assert nativo |
| Assertions | ~40 métodos (assertEqual, assertRaises, …) | assert + introspección: muestra valores en el fallo |
| Setup/teardown | setUp/tearDown por método | Fixtures componibles con scopes y yield |
| Parametrización | Verboso (subTest) o loops | @pytest.mark.parametrize de primera clase |
| Fixtures | Herencia de clases | Inyección por dependencia, sin herencia |
| Plugins | Limitado | Ecosistema enorme (xdist, cov, asyncio, mock) |
| Descubrimiento | python -m unittest | Auto-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):
- Load / performance tests: verifican que el sistema aguanta X requests/seg bajo latencia acotada. Locust (Python, escribes escenarios como código, escalable distribuido) y Gatling (JVM/Scala, alto rendimiento, reportes detallados) son los estándar. Se corren fuera del pipeline unitario —típicamente en un stage nightly o pre-release contra un entorno staging, no en cada PR (son lentos y ruidosos).
# 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: automatiza la matriz de entornos —corre la suite contra múltiples versiones de Python/dependencias en virtualenvs aislados. Garantiza “funciona en 3.11, 3.12 y 3.13” reproduciblemente, y es el guardián de compatibilidad para librerías.
# tox.ini
[tox]
envlist = py311, py312, py313, lint
[testenv]
deps = -r requirements-dev.txt
commands = pytest --cov=app {posargs}
[testenv:lint]
commands = ruff check .
- Property-based / fuzzing (
hypothesis): en vez de ejemplos fijos, generas cientos de inputs y afirmas propiedades invariantes (round-trip serialize/deserialize, idempotencia). Encuentra edge cases que no imaginaste; funciona como guardián de robustez.
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
- Line coverage: ¿se ejecutó la línea? Branch coverage (
--cov-branch): ¿se tomaron ambas ramas de cadaif? Branch es más honesto —una línea puede ejecutarse solo por su rama “verdadera” y quedar sin probar la “falsa”. --cov-fail-under=80como gate en CI evita la erosión de cobertura (que un PR baje el %). El 80 es arbitrario pero explícito; mejor que “cobertura opcional”.--cov-report=term-missinglista exactamente qué líneas quedaron sin cubrir.
Límites que un senior debe articular:
- 100% coverage puede tener cero asserts. Un test que ejecuta todo el código sin verificar nada da 100% y atrapa cero bugs. Coverage alto es necesario, no suficiente.
- El scope importa más que el número. Prioriza cubrir paths críticos (idempotencia, auth, cobros, error handling) sobre perseguir el 100% en boilerplate. Marca lo trivial con
# pragma: no cover(config,__init__, logging setup) para no distorsionar la métrica. - Mutation testing (
mutmut,cosmic-ray) es el complemento honesto: introduce bugs deliberados (cambia+por-,<por<=) y verifica que algún test falla. Un mutante que “sobrevive” con 100% coverage prueba que tus tests ejecutan la línea pero no afirman nada sobre ella. Es la respuesta a “¿por qué confío en los tests?”.
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:
- Reports:
--junitxmlproduce el formato estándar que CI parsea para mostrar tests fallidos inline en el PR.--cov-report=xmlalimenta servicios de coverage (Codecov) que comentan el delta de cobertura. - Artifacts: subir
report.xml, logs, o el HTML de coverage conif: always()para poder inspeccionar después del fallo. Sin esto, un fallo en CI es una caja negra. - Notifications: notificar solo lo accionable (fallo en
main, no cada corrida verde) a Slack/email evita fatiga de alertas. Services (Postgres/Redis) levantan dependencias reales accesibles enlocalhost—clave para que los integration tests corran igual que en local.
Testing infra con Docker y Docker Compose
Para integration/e2e necesitas dependencias reales reproducibles. Dos enfoques:
docker-composepara e2e local/CI: levanta el stack completo (app + DB + cache + bus) y corres la suite contra él. Reproduce producción; ideal para el smoke test pre-deploy.testcontainers(recomendado para integration): levanta containers efímeros desde el propio test en Python, con ciclo de vida atado a la fixture. Ventaja sobreservices:de CI: la misma suite corre idéntica en local y en CI sin config externa, y cada run parte de un container limpio.
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:
-
Storage (DB): usa una DB real de test (no in-memory SQLite si producción es Postgres —esconde bugs de queries y tipos específicos del dialecto). Aísla con transacción + rollback por test (rápido) o truncate entre tests (más lento, necesario si el código hace su propio
commit). El test debe verificar que constraints, índices y migraciones funcionan, no solo el happy path del ORM. -
Message bus / colas: el reto es la asincronía. Publicas un evento y el consumidor lo procesa “en algún momento”. Estrategias: un fake in-memory del broker para unit; un broker real (RabbitMQ/Kafka vía testcontainers) para integration, donde publicas y haces polling con timeout hasta ver el efecto (nunca
sleep). Verifica también dead-letter y reintentos. -
3rd party (HTTP externos): nunca pegues al servicio real en la suite (lento, frágil, rate-limited, cambia bajo tus pies). Opciones: mockear el cliente en el borde; grabar/reproducir respuestas reales con
vcrpy(guarda “cassettes” JSON) oresponses/respx(mock a nivel de transport HTTP pararequests/httpx). El contract test verifica que tu parser sigue funcionando contra el shape real grabado. -
Microservicios / backward compatibility de API: el fallo más caro es romper a un consumidor al desplegar. Dos herramientas:
- Consumer-driven contract testing (
Pact): el consumidor define un contrato (qué requests hace, qué responses espera); el provider corre un test que verifica que su API real cumple ese contrato. Atrapa breaking changes antes del deploy, sin levantar ambos servicios juntos. - Schema compatibility checks: para eventos (Avro/Protobuf/JSON Schema), un test que compara el schema nuevo contra el viejo y falla si introduce un cambio incompatible (quitar un campo, cambiar un tipo). En APIs REST, versionar (
/v1,/v2) y mantener tests de la versión vieja mientras haya consumidores.
- Consumer-driven contract testing (
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?
- ❌ Wrong / trap: “Always mock every external dependency so tests are fast and isolated.” Why it fails: mocking everything produces tautological tests that verify your mocks were called as you configured them — zero confidence the real system works. It also couples tests to implementation, so refactors break green tests.
- ✅ Correct: Mock at the system boundary (third-party HTTP, message broker, the clock) and in unit tests to isolate the unit under test. Do not mock in integration tests — those exist precisely to verify real components interact correctly; mocking the DB there defeats the purpose.
- ⭐ Optimal (senior): Prefer fakes over mocks for your own collaborators (an in-memory repository gives more confidence and less fragility than a
MagicMockwith fivereturn_values). Useautospec=Trueso the double respects the real signature and can’t drift silently. The heuristic: the moreassert_called_witha test has, the more it asserts how work was done rather than what result was produced — that’s the fragility smell. Mock behavior you can’t otherwise trigger (timeouts, 500s); fake collaborators you own.
Q2 — patch("app.email.send_email") doesn’t affect the code under test. Why?
- ❌ Wrong / trap: “The import is cached, so you need to reload the module.” Why it fails: reloading is a hack that masks the real rule and breaks other tests.
- ✅ Correct: You must patch where the name is looked up, not where it’s defined. If
app.servicedidfrom app.email import send_email, it holds its own reference in theapp.servicenamespace. Patchingapp.email.send_emailrebinds the original module’s name butapp.servicestill points at the original object. Patchapp.service.send_email. - ⭐ Optimal (senior): This is a consequence of Python’s name binding:
from x import ycopies the reference into the importing module’s namespace at import time. Patch the lookup site. A design that sidesteps the problem entirely: inject the dependency (passsend_emailas an argument or via a collaborator) so tests supply a fake without anypatch— dependency injection makes the seam explicit and the “where to patch” question disappears.
Q3 — A test passes locally but fails ~1 in 10 runs in CI. How do you approach it?
- ❌ Wrong / trap: “Add
pytest-rerunfailuresso it retries until it passes.” Why it fails: auto-retry hides flakiness rather than fixing it; the underlying non-determinism (often a real race in production code) stays and erodes trust in the suite. - ✅ Correct: Diagnose the source of non-determinism: real time (
sleep,datetime.now()), test-ordering / shared state, concurrency races, network, or uncontrolled randomness. Fix the root cause — freeze the clock, isolate state, poll-with-timeout instead ofsleep, seed randomness. - ⭐ Optimal (senior): Reproduce it deterministically first:
pytest-randomlyto surface order-dependence, run under-nto expose shared-state races, fix the seed to reproduce randomness failures. A flaky test is often the messenger of a real production race (e.g. the idempotency check), so investigate the code, not just the test. Retries are acceptable only as instrumentation to measure flakiness rate with telemetry — never as the cure. Thesleep()waiting for a logical condition is the #1 smell; replace with polling.
Q4 — Your service has 95% line coverage. Is it well tested?
- ❌ Wrong / trap: “Yes, 95% is above the 80% bar, so we’re covered.” Why it fails: coverage measures which lines executed, not whether any assertion verified their behavior. A test with no asserts yields coverage and catches nothing.
- ✅ Correct: Not necessarily. Coverage is necessary but not sufficient. Enable branch coverage (
--cov-branch) since line coverage can miss the untaken side of anif. What matters is whether critical paths (idempotency, auth, payments, error handling) are covered and meaningfully asserted, not the headline number. - ⭐ Optimal (senior): Use coverage as a gate against erosion (
--cov-fail-under) but validate test quality with mutation testing (mutmut,cosmic-ray): it injects bugs and checks that some test fails. Surviving mutants at 95% coverage prove tests execute lines without asserting on them. Focus effort on critical-path branches; mark trivial boilerplate# pragma: no coverso the metric isn’t diluted. The honest answer to “why do we trust the tests?” is mutation score, not coverage.
Q5 — How do you test that a new release of a microservice doesn’t break its consumers?
- ❌ Wrong / trap: “Spin up all services in docker-compose and run E2E tests across them.” Why it fails: slow, brittle, requires every consumer deployed together, and gives poor localization — the opposite of scalable cross-service safety.
- ✅ Correct: Use consumer-driven contract testing (e.g. Pact): each consumer publishes a contract of the requests it makes and responses it expects; the provider runs a test verifying its real API satisfies every consumer contract. Breaking changes fail before deploy without co-deploying services.
- ⭐ Optimal (senior): Combine contract tests with schema-compatibility checks in CI — for event payloads (Avro/Protobuf/JSON Schema) fail the build on backward-incompatible changes (removed field, narrowed type); for REST, version the API and keep the old version’s tests alive while consumers exist. Contract tests catch behavioral drift; schema checks catch structural drift. Together they let each service deploy independently — the whole point of microservices — instead of a fragile synchronized release.
Q6 — Why is a function-scoped transaction+rollback fixture better than truncating tables between tests?
- ❌ Wrong / trap: “Truncating is fine, just clean the DB before each test.” Why it fails: truncate-then-reseed is slower and error-prone, and “before each test” cleanup doesn’t run if a test throws early, leaving dirty state that fails the next test — order coupling.
- ✅ Correct: A function-scoped fixture wraps each test in a transaction and rolls back in teardown, giving full isolation without truncating. The rollback runs even if the test fails or raises, so every test starts clean regardless of execution order.
- ⭐ Optimal (senior): Pair a
session-scoped engine (expensive, immutable) with afunction-scoped transaction (cheap to roll back). This enables parallelization (pytest-xdist) and order-randomization safely. The one caveat: if the code under test issues its owncommit, the outer rollback can’t undo it — use nested savepoints (SAVEPOINT/begin_nested) or truncate for those cases. The invariant is that tests must never depend on execution order; that’s hidden coupling that makes suites brittle as they grow.
Q7 — What makes a “good” unit test, and why can you trust a green suite?
- ❌ Wrong / trap: “It has high coverage and passes.” Why it fails: passing and covered says nothing about independence, determinism, or whether it asserts meaningful behavior.
- ✅ Correct: A good test is independent (no order/shared-state coupling), atomic (one behavior per test), deterministic (no real time/network/uncontrolled randomness), fast, readable (Arrange-Act-Assert, no
if/forlogic), and asserts observable behavior, not implementation. You trust the suite because each of these makes a green result mean something. - ⭐ Optimal (senior): Add: it must be able to fail — verify it goes red when the behavior breaks (a test that can never fail gives false confidence). Testing behavior over implementation is what lets it survive refactors; asserting internal call sequences makes it a change-detector, not a safety net. The ultimate trust signal is mutation score: if injected bugs make tests fail, the suite genuinely constrains the code. A test with cyclomatic complexity > 1 is itself a liability — logic in a test needs its own test.
Referencias
- pytest — documentación oficial: https://docs.pytest.org/
- pytest fixtures: https://docs.pytest.org/en/stable/how-to/fixtures.html
- pytest parametrize: https://docs.pytest.org/en/stable/how-to/parametrize.html
- pytest markers: https://docs.pytest.org/en/stable/how-to/mark.html
unittest— Python stdlib: https://docs.python.org/3/library/unittest.htmlunittest.mock: https://docs.python.org/3/library/unittest.mock.html- Where to patch (
unittest.mock): https://docs.python.org/3/library/unittest.mock.html#where-to-patch - Coverage.py: https://coverage.readthedocs.io/
- pytest-cov: https://pytest-cov.readthedocs.io/
- pytest-xdist (paralelización): https://pytest-xdist.readthedocs.io/
- pytest-asyncio: https://pytest-asyncio.readthedocs.io/
- tox: https://tox.wiki/
- Hypothesis (property-based testing): https://hypothesis.readthedocs.io/
- Locust (load testing): https://docs.locust.io/
- Gatling: https://docs.gatling.io/
- Testcontainers for Python: https://testcontainers-python.readthedocs.io/
- Pact (consumer-driven contract testing): https://docs.pact.io/
- mutmut (mutation testing): https://mutmut.readthedocs.io/
- Martin Fowler — Test Doubles (mock/stub/fake/spy): https://martinfowler.com/bliki/TestDouble.html
- Martin Fowler — Test Pyramid: https://martinfowler.com/articles/practical-test-pyramid.html