@saulwade/swl-ses 1.3.4 → 1.3.7

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