# 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.