cdcdb04d01
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
207 lines
9.1 KiB
Markdown
207 lines
9.1 KiB
Markdown
# 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.
|