Files
fn_registry/dev/issues/completed/0023-testing-utils.md
T

222 lines
9.3 KiB
Markdown

---
id: "0023"
title: "Testing Utilities"
status: completado
type: feature
domain: []
scope: multi-app
priority: media
depends: []
blocks: []
related: []
created: 2026-05-17
updated: 2026-05-17
tags: []
---
# 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.