@saulwade/swl-ses 1.4.0 → 1.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +4 -3
- package/README.md +15 -14
- package/agentes/nemesis-auditor-swl.md +161 -0
- package/bin/swl-mcp-server.js +187 -187
- package/comandos/swl/.evolved.json +22 -22
- package/comandos/swl/contribuir.md +233 -233
- package/comandos/swl/nemesis.md +122 -0
- package/comandos/swl/salud.md +34 -0
- package/comandos/swl/verificar.md +45 -0
- package/gateway/lib/event-channel.js +191 -191
- package/habilidades/backend-production-resilience/SKILL.md +288 -288
- package/habilidades/benchmark-memoria/SKILL.md +186 -186
- package/habilidades/diagrama-arquitectura/assets/template.html +276 -276
- package/habilidades/doubt-driven-review/SKILL.md +171 -171
- package/habilidades/doubt-driven-review/recursos/EXAMPLES.md +130 -130
- package/habilidades/eval-framework/SKILL.md +212 -212
- package/habilidades/feynman-auditor-swl/SKILL.md +123 -0
- package/habilidades/feynman-auditor-swl/recursos/preguntas-language-agnostic.md +108 -0
- package/habilidades/harness-claude-code/SKILL.md +299 -299
- package/habilidades/infra-github-actions/SKILL.md +166 -166
- package/habilidades/legacy-code-rescue/SKILL.md +267 -267
- package/habilidades/manejo-errores/.evolved.json +8 -8
- package/habilidades/meta-skills-estandar/recursos/convencion-examples.md +93 -93
- package/habilidades/meta-skills-estandar/recursos/skills-as-agents.md +163 -163
- package/habilidades/patrones-python/SKILL.md +229 -229
- package/habilidades/patrones-python/recursos/patrones-avanzados.md +469 -469
- package/habilidades/planear-fase/SKILL.md +319 -319
- package/habilidades/release-semver/.evolved.json +8 -8
- package/habilidades/state-inconsistency-auditor-swl/SKILL.md +166 -0
- package/habilidades/state-inconsistency-auditor-swl/recursos/coupled-state-patterns.md +147 -0
- package/habilidades/testing-python/SKILL.md +340 -340
- package/habilidades/web-fetcher-routing/SKILL.md +75 -0
- package/hooks/claudemd-bloat-detector.js +161 -161
- package/hooks/lib/agent-routing.js +107 -107
- package/hooks/lib/auto-consolidator.js +335 -335
- package/hooks/lib/error-classifier.js +308 -308
- package/hooks/lib/merkle-audit.js +96 -96
- package/hooks/lib/provenance-tracker.js +191 -191
- package/hooks/lib/rate-limit-tracker.js +253 -253
- package/hooks/lib/resource-quota.js +122 -122
- package/hooks/lib/retry-jitter.js +165 -165
- package/hooks/lib/security-net.js +201 -0
- package/hooks/lib/skill-auditor.js +588 -588
- package/hooks/lib/sync-status.js +228 -228
- package/hooks/lib/taint-tracker.js +107 -107
- package/hooks/lib/text-similarity.js +241 -241
- package/hooks/lib/toon-compressor.js +245 -245
- package/hooks/registro-turnos.js +209 -209
- package/hooks/sugerir-regenerar-inventario.js +170 -170
- package/hooks/validar-formato-post-subagente.js +140 -140
- package/hooks/validar-memoria-hook.js +218 -218
- package/instintos/prompt-appendices.yaml +57 -57
- package/manifiestos/agent-output-schemas.json +57 -57
- package/manifiestos/modulos.json +41 -6
- package/manifiestos/perfiles.json +2 -1
- package/manifiestos/skills-lock.json +30 -9
- package/package.json +2 -2
- package/plantillas/auditor-veto-template.md +105 -105
- package/plantillas/github-workflows/README.md +47 -47
- package/plantillas/github-workflows/release-please.yml +44 -44
- package/plantillas/github-workflows/swl-ci.yml +107 -107
- package/plantillas/github-workflows/swl-security.yml +51 -51
- package/plugin.json +10 -2
- package/reglas/analisis-previo-tareas-grandes.md +172 -172
- package/reglas/arreglar-al-detectar.md +147 -147
- package/reglas/fragmentos-compartidos.md +152 -152
- package/reglas/harness-claude-code.md +213 -213
- package/reglas/usar-context7.md +226 -226
- package/schemas/diary-entry.schema.json +80 -80
- package/scripts/audit-tools/audit-history.js +330 -0
- package/scripts/audit-tools/bundle-tracker.js +290 -0
- package/scripts/audit-tools/canary-monitor.js +352 -0
- package/scripts/audit-tools/code-profiler.js +605 -0
- package/scripts/audit-tools/dep-doctor.js +320 -0
- package/scripts/audit-tools/env-validator.js +206 -0
- package/scripts/audit-tools/lib/fs-walk.js +48 -0
- package/scripts/audit-tools/lib/output.js +23 -0
- package/scripts/audit-tools/migration-checker.js +392 -0
- package/scripts/audit-tools/pentest-scanner.js +1436 -0
- package/scripts/benchmark-memoria.js +167 -167
- package/scripts/configurar-branch-protection.js +418 -418
- package/scripts/detectar-aprendizajes-duplicados.js +151 -151
- package/scripts/field-report.js +199 -199
- package/scripts/generar-checklists-consolidados.js +273 -273
- package/scripts/generar-inventario.js +420 -420
- package/scripts/generar-matriz-lenguajes.js +271 -271
- package/scripts/lib/artefactos-python.js +43 -43
- package/scripts/lib/benchmark-metrics.js +160 -160
- package/scripts/lib/budget-enforcer.js +252 -252
- package/scripts/lib/configurar-ci.js +380 -380
- package/scripts/lib/contadores-inventario.js +217 -217
- package/scripts/lib/detectar-stack-detallado.js +307 -307
- package/scripts/lib/diary-entry.js +234 -234
- package/scripts/lib/eval-metrics-store.js +218 -218
- package/scripts/lib/eval-quality.js +171 -171
- package/scripts/lib/eval-schemas.js +144 -144
- package/scripts/lib/eval-self-correct.js +106 -106
- package/scripts/lib/eval-validator.js +185 -185
- package/scripts/lib/jaccard-similarity.js +98 -98
- package/scripts/lib/longmemeval-runner.js +125 -125
- package/scripts/lib/manifiestos.js +42 -1
- package/scripts/lib/npm-version.js +261 -261
- package/scripts/lib/paquetes-conocidos.js +50 -50
- package/scripts/lib/prompt-builder.js +264 -264
- package/scripts/lib/rrf-fusion.js +175 -175
- package/scripts/lib/scoring-instintos.js +277 -277
- package/scripts/lib/semantic-search.js +252 -252
- package/scripts/limpiar-artefactos-python.js +131 -131
- package/scripts/mcp-server/README.md +128 -128
- package/scripts/mcp-server/handlers.js +206 -206
- package/scripts/migrar-csv-a-array.js +168 -168
- package/scripts/migrar-fase-dominio.js +201 -201
- package/scripts/publicar.js +511 -511
- package/scripts/run-eval.js +141 -141
- package/scripts/validar-manifest.js +231 -195
- package/scripts/validar-userland-vacio.js +110 -110
|
@@ -1,340 +1,340 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: testing-python
|
|
3
|
-
description: Testing Python con pytest. Fixtures, mocking, parametrize, cobertura, factories, testing async y patrones de test doubles. TDD y testing de capas separadas.
|
|
4
|
-
version: "1.2.1"
|
|
5
|
-
herramientasPermitidas: [Read, Bash]
|
|
6
|
-
exclusiones:
|
|
7
|
-
- "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."
|
|
8
|
-
- "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."
|
|
9
|
-
- "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."
|
|
10
|
-
- "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`."
|
|
11
|
-
evolvable: true # default para skill estandar
|
|
12
|
-
evolved: true
|
|
13
|
-
evolved-from: "5.12.3"
|
|
14
|
-
evolved-at: "2026-04-25"
|
|
15
|
-
evolved-by: "aprender"
|
|
16
|
-
evolved-note: "2 gotchas nuevos: chdir vs __dirname y sanitizar-antes-de-truncar"
|
|
17
|
-
---
|
|
18
|
-
# Testing Python con pytest
|
|
19
|
-
|
|
20
|
-
## Cuándo NO cargar
|
|
21
|
-
|
|
22
|
-
- Los tests son de FastAPI con AsyncClient/httpx — cargar `fastapi-experto` para el contexto de fixtures de ese framework.
|
|
23
|
-
- Los tests son de Django con pytest-django y `@pytest.mark.django_db` — cargar `django-experto`.
|
|
24
|
-
- La tarea es auditar la cobertura existente — cargar `checklist-calidad` para métricas de calidad.
|
|
25
|
-
|
|
26
|
-
## Principios de testing
|
|
27
|
-
|
|
28
|
-
- **Un test verifica un solo comportamiento** — no múltiples cosas a la vez.
|
|
29
|
-
- **Los tests son documentación** — el nombre del test debe describir QUÉ se prueba.
|
|
30
|
-
- **Test doubles solo cuando es necesario** — no mockear lo que puedes usar real.
|
|
31
|
-
- **Tests rápidos > tests lentos** — unitarios son milisegundos; de integración, segundos.
|
|
32
|
-
- **Cobertura de líneas no es meta** — cobertura de comportamientos sí lo es.
|
|
33
|
-
|
|
34
|
-
---
|
|
35
|
-
|
|
36
|
-
## Estructura de proyecto de tests
|
|
37
|
-
|
|
38
|
-
```
|
|
39
|
-
tests/
|
|
40
|
-
├── conftest.py # Fixtures compartidos globalmente
|
|
41
|
-
├── unit/ # Tests unitarios (sin I/O)
|
|
42
|
-
│ ├── conftest.py
|
|
43
|
-
│ ├── test_factura_service.py
|
|
44
|
-
│ └── test_validaciones.py
|
|
45
|
-
├── integration/ # Tests con BD real (o en memoria)
|
|
46
|
-
│ ├── conftest.py
|
|
47
|
-
│ └── test_factura_api.py
|
|
48
|
-
└── e2e/ # Tests end-to-end (opcional)
|
|
49
|
-
└── test_flujo_facturacion.py
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
---
|
|
53
|
-
|
|
54
|
-
## Nomenclatura de tests
|
|
55
|
-
|
|
56
|
-
```python
|
|
57
|
-
# Patrón: test_<qué>_<condición>_<resultado_esperado>
|
|
58
|
-
def test_calcular_iva_monto_positivo_retorna_monto_correcto(): ...
|
|
59
|
-
def test_calcular_iva_monto_negativo_lanza_valor_error(): ...
|
|
60
|
-
def test_crear_factura_cliente_inactivo_lanza_error_negocio(): ...
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
---
|
|
64
|
-
|
|
65
|
-
## Fixtures
|
|
66
|
-
|
|
67
|
-
```python
|
|
68
|
-
# conftest.py
|
|
69
|
-
import pytest
|
|
70
|
-
from decimal import Decimal
|
|
71
|
-
|
|
72
|
-
@pytest.fixture
|
|
73
|
-
def monto_valido() -> Decimal:
|
|
74
|
-
return Decimal("1000.00")
|
|
75
|
-
|
|
76
|
-
@pytest.fixture
|
|
77
|
-
def factura_data() -> dict:
|
|
78
|
-
return {
|
|
79
|
-
"folio": "F-001",
|
|
80
|
-
"fecha": "2026-03-25",
|
|
81
|
-
"subtotal": Decimal("1000.00"),
|
|
82
|
-
"cliente_id": "cliente-123",
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
# Fixture con scope para BD — se crea una vez por sesión
|
|
86
|
-
@pytest.fixture(scope="session")
|
|
87
|
-
def engine():
|
|
88
|
-
from sqlalchemy import create_engine
|
|
89
|
-
engine = create_engine("sqlite:///:memory:")
|
|
90
|
-
Base.metadata.create_all(engine)
|
|
91
|
-
yield engine
|
|
92
|
-
Base.metadata.drop_all(engine)
|
|
93
|
-
|
|
94
|
-
@pytest.fixture
|
|
95
|
-
def db(engine):
|
|
96
|
-
from sqlalchemy.orm import Session
|
|
97
|
-
with Session(engine) as session:
|
|
98
|
-
yield session
|
|
99
|
-
session.rollback() # Limpiar después de cada test
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
---
|
|
103
|
-
|
|
104
|
-
## Parametrize — probar múltiples casos
|
|
105
|
-
|
|
106
|
-
```python
|
|
107
|
-
import pytest
|
|
108
|
-
from decimal import Decimal
|
|
109
|
-
|
|
110
|
-
@pytest.mark.parametrize("monto,tasa,esperado", [
|
|
111
|
-
(Decimal("100.00"), 0.16, Decimal("16.00")),
|
|
112
|
-
(Decimal("0.00"), 0.16, Decimal("0.00")),
|
|
113
|
-
(Decimal("999.99"), 0.16, Decimal("159.998")),
|
|
114
|
-
])
|
|
115
|
-
def test_calcular_iva(monto, tasa, esperado):
|
|
116
|
-
resultado = calcular_iva(monto, tasa)
|
|
117
|
-
assert resultado == pytest.approx(float(esperado), rel=1e-4)
|
|
118
|
-
|
|
119
|
-
# Parametrize con IDs descriptivos
|
|
120
|
-
@pytest.mark.parametrize("estatus,puede_cancelar", [
|
|
121
|
-
pytest.param("borrador", True, id="borrador-puede-cancelar"),
|
|
122
|
-
pytest.param("cancelada", False, id="cancelada-ya-cancelada"),
|
|
123
|
-
])
|
|
124
|
-
def test_factura_puede_cancelar(estatus, puede_cancelar):
|
|
125
|
-
factura = Factura(estatus=estatus)
|
|
126
|
-
assert factura.puede_cancelar() == puede_cancelar
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
---
|
|
130
|
-
|
|
131
|
-
## Mocking — reglas clave
|
|
132
|
-
|
|
133
|
-
- Mockear en el boundary: BD, APIs externas, filesystem, reloj.
|
|
134
|
-
- NUNCA mockear código propio — señal de acoplamiento excesivo.
|
|
135
|
-
- Usar `pytest-mock` (mocker) sobre `unittest.mock.patch` para mayor limpieza.
|
|
136
|
-
- `AsyncMock` para funciones async.
|
|
137
|
-
|
|
138
|
-
Para ejemplos completos de mocking, testing async y factories, ver [recursos/ejemplos-completos.md](recursos/ejemplos-completos.md).
|
|
139
|
-
|
|
140
|
-
---
|
|
141
|
-
|
|
142
|
-
## Factories con factory_boy — resumen
|
|
143
|
-
|
|
144
|
-
Usar factories sobre fixtures hardcodeados. Permiten sobreescribir solo lo relevante:
|
|
145
|
-
|
|
146
|
-
```python
|
|
147
|
-
# tests/factories.py — definir factories centralizados
|
|
148
|
-
class FacturaFactory(factory.Factory):
|
|
149
|
-
class Meta:
|
|
150
|
-
model = Factura
|
|
151
|
-
estatus = "borrador"
|
|
152
|
-
cliente = factory.SubFactory(ClienteFactory)
|
|
153
|
-
|
|
154
|
-
# En el test — solo los datos que importan
|
|
155
|
-
factura = FacturaFactory(estatus="pagada", total=Decimal("1000.00"))
|
|
156
|
-
```
|
|
157
|
-
|
|
158
|
-
Para ejemplos completos de factories con factory_boy, ver [recursos/ejemplos-completos.md](recursos/ejemplos-completos.md).
|
|
159
|
-
|
|
160
|
-
---
|
|
161
|
-
|
|
162
|
-
## Cobertura de tests
|
|
163
|
-
|
|
164
|
-
```bash
|
|
165
|
-
# Ejecutar con cobertura
|
|
166
|
-
pytest --cov=app --cov-report=term-missing --cov-report=html
|
|
167
|
-
|
|
168
|
-
# Requerir cobertura mínima (falla si no se alcanza)
|
|
169
|
-
pytest --cov=app --cov-fail-under=85
|
|
170
|
-
```
|
|
171
|
-
|
|
172
|
-
```toml
|
|
173
|
-
# pyproject.toml
|
|
174
|
-
[tool.coverage.run]
|
|
175
|
-
source = ["app"]
|
|
176
|
-
omit = ["app/migrations/*", "app/main.py", "*/tests/*"]
|
|
177
|
-
|
|
178
|
-
[tool.coverage.report]
|
|
179
|
-
exclude_lines = [
|
|
180
|
-
"pragma: no cover",
|
|
181
|
-
"def __repr__",
|
|
182
|
-
"if TYPE_CHECKING:",
|
|
183
|
-
"raise NotImplementedError",
|
|
184
|
-
]
|
|
185
|
-
```
|
|
186
|
-
|
|
187
|
-
---
|
|
188
|
-
|
|
189
|
-
## Markers y organización
|
|
190
|
-
|
|
191
|
-
```python
|
|
192
|
-
# Registrar markers en pyproject.toml
|
|
193
|
-
# [tool.pytest.ini_options]
|
|
194
|
-
# markers = [
|
|
195
|
-
# "slow: tests que tardan más de 1 segundo",
|
|
196
|
-
# "integration: tests que requieren BD o red",
|
|
197
|
-
# "unit: tests puramente unitarios",
|
|
198
|
-
# ]
|
|
199
|
-
|
|
200
|
-
@pytest.mark.slow
|
|
201
|
-
@pytest.mark.integration
|
|
202
|
-
async def test_proceso_completo_facturacion(): ...
|
|
203
|
-
|
|
204
|
-
# pytest -m "not integration" # Ejecutar solo unitarios
|
|
205
|
-
# pytest -x # Parar al primer fallo
|
|
206
|
-
```
|
|
207
|
-
|
|
208
|
-
---
|
|
209
|
-
|
|
210
|
-
## Anti-patrones principales
|
|
211
|
-
|
|
212
|
-
- **Test que verifica demasiado**: un solo test con 10+ asserts sobre comportamientos distintos. Dividir en tests separados.
|
|
213
|
-
- **Lógica de negocio en tests**: duplicar if/else del código de producción en el test. Usar valores concretos con parametrize.
|
|
214
|
-
- **Sleep en tests**: NUNCA `time.sleep()`. Mockear el reloj con `freezegun`.
|
|
215
|
-
|
|
216
|
-
Para ejemplos detallados MAL vs BIEN de anti-patrones, ver [recursos/ejemplos-completos.md](recursos/ejemplos-completos.md).
|
|
217
|
-
|
|
218
|
-
## Gotchas / Errores comunes no obvios
|
|
219
|
-
|
|
220
|
-
- **`@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.
|
|
221
|
-
- **`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.
|
|
222
|
-
- **`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.
|
|
223
|
-
- **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.
|
|
224
|
-
- **`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`.
|
|
225
|
-
- **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.
|
|
226
|
-
|
|
227
|
-
## Refactorizar parsers: fixtures multi-formato ANTES del cambio
|
|
228
|
-
|
|
229
|
-
### SIEMPRE: tener fixtures de cada formato soportado antes de modificar un parser
|
|
230
|
-
|
|
231
|
-
**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).
|
|
232
|
-
|
|
233
|
-
**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.
|
|
234
|
-
|
|
235
|
-
**Regla operativa**:
|
|
236
|
-
|
|
237
|
-
1. Crear `tests/fixtures/[dominio]/[nombre]-[formato].ext` para CADA formato conocido **antes** de tocar el parser.
|
|
238
|
-
2. Escribir tests de conteo/identidad para AMBOS formatos (ej: "produce exactamente 13 IDs canónicos") ANTES del cambio.
|
|
239
|
-
3. Modificar el regex/parser.
|
|
240
|
-
4. Verificar que AMBOS tests siguen verdes. Si uno se rompe, revertir y acotar más el cambio.
|
|
241
|
-
|
|
242
|
-
```python
|
|
243
|
-
# BIEN — fixtures explícitos de cada formato soportado, test antes del fix
|
|
244
|
-
FIXTURE_CANONICO = Path("tests/fixtures/cedulas/cedula-formato-v1.md")
|
|
245
|
-
FIXTURE_REEXTRAIDO = Path("tests/fixtures/cedulas/cedula-formato-v2.md")
|
|
246
|
-
|
|
247
|
-
@pytest.mark.parametrize("fixture", [FIXTURE_CANONICO, FIXTURE_REEXTRAIDO])
|
|
248
|
-
def test_parser_produce_13_ids_canonicos(fixture):
|
|
249
|
-
texto = fixture.read_text(encoding="utf-8")
|
|
250
|
-
ids = [h.get("id") for h in extraer(texto)]
|
|
251
|
-
assert len(ids) == 13
|
|
252
|
-
assert "X.X.x" not in ids # no debe caer al fallback legacy
|
|
253
|
-
```
|
|
254
|
-
|
|
255
|
-
**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.
|
|
256
|
-
|
|
257
|
-
**Relacionado**: patrón "characterization test" de Michael Feathers — capturar el comportamiento actual como fixture byte-exact antes de refactorizar.
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
---
|
|
262
|
-
|
|
263
|
-
## Tests de idempotencia requieren 2 ejecuciones + diff del estado
|
|
264
|
-
|
|
265
|
-
### Regla
|
|
266
|
-
|
|
267
|
-
Para cualquier pipeline **resumable**, **reentrante** o **idempotente por diseño**
|
|
268
|
-
(walkers que marcan estado en cada paso, workers que dedupean por clave, jobs que
|
|
269
|
-
continúan donde se interrumpieron), el test unitario que pasa con 1 ejecución es
|
|
270
|
-
insuficiente. Se necesitan **2 ejecuciones consecutivas del mismo input** y un
|
|
271
|
-
assert sobre el **diff del estado**.
|
|
272
|
-
|
|
273
|
-
### Por qué
|
|
274
|
-
|
|
275
|
-
El bug más frecuente en pipelines resumables es que el dedupe solo considera
|
|
276
|
-
estado terminal (`estado == "ok"`), ignorando estados intermedios (`descubierto`,
|
|
277
|
-
`en_proceso`). En la segunda corrida, los ítems en estado intermedio se duplican
|
|
278
|
-
aunque el walker "sabe" que ya los vio. Este bug **nunca aparece** en tests
|
|
279
|
-
unitarios que solo verifican una corrida — se necesita la corrida N+1 para
|
|
280
|
-
observar la duplicación.
|
|
281
|
-
|
|
282
|
-
### Patrón canónico
|
|
283
|
-
|
|
284
|
-
```python
|
|
285
|
-
def test_walker_resumable_no_duplica_en_corridas_sucesivas(tmp_path):
|
|
286
|
-
# Arrange — dataset con 100 archivos
|
|
287
|
-
fuente = crear_fuente_con_100_archivos(tmp_path)
|
|
288
|
-
manifest = tmp_path / "manifest.jsonl"
|
|
289
|
-
|
|
290
|
-
# Act corrida 1
|
|
291
|
-
walker = Walker(fuente=fuente, manifest=manifest)
|
|
292
|
-
walker.ejecutar()
|
|
293
|
-
manifest_despues_1 = manifest.read_text().splitlines()
|
|
294
|
-
|
|
295
|
-
# Act corrida 2 (re-ejecución completa, sin reset)
|
|
296
|
-
walker2 = Walker(fuente=fuente, manifest=manifest)
|
|
297
|
-
walker2.ejecutar()
|
|
298
|
-
manifest_despues_2 = manifest.read_text().splitlines()
|
|
299
|
-
|
|
300
|
-
# Assert 1: la segunda corrida NO agrega entradas duplicadas
|
|
301
|
-
diff = len(manifest_despues_2) - len(manifest_despues_1)
|
|
302
|
-
assert diff == 0, (
|
|
303
|
-
f"Corrida 2 agregó {diff} entradas. El dedupe está ignorando "
|
|
304
|
-
f"algún estado intermedio. Manifest antes={len(manifest_despues_1)}, "
|
|
305
|
-
f"después={len(manifest_despues_2)}."
|
|
306
|
-
)
|
|
307
|
-
|
|
308
|
-
# Assert 2: todas las entradas son únicas por su clave de dedupe (SHA)
|
|
309
|
-
shas = [json.loads(l)["sha256"] for l in manifest_despues_2]
|
|
310
|
-
assert len(shas) == len(set(shas)), "Hay SHAs duplicados en manifest"
|
|
311
|
-
|
|
312
|
-
# Assert 3: el count final coincide con el dataset fuente
|
|
313
|
-
assert len(manifest_despues_2) == 100
|
|
314
|
-
```
|
|
315
|
-
|
|
316
|
-
### Reglas
|
|
317
|
-
|
|
318
|
-
- **Dos corridas exactas** — mismo input, mismo código, diferente momento. No resetear estado entre corridas; eso simula el caso "pipeline interrumpido y retomado".
|
|
319
|
-
- **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.
|
|
320
|
-
- **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.
|
|
321
|
-
- **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".
|
|
322
|
-
- **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.
|
|
323
|
-
|
|
324
|
-
### Anti-patrón
|
|
325
|
-
|
|
326
|
-
```python
|
|
327
|
-
# MAL — una sola corrida; el bug de dedupe parcial pasa desapercibido
|
|
328
|
-
def test_walker_procesa_100_archivos(tmp_path):
|
|
329
|
-
walker = Walker(fuente=crear_100_archivos(tmp_path))
|
|
330
|
-
walker.ejecutar()
|
|
331
|
-
assert len(walker.manifest) == 100 # pasa aunque dedupe solo cubra estado "ok"
|
|
332
|
-
```
|
|
333
|
-
|
|
334
|
-
### Aplicabilidad
|
|
335
|
-
|
|
336
|
-
- Walkers de filesystem que marcan progreso en un manifest
|
|
337
|
-
- Workers con dead-letter queue que reintentan mensajes fallidos
|
|
338
|
-
- ETL con checkpoints parciales
|
|
339
|
-
- Migradores de datos con strategy `upsert` o `insert or ignore`
|
|
340
|
-
- Cualquier job que tolere interrupción y reanudación
|
|
1
|
+
---
|
|
2
|
+
name: testing-python
|
|
3
|
+
description: Testing Python con pytest. Fixtures, mocking, parametrize, cobertura, factories, testing async y patrones de test doubles. TDD y testing de capas separadas.
|
|
4
|
+
version: "1.2.1"
|
|
5
|
+
herramientasPermitidas: [Read, Bash]
|
|
6
|
+
exclusiones:
|
|
7
|
+
- "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."
|
|
8
|
+
- "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."
|
|
9
|
+
- "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."
|
|
10
|
+
- "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`."
|
|
11
|
+
evolvable: true # default para skill estandar
|
|
12
|
+
evolved: true
|
|
13
|
+
evolved-from: "5.12.3"
|
|
14
|
+
evolved-at: "2026-04-25"
|
|
15
|
+
evolved-by: "aprender"
|
|
16
|
+
evolved-note: "2 gotchas nuevos: chdir vs __dirname y sanitizar-antes-de-truncar"
|
|
17
|
+
---
|
|
18
|
+
# Testing Python con pytest
|
|
19
|
+
|
|
20
|
+
## Cuándo NO cargar
|
|
21
|
+
|
|
22
|
+
- Los tests son de FastAPI con AsyncClient/httpx — cargar `fastapi-experto` para el contexto de fixtures de ese framework.
|
|
23
|
+
- Los tests son de Django con pytest-django y `@pytest.mark.django_db` — cargar `django-experto`.
|
|
24
|
+
- La tarea es auditar la cobertura existente — cargar `checklist-calidad` para métricas de calidad.
|
|
25
|
+
|
|
26
|
+
## Principios de testing
|
|
27
|
+
|
|
28
|
+
- **Un test verifica un solo comportamiento** — no múltiples cosas a la vez.
|
|
29
|
+
- **Los tests son documentación** — el nombre del test debe describir QUÉ se prueba.
|
|
30
|
+
- **Test doubles solo cuando es necesario** — no mockear lo que puedes usar real.
|
|
31
|
+
- **Tests rápidos > tests lentos** — unitarios son milisegundos; de integración, segundos.
|
|
32
|
+
- **Cobertura de líneas no es meta** — cobertura de comportamientos sí lo es.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Estructura de proyecto de tests
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
tests/
|
|
40
|
+
├── conftest.py # Fixtures compartidos globalmente
|
|
41
|
+
├── unit/ # Tests unitarios (sin I/O)
|
|
42
|
+
│ ├── conftest.py
|
|
43
|
+
│ ├── test_factura_service.py
|
|
44
|
+
│ └── test_validaciones.py
|
|
45
|
+
├── integration/ # Tests con BD real (o en memoria)
|
|
46
|
+
│ ├── conftest.py
|
|
47
|
+
│ └── test_factura_api.py
|
|
48
|
+
└── e2e/ # Tests end-to-end (opcional)
|
|
49
|
+
└── test_flujo_facturacion.py
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Nomenclatura de tests
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
# Patrón: test_<qué>_<condición>_<resultado_esperado>
|
|
58
|
+
def test_calcular_iva_monto_positivo_retorna_monto_correcto(): ...
|
|
59
|
+
def test_calcular_iva_monto_negativo_lanza_valor_error(): ...
|
|
60
|
+
def test_crear_factura_cliente_inactivo_lanza_error_negocio(): ...
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Fixtures
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
# conftest.py
|
|
69
|
+
import pytest
|
|
70
|
+
from decimal import Decimal
|
|
71
|
+
|
|
72
|
+
@pytest.fixture
|
|
73
|
+
def monto_valido() -> Decimal:
|
|
74
|
+
return Decimal("1000.00")
|
|
75
|
+
|
|
76
|
+
@pytest.fixture
|
|
77
|
+
def factura_data() -> dict:
|
|
78
|
+
return {
|
|
79
|
+
"folio": "F-001",
|
|
80
|
+
"fecha": "2026-03-25",
|
|
81
|
+
"subtotal": Decimal("1000.00"),
|
|
82
|
+
"cliente_id": "cliente-123",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# Fixture con scope para BD — se crea una vez por sesión
|
|
86
|
+
@pytest.fixture(scope="session")
|
|
87
|
+
def engine():
|
|
88
|
+
from sqlalchemy import create_engine
|
|
89
|
+
engine = create_engine("sqlite:///:memory:")
|
|
90
|
+
Base.metadata.create_all(engine)
|
|
91
|
+
yield engine
|
|
92
|
+
Base.metadata.drop_all(engine)
|
|
93
|
+
|
|
94
|
+
@pytest.fixture
|
|
95
|
+
def db(engine):
|
|
96
|
+
from sqlalchemy.orm import Session
|
|
97
|
+
with Session(engine) as session:
|
|
98
|
+
yield session
|
|
99
|
+
session.rollback() # Limpiar después de cada test
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Parametrize — probar múltiples casos
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
import pytest
|
|
108
|
+
from decimal import Decimal
|
|
109
|
+
|
|
110
|
+
@pytest.mark.parametrize("monto,tasa,esperado", [
|
|
111
|
+
(Decimal("100.00"), 0.16, Decimal("16.00")),
|
|
112
|
+
(Decimal("0.00"), 0.16, Decimal("0.00")),
|
|
113
|
+
(Decimal("999.99"), 0.16, Decimal("159.998")),
|
|
114
|
+
])
|
|
115
|
+
def test_calcular_iva(monto, tasa, esperado):
|
|
116
|
+
resultado = calcular_iva(monto, tasa)
|
|
117
|
+
assert resultado == pytest.approx(float(esperado), rel=1e-4)
|
|
118
|
+
|
|
119
|
+
# Parametrize con IDs descriptivos
|
|
120
|
+
@pytest.mark.parametrize("estatus,puede_cancelar", [
|
|
121
|
+
pytest.param("borrador", True, id="borrador-puede-cancelar"),
|
|
122
|
+
pytest.param("cancelada", False, id="cancelada-ya-cancelada"),
|
|
123
|
+
])
|
|
124
|
+
def test_factura_puede_cancelar(estatus, puede_cancelar):
|
|
125
|
+
factura = Factura(estatus=estatus)
|
|
126
|
+
assert factura.puede_cancelar() == puede_cancelar
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Mocking — reglas clave
|
|
132
|
+
|
|
133
|
+
- Mockear en el boundary: BD, APIs externas, filesystem, reloj.
|
|
134
|
+
- NUNCA mockear código propio — señal de acoplamiento excesivo.
|
|
135
|
+
- Usar `pytest-mock` (mocker) sobre `unittest.mock.patch` para mayor limpieza.
|
|
136
|
+
- `AsyncMock` para funciones async.
|
|
137
|
+
|
|
138
|
+
Para ejemplos completos de mocking, testing async y factories, ver [recursos/ejemplos-completos.md](recursos/ejemplos-completos.md).
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Factories con factory_boy — resumen
|
|
143
|
+
|
|
144
|
+
Usar factories sobre fixtures hardcodeados. Permiten sobreescribir solo lo relevante:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
# tests/factories.py — definir factories centralizados
|
|
148
|
+
class FacturaFactory(factory.Factory):
|
|
149
|
+
class Meta:
|
|
150
|
+
model = Factura
|
|
151
|
+
estatus = "borrador"
|
|
152
|
+
cliente = factory.SubFactory(ClienteFactory)
|
|
153
|
+
|
|
154
|
+
# En el test — solo los datos que importan
|
|
155
|
+
factura = FacturaFactory(estatus="pagada", total=Decimal("1000.00"))
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Para ejemplos completos de factories con factory_boy, ver [recursos/ejemplos-completos.md](recursos/ejemplos-completos.md).
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## Cobertura de tests
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
# Ejecutar con cobertura
|
|
166
|
+
pytest --cov=app --cov-report=term-missing --cov-report=html
|
|
167
|
+
|
|
168
|
+
# Requerir cobertura mínima (falla si no se alcanza)
|
|
169
|
+
pytest --cov=app --cov-fail-under=85
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
```toml
|
|
173
|
+
# pyproject.toml
|
|
174
|
+
[tool.coverage.run]
|
|
175
|
+
source = ["app"]
|
|
176
|
+
omit = ["app/migrations/*", "app/main.py", "*/tests/*"]
|
|
177
|
+
|
|
178
|
+
[tool.coverage.report]
|
|
179
|
+
exclude_lines = [
|
|
180
|
+
"pragma: no cover",
|
|
181
|
+
"def __repr__",
|
|
182
|
+
"if TYPE_CHECKING:",
|
|
183
|
+
"raise NotImplementedError",
|
|
184
|
+
]
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Markers y organización
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
# Registrar markers en pyproject.toml
|
|
193
|
+
# [tool.pytest.ini_options]
|
|
194
|
+
# markers = [
|
|
195
|
+
# "slow: tests que tardan más de 1 segundo",
|
|
196
|
+
# "integration: tests que requieren BD o red",
|
|
197
|
+
# "unit: tests puramente unitarios",
|
|
198
|
+
# ]
|
|
199
|
+
|
|
200
|
+
@pytest.mark.slow
|
|
201
|
+
@pytest.mark.integration
|
|
202
|
+
async def test_proceso_completo_facturacion(): ...
|
|
203
|
+
|
|
204
|
+
# pytest -m "not integration" # Ejecutar solo unitarios
|
|
205
|
+
# pytest -x # Parar al primer fallo
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## Anti-patrones principales
|
|
211
|
+
|
|
212
|
+
- **Test que verifica demasiado**: un solo test con 10+ asserts sobre comportamientos distintos. Dividir en tests separados.
|
|
213
|
+
- **Lógica de negocio en tests**: duplicar if/else del código de producción en el test. Usar valores concretos con parametrize.
|
|
214
|
+
- **Sleep en tests**: NUNCA `time.sleep()`. Mockear el reloj con `freezegun`.
|
|
215
|
+
|
|
216
|
+
Para ejemplos detallados MAL vs BIEN de anti-patrones, ver [recursos/ejemplos-completos.md](recursos/ejemplos-completos.md).
|
|
217
|
+
|
|
218
|
+
## Gotchas / Errores comunes no obvios
|
|
219
|
+
|
|
220
|
+
- **`@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.
|
|
221
|
+
- **`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.
|
|
222
|
+
- **`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.
|
|
223
|
+
- **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.
|
|
224
|
+
- **`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`.
|
|
225
|
+
- **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.
|
|
226
|
+
|
|
227
|
+
## Refactorizar parsers: fixtures multi-formato ANTES del cambio
|
|
228
|
+
|
|
229
|
+
### SIEMPRE: tener fixtures de cada formato soportado antes de modificar un parser
|
|
230
|
+
|
|
231
|
+
**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).
|
|
232
|
+
|
|
233
|
+
**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.
|
|
234
|
+
|
|
235
|
+
**Regla operativa**:
|
|
236
|
+
|
|
237
|
+
1. Crear `tests/fixtures/[dominio]/[nombre]-[formato].ext` para CADA formato conocido **antes** de tocar el parser.
|
|
238
|
+
2. Escribir tests de conteo/identidad para AMBOS formatos (ej: "produce exactamente 13 IDs canónicos") ANTES del cambio.
|
|
239
|
+
3. Modificar el regex/parser.
|
|
240
|
+
4. Verificar que AMBOS tests siguen verdes. Si uno se rompe, revertir y acotar más el cambio.
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
# BIEN — fixtures explícitos de cada formato soportado, test antes del fix
|
|
244
|
+
FIXTURE_CANONICO = Path("tests/fixtures/cedulas/cedula-formato-v1.md")
|
|
245
|
+
FIXTURE_REEXTRAIDO = Path("tests/fixtures/cedulas/cedula-formato-v2.md")
|
|
246
|
+
|
|
247
|
+
@pytest.mark.parametrize("fixture", [FIXTURE_CANONICO, FIXTURE_REEXTRAIDO])
|
|
248
|
+
def test_parser_produce_13_ids_canonicos(fixture):
|
|
249
|
+
texto = fixture.read_text(encoding="utf-8")
|
|
250
|
+
ids = [h.get("id") for h in extraer(texto)]
|
|
251
|
+
assert len(ids) == 13
|
|
252
|
+
assert "X.X.x" not in ids # no debe caer al fallback legacy
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
**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.
|
|
256
|
+
|
|
257
|
+
**Relacionado**: patrón "characterization test" de Michael Feathers — capturar el comportamiento actual como fixture byte-exact antes de refactorizar.
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## Tests de idempotencia requieren 2 ejecuciones + diff del estado
|
|
264
|
+
|
|
265
|
+
### Regla
|
|
266
|
+
|
|
267
|
+
Para cualquier pipeline **resumable**, **reentrante** o **idempotente por diseño**
|
|
268
|
+
(walkers que marcan estado en cada paso, workers que dedupean por clave, jobs que
|
|
269
|
+
continúan donde se interrumpieron), el test unitario que pasa con 1 ejecución es
|
|
270
|
+
insuficiente. Se necesitan **2 ejecuciones consecutivas del mismo input** y un
|
|
271
|
+
assert sobre el **diff del estado**.
|
|
272
|
+
|
|
273
|
+
### Por qué
|
|
274
|
+
|
|
275
|
+
El bug más frecuente en pipelines resumables es que el dedupe solo considera
|
|
276
|
+
estado terminal (`estado == "ok"`), ignorando estados intermedios (`descubierto`,
|
|
277
|
+
`en_proceso`). En la segunda corrida, los ítems en estado intermedio se duplican
|
|
278
|
+
aunque el walker "sabe" que ya los vio. Este bug **nunca aparece** en tests
|
|
279
|
+
unitarios que solo verifican una corrida — se necesita la corrida N+1 para
|
|
280
|
+
observar la duplicación.
|
|
281
|
+
|
|
282
|
+
### Patrón canónico
|
|
283
|
+
|
|
284
|
+
```python
|
|
285
|
+
def test_walker_resumable_no_duplica_en_corridas_sucesivas(tmp_path):
|
|
286
|
+
# Arrange — dataset con 100 archivos
|
|
287
|
+
fuente = crear_fuente_con_100_archivos(tmp_path)
|
|
288
|
+
manifest = tmp_path / "manifest.jsonl"
|
|
289
|
+
|
|
290
|
+
# Act corrida 1
|
|
291
|
+
walker = Walker(fuente=fuente, manifest=manifest)
|
|
292
|
+
walker.ejecutar()
|
|
293
|
+
manifest_despues_1 = manifest.read_text().splitlines()
|
|
294
|
+
|
|
295
|
+
# Act corrida 2 (re-ejecución completa, sin reset)
|
|
296
|
+
walker2 = Walker(fuente=fuente, manifest=manifest)
|
|
297
|
+
walker2.ejecutar()
|
|
298
|
+
manifest_despues_2 = manifest.read_text().splitlines()
|
|
299
|
+
|
|
300
|
+
# Assert 1: la segunda corrida NO agrega entradas duplicadas
|
|
301
|
+
diff = len(manifest_despues_2) - len(manifest_despues_1)
|
|
302
|
+
assert diff == 0, (
|
|
303
|
+
f"Corrida 2 agregó {diff} entradas. El dedupe está ignorando "
|
|
304
|
+
f"algún estado intermedio. Manifest antes={len(manifest_despues_1)}, "
|
|
305
|
+
f"después={len(manifest_despues_2)}."
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# Assert 2: todas las entradas son únicas por su clave de dedupe (SHA)
|
|
309
|
+
shas = [json.loads(l)["sha256"] for l in manifest_despues_2]
|
|
310
|
+
assert len(shas) == len(set(shas)), "Hay SHAs duplicados en manifest"
|
|
311
|
+
|
|
312
|
+
# Assert 3: el count final coincide con el dataset fuente
|
|
313
|
+
assert len(manifest_despues_2) == 100
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### Reglas
|
|
317
|
+
|
|
318
|
+
- **Dos corridas exactas** — mismo input, mismo código, diferente momento. No resetear estado entre corridas; eso simula el caso "pipeline interrumpido y retomado".
|
|
319
|
+
- **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.
|
|
320
|
+
- **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.
|
|
321
|
+
- **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".
|
|
322
|
+
- **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.
|
|
323
|
+
|
|
324
|
+
### Anti-patrón
|
|
325
|
+
|
|
326
|
+
```python
|
|
327
|
+
# MAL — una sola corrida; el bug de dedupe parcial pasa desapercibido
|
|
328
|
+
def test_walker_procesa_100_archivos(tmp_path):
|
|
329
|
+
walker = Walker(fuente=crear_100_archivos(tmp_path))
|
|
330
|
+
walker.ejecutar()
|
|
331
|
+
assert len(walker.manifest) == 100 # pasa aunque dedupe solo cubra estado "ok"
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
### Aplicabilidad
|
|
335
|
+
|
|
336
|
+
- Walkers de filesystem que marcan progreso en un manifest
|
|
337
|
+
- Workers con dead-letter queue que reintentan mensajes fallidos
|
|
338
|
+
- ETL con checkpoints parciales
|
|
339
|
+
- Migradores de datos con strategy `upsert` o `insert or ignore`
|
|
340
|
+
- Cualquier job que tolere interrupción y reanudación
|