From 5d47bc3fe3325c3a95d720b0a33ad6d75c9056e6 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Mon, 13 Apr 2026 02:01:16 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20cerrar=20issue=200023=20=E2=80=94=20tes?= =?UTF-8?q?ting=20utilities=20implementadas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- dev/issues/completed/0023-testing-utils.md | 206 +++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 dev/issues/completed/0023-testing-utils.md diff --git a/dev/issues/completed/0023-testing-utils.md b/dev/issues/completed/0023-testing-utils.md new file mode 100644 index 00000000..b2a0d090 --- /dev/null +++ b/dev/issues/completed/0023-testing-utils.md @@ -0,0 +1,206 @@ +# 0023 — Testing Utilities + +## Metadata + +| Campo | Valor | +|-------|-------| +| **ID** | 0023 | +| **Estado** | pendiente | +| **Prioridad** | media | +| **Tipo** | feature | + +## Dependencias + +Ninguna. + +--- + +## Objetivo + +Crear funciones reutilizables de testing en Go (dominio core) que eliminen el boilerplate repetitivo en tests de apps y funciones del registry: setup de HTTP servers de prueba, bases de datos temporales, fixtures, assertions estructurales y captura de side effects. + +## Contexto + +- Cada funcion y app que necesita tests construye su propio setup: `httptest.NewServer` con rutas ad-hoc, SQLite temporal con schema manual, env vars seteadas a mano con `t.Cleanup`. +- No hay helpers compartidos. El mismo patron de "crear DB temporal, insertar datos, ejecutar test, limpiar" se repite en decenas de tests con variaciones minimas. +- Go idiomatico usa `t.Helper()` y funciones que retornan cleanup funcs. Estas funciones siguen ese patron exacto. +- Al ser primitivas de testing viven en dominio `core` — son tan fundamentales como `filter_slice` o `map_slice`. + +## Arquitectura + +``` +functions/core/ + test_http_server.go -- NEW: crea httptest.Server con rutas + test_http_server.md -- NEW + test_db_setup.go -- NEW: crea SQLite temporal con schema + test_db_setup.md -- NEW + test_db_seed.go -- NEW: inserta datos semilla en test DB + test_db_seed.md -- NEW + test_fixture_load.go -- NEW: carga fixture desde JSON/YAML + test_fixture_load.md -- NEW + assert_json_equal.go -- NEW: deep compare de JSON con diff + assert_json_equal.md -- NEW + assert_contains_all.go -- NEW: verifica que slice contenga todos los elementos + assert_contains_all.md -- NEW + test_env_set.go -- NEW: setea env vars con restore func + test_env_set.md -- NEW + test_capture_logs.go -- NEW: captura log output durante test + test_capture_logs.md -- NEW + +types/core/ + test_server.md -- NEW + test_db.md -- NEW +``` + +## Diseno + +### Tipos + +```go +// TestServer wrappea httptest.Server con client preconfigurado +type TestServer struct { + URL string + Client *http.Client + Cleanup func() +} + +// TestDB wrappea una conexion SQLite temporal +type TestDB struct { + DB *sql.DB + Path string // "" si es :memory: + Cleanup func() +} +``` + +### Funciones + +| Funcion | Purity | Firma (simplificada) | +|---------|--------|---------------------| +| `test_http_server` | impure | `(t *testing.T, routes map[string]http.HandlerFunc) TestServer` | +| `test_db_setup` | impure | `(t *testing.T, schema string) TestDB` | +| `test_db_seed` | impure | `(t *testing.T, db *sql.DB, table string, rows []map[string]any) error` | +| `test_fixture_load` | impure | `(t *testing.T, path string, dst any) error` | +| `assert_json_equal` | pure | `(expected, actual []byte) (bool, string)` | +| `assert_contains_all` | pure | `(haystack []string, needles []string) (bool, []string)` | +| `test_env_set` | impure | `(t *testing.T, vars map[string]string) func()` | +| `test_capture_logs` | impure | `(t *testing.T) (*bytes.Buffer, func() []string)` | + +Todas las funciones impuras llaman a `t.Helper()` para que los errores apunten al caller en el stack trace. + +--- + +## Tareas + +### Fase 1: Tipos + funciones core + +- [ ] **1.1** Crear tipos `TestServer` y `TestDB` en `functions/core/` con `.md` en `types/core/` +- [ ] **1.2** `test_http_server` — crea `httptest.Server` con un `ServeMux` construido a partir del map de rutas, retorna `TestServer` con URL, Client y Cleanup registrado en `t.Cleanup` +- [ ] **1.3** `test_db_setup` — abre SQLite `:memory:` (o tempfile si se necesita WAL), ejecuta schema SQL, retorna `TestDB` con Cleanup registrado en `t.Cleanup` +- [ ] **1.4** `test_db_seed` — itera `rows`, construye INSERT dinamico por tabla, ejecuta en transaccion +- [ ] **1.5** `assert_json_equal` — `json.Marshal` ambos valores, compara con `reflect.DeepEqual` sobre `any`, retorna diff legible si difieren +- [ ] **1.6** `assert_contains_all` — recorre needles y acumula los faltantes, retorna `(true, nil)` o `(false, missing)` + +### Fase 2: Helpers de side effects + tests + indexado + +- [ ] **2.1** `test_fixture_load` — detecta extension `.json`/`.yaml`/`.yml`, lee archivo, decode en `dst` +- [ ] **2.2** `test_env_set` — itera el map, llama `os.Setenv`, construye restore func que restaura los valores originales (o `os.Unsetenv` si no existian) +- [ ] **2.3** `test_capture_logs` — redirige `log.SetOutput` a un `bytes.Buffer`, retorna buffer + funcion que restaura el output original y devuelve las lineas capturadas +- [ ] **2.4** Tests de cada funcion (los helpers se testean a si mismos — meta pero necesario) +- [ ] **2.5** `fn index` y verificar con `fn show` cada ID + +--- + +## Ejemplo de uso + +Test de un handler API que lee de SQLite — compone `test_http_server` + `test_db_setup` + `test_db_seed` + `assert_json_equal`: + +```go +func TestListUsersHandler(t *testing.T) { + // 1. DB temporal con schema y datos + tdb := core.TestDbSetup(t, ` + CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT); + `) + // tdb.Cleanup se ejecuta automaticamente al final del test + + core.TestDbSeed(t, tdb.DB, "users", []map[string]any{ + {"id": 1, "name": "Alice", "email": "alice@example.com"}, + {"id": 2, "name": "Bob", "email": "bob@example.com"}, + }) + + // 2. Handler que usa la DB + handler := func(w http.ResponseWriter, r *http.Request) { + rows, _ := tdb.DB.Query("SELECT id, name, email FROM users ORDER BY id") + defer rows.Close() + var users []map[string]any + for rows.Next() { + var id int; var name, email string + rows.Scan(&id, &name, &email) + users = append(users, map[string]any{"id": id, "name": name, "email": email}) + } + json.NewEncoder(w).Encode(users) + } + + // 3. Server de prueba con la ruta + ts := core.TestHttpServer(t, map[string]http.HandlerFunc{ + "GET /api/users": handler, + }) + // ts.Cleanup se ejecuta automaticamente + + // 4. Request y assertion + resp, _ := ts.Client.Get(ts.URL + "/api/users") + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + expected := `[{"id":1,"name":"Alice","email":"alice@example.com"},{"id":2,"name":"Bob","email":"bob@example.com"}]` + if ok, diff := core.AssertJsonEqual([]byte(expected), body); !ok { + t.Errorf("response mismatch:\n%s", diff) + } +} +``` + +```go +// Env vars aisladas para un test +func TestConfigFromEnv(t *testing.T) { + restore := core.TestEnvSet(t, map[string]string{ + "DB_PATH": "/tmp/test.db", + "API_PORT": "9999", + }) + defer restore() + + cfg := LoadConfig() + if cfg.Port != 9999 { + t.Errorf("expected port 9999, got %d", cfg.Port) + } +} +``` + +```go +// Captura de logs para verificar side effects +func TestLogOutput(t *testing.T) { + buf, finish := core.TestCaptureLogs(t) + _ = buf // acceso directo al buffer si se necesita + + DoSomethingThatLogs() + + lines := finish() // restaura log output, retorna lineas + ok, missing := core.AssertContainsAll(lines, []string{"starting", "completed"}) + if !ok { + t.Errorf("missing log lines: %v", missing) + } +} +``` + +## Decisiones de diseno + +- **Dominio `core`:** son primitivas de testing tan genericas como las funciones de slice o map. No dependen de ningun dominio especifico. +- **Cleanup via `t.Cleanup` + return:** todas las funciones registran cleanup en `t.Cleanup` para que sea automatico, pero tambien retornan la funcion cleanup para uso explicito con `defer` cuando el orden importa. +- **`:memory:` por defecto en test DB:** evita archivos temporales huerfanos. Si un test necesita WAL o acceso concurrente, `test_db_setup` acepta un flag para crear un tempfile en su lugar. +- **Assertions retornan `(bool, info)` en vez de llamar a `t.Fatal`:** las funciones puras no reciben `*testing.T`. El caller decide si es `t.Error` o `t.Fatal`. Esto mantiene la pureza de `assert_json_equal` y `assert_contains_all`. +- **`test_db_seed` con `[]map[string]any`:** maximo flexibilidad sin necesidad de definir structs. Las keys del map son columnas, los values se insertan con `?` placeholders. Soporta cualquier tabla sin generacion de codigo. +- **No es un framework de testing:** son funciones sueltas que se componen. No reemplazan `testing.T` ni introducen un DSL de assertions. Se usan con `if` y `t.Errorf` estandar de Go. + +## Riesgos + +- **Dependencia de `database/sql` + SQLite driver en core:** las funciones `test_db_*` necesitan el driver SQLite. Mitigado porque el registry ya usa `mattn/go-sqlite3` globalmente con `-tags fts5`, asi que no es una dependencia nueva. +- **Scope creep hacia un test framework:** mitigado con la regla de que cada funcion es atomica y no hay estado compartido entre ellas. Si se necesitan mas assertions, se anaden como funciones individuales, no como metodos de un `TestSuite`. +- **`test_capture_logs` con `log.SetOutput` global:** en tests paralelos (`t.Parallel`) esto puede capturar logs de otros tests. Documentar que `test_capture_logs` no es safe para tests paralelos, o usar un `log.Logger` propio pasado como parametro.