@saulwade/swl-ses 1.3.7 → 1.4.0

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.
Files changed (129) hide show
  1. package/CLAUDE.md +12 -4
  2. package/README.md +1 -1
  3. package/bin/swl-mcp-server.js +187 -187
  4. package/bin/swl-webhook-server.js +198 -0
  5. package/comandos/swl/.evolved.json +22 -22
  6. package/comandos/swl/adoptar-proyecto.md +21 -1
  7. package/comandos/swl/claudemd.md +14 -1
  8. package/comandos/swl/contribuir.md +233 -233
  9. package/comandos/swl/exportar-vault.md +207 -7
  10. package/comandos/swl/nuevo-proyecto.md +24 -2
  11. package/gateway/adapters/base.js +109 -0
  12. package/gateway/adapters/discord.js +167 -0
  13. package/gateway/adapters/email.js +221 -0
  14. package/gateway/adapters/slack.js +192 -0
  15. package/gateway/adapters/telegram.js +183 -0
  16. package/gateway/adapters/webhook.js +113 -0
  17. package/gateway/adapters/whatsapp.js +214 -0
  18. package/gateway/agent-executor.js +322 -0
  19. package/gateway/command-relay.js +271 -0
  20. package/gateway/cron/jobs.js +263 -0
  21. package/gateway/cron/scheduler.js +322 -0
  22. package/gateway/cron/store.js +335 -0
  23. package/gateway/index.js +320 -0
  24. package/gateway/lib/event-channel.js +191 -0
  25. package/gateway/session.js +131 -0
  26. package/gateway/webhook-server.js +324 -0
  27. package/habilidades/backend-production-resilience/SKILL.md +288 -288
  28. package/habilidades/benchmark-memoria/SKILL.md +186 -186
  29. package/habilidades/build-errors-nextjs/SKILL.md +55 -1
  30. package/habilidades/diagrama-arquitectura/assets/template.html +276 -276
  31. package/habilidades/doubt-driven-review/SKILL.md +171 -171
  32. package/habilidades/doubt-driven-review/recursos/EXAMPLES.md +130 -130
  33. package/habilidades/eval-framework/SKILL.md +212 -212
  34. package/habilidades/extractor-de-aprendizajes/SKILL.md +24 -10
  35. package/habilidades/harness-claude-code/SKILL.md +299 -299
  36. package/habilidades/infra-github-actions/SKILL.md +166 -166
  37. package/habilidades/legacy-code-rescue/SKILL.md +267 -267
  38. package/habilidades/manejo-errores/.evolved.json +8 -8
  39. package/habilidades/meta-skills-estandar/recursos/convencion-examples.md +93 -93
  40. package/habilidades/meta-skills-estandar/recursos/skills-as-agents.md +163 -163
  41. package/habilidades/nextjs-testing/SKILL.md +89 -5
  42. package/habilidades/node-experto/SKILL.md +37 -1
  43. package/habilidades/patrones-python/SKILL.md +229 -229
  44. package/habilidades/patrones-python/recursos/patrones-avanzados.md +469 -469
  45. package/habilidades/planear-fase/SKILL.md +319 -319
  46. package/habilidades/react-experto/SKILL.md +45 -4
  47. package/habilidades/release-semver/.evolved.json +8 -8
  48. package/habilidades/swl-claudemd/SKILL.md +15 -1
  49. package/habilidades/tdd-workflow/SKILL.md +36 -4
  50. package/habilidades/testing-python/SKILL.md +340 -340
  51. package/hooks/claudemd-bloat-detector.js +161 -161
  52. package/hooks/inyeccion-contexto.js +8 -3
  53. package/hooks/lib/agent-routing.js +107 -107
  54. package/hooks/lib/auto-consolidator.js +335 -335
  55. package/hooks/lib/error-classifier.js +308 -308
  56. package/hooks/lib/merkle-audit.js +96 -96
  57. package/hooks/lib/provenance-tracker.js +191 -191
  58. package/hooks/lib/rate-limit-ip.js +177 -0
  59. package/hooks/lib/rate-limit-tracker.js +253 -253
  60. package/hooks/lib/resource-quota.js +122 -122
  61. package/hooks/lib/retry-jitter.js +165 -165
  62. package/hooks/lib/skill-auditor.js +588 -588
  63. package/hooks/lib/sync-status.js +228 -228
  64. package/hooks/lib/taint-tracker.js +107 -107
  65. package/hooks/lib/text-similarity.js +241 -241
  66. package/hooks/lib/toon-compressor.js +245 -245
  67. package/hooks/lib/webhook-dedup.js +184 -0
  68. package/hooks/lib/webhook-verify.js +123 -0
  69. package/hooks/proteccion-rutas.js +120 -15
  70. package/hooks/registro-turnos.js +209 -209
  71. package/hooks/sugerir-regenerar-inventario.js +170 -170
  72. package/hooks/validar-formato-post-subagente.js +140 -140
  73. package/hooks/validar-memoria-hook.js +218 -218
  74. package/instintos/prompt-appendices.yaml +57 -57
  75. package/manifiestos/agent-output-schemas.json +57 -57
  76. package/manifiestos/modulos.json +1 -0
  77. package/manifiestos/skills-lock.json +37 -37
  78. package/package.json +5 -3
  79. package/plantillas/auditor-veto-template.md +105 -105
  80. package/plantillas/github-workflows/README.md +47 -47
  81. package/plantillas/github-workflows/release-please.yml +44 -44
  82. package/plantillas/github-workflows/swl-ci.yml +107 -107
  83. package/plantillas/github-workflows/swl-security.yml +51 -51
  84. package/plugin.json +1 -1
  85. package/reglas/analisis-previo-tareas-grandes.md +172 -172
  86. package/reglas/arreglar-al-detectar.md +147 -147
  87. package/reglas/fragmentos-compartidos.md +152 -152
  88. package/reglas/harness-claude-code.md +213 -213
  89. package/reglas/usar-context7.md +226 -226
  90. package/reglas/usar-sistema-swl.md +251 -0
  91. package/schemas/diary-entry.schema.json +80 -80
  92. package/scripts/benchmark-memoria.js +167 -167
  93. package/scripts/comandos/skills.js +251 -2
  94. package/scripts/configurar-branch-protection.js +418 -418
  95. package/scripts/detectar-aprendizajes-duplicados.js +151 -151
  96. package/scripts/field-report.js +199 -199
  97. package/scripts/generar-checklists-consolidados.js +273 -273
  98. package/scripts/generar-inventario.js +420 -420
  99. package/scripts/generar-matriz-lenguajes.js +271 -271
  100. package/scripts/lib/artefactos-python.js +43 -43
  101. package/scripts/lib/benchmark-metrics.js +160 -160
  102. package/scripts/lib/budget-enforcer.js +252 -252
  103. package/scripts/lib/configurar-ci.js +380 -380
  104. package/scripts/lib/contadores-inventario.js +217 -217
  105. package/scripts/lib/detectar-stack-detallado.js +307 -307
  106. package/scripts/lib/diary-entry.js +234 -234
  107. package/scripts/lib/eval-metrics-store.js +218 -218
  108. package/scripts/lib/eval-quality.js +171 -171
  109. package/scripts/lib/eval-schemas.js +144 -144
  110. package/scripts/lib/eval-self-correct.js +106 -106
  111. package/scripts/lib/eval-validator.js +185 -185
  112. package/scripts/lib/jaccard-similarity.js +98 -98
  113. package/scripts/lib/longmemeval-runner.js +125 -125
  114. package/scripts/lib/npm-version.js +261 -261
  115. package/scripts/lib/paquetes-conocidos.js +50 -50
  116. package/scripts/lib/prompt-builder.js +264 -264
  117. package/scripts/lib/rrf-fusion.js +175 -175
  118. package/scripts/lib/scoring-instintos.js +277 -277
  119. package/scripts/lib/semantic-search.js +252 -252
  120. package/scripts/limpiar-artefactos-python.js +131 -131
  121. package/scripts/mcp-server/README.md +128 -128
  122. package/scripts/mcp-server/handlers.js +206 -206
  123. package/scripts/migrar-csv-a-array.js +168 -168
  124. package/scripts/migrar-fase-dominio.js +201 -201
  125. package/scripts/publicar.js +511 -511
  126. package/scripts/run-eval.js +141 -141
  127. package/scripts/validar-manifest.js +195 -195
  128. package/scripts/validar-userland-vacio.js +110 -110
  129. package/scripts/verificar-release.js +110 -0
@@ -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