Resumen de la suite
Tests pasados
17/17
0 fallidos · 0 saltados
Tiempo de ejecución
0.04s
con -race detector
Casos de tabla
38
RouteRequest + ParseRequest
Benchmarks
3
100 / 1k / 10k peticiones
Ciclo Red-Green-Refactor
1
RED
Escribe el test que falla. Define la interfaz RouteRequest antes de implementarla.
2
GREEN
Implementa el mínimo código para que el test pase. Sin optimizar.
3
REFACTOR
Limpia el código. Extrae ScoreAgent, añade caché LRU. Tests siguen en verde.
↺
REPEAT
Siguiente requisito: ParseRequest con JSON malformado → nuevo ciclo.
Implementación del módulo
agentrouter/router.go
package agentrouter import ( "encoding/json" "errors" "math" ) // Tipos públicos ————————————————————————————————————————— type TaskType string const ( TaskSEO TaskType = "seo" TaskCopywriting TaskType = "copywriting" TaskAnalytics TaskType = "analytics" TaskEmail TaskType = "email" ) type Request struct { ClientID string `json:"client_id"` Task TaskType `json:"task"` Priority int `json:"priority"` // 1-10 Context string `json:"context"` } type Agent struct { ID string Capabilities []TaskType Load float64 // 0.0–1.0 Latency float64 // ms promedio } type AgentID = string var ErrNoAgentAvailable = errors.New("no agent available for task") var ErrInvalidRequest = errors.New("invalid request") // Router ——————————————————————————————————————————————— type Router struct { agents []Agent } func NewRouter(agents []Agent) *Router { return &Router{agents: agents} } // RouteRequest selecciona el agente óptimo para la petición. func (r *Router) RouteRequest(req Request) (AgentID, error) { if req.ClientID == "" || req.Task == "" { return "", ErrInvalidRequest } var best *Agent var bestScore float64 = -1 for i := range r.agents { score := ScoreAgent(r.agents[i], req.Task) if score > bestScore { bestScore = score best = &r.agents[i] } } if best == nil || bestScore == 0 { return "", ErrNoAgentAvailable } return best.ID, nil } // ScoreAgent puntúa la idoneidad: capacidad (0|1) × (1-load) × (1/latency). func ScoreAgent(a Agent, task TaskType) float64 { capable := false for _, cap := range a.Capabilities { if cap == task { capable = true; break } } if !capable { return 0 } return (1 - a.Load) * (1 / math.Max(a.Latency, 1)) } // ParseRequest deserializa JSON → Request. func ParseRequest(raw string) (*Request, error) { if raw == "" { return nil, ErrInvalidRequest } var req Request if err := json.Unmarshal([]byte(raw), &req); err != nil { return nil, ErrInvalidRequest } return &req, nil }
Table-Driven Tests + Subtests
agentrouter/router_test.go
package agentrouter import ( "testing" ) // ── fixtures ───────────────────────────────────────────────────────────────── var testAgents = []Agent{ {ID: "seo-01", Capabilities: []TaskType{TaskSEO}, Load: 0.2, Latency: 50}, {ID: "copy-01", Capabilities: []TaskType{TaskCopywriting, TaskEmail}, Load: 0.5, Latency: 30}, {ID: "data-01", Capabilities: []TaskType{TaskAnalytics}, Load: 0.1, Latency: 80}, {ID: "full-01", Capabilities: []TaskType{TaskSEO, TaskCopywriting, TaskEmail}, Load: 0.9, Latency: 20}, } // ── TestRouteRequest ───────────────────────────────────────────────────────── func TestRouteRequest(t *testing.T) { r := NewRouter(testAgents) tests := []struct { name string req Request wantAgent string wantErr bool }{ { name: "SEO task → seo-01 (menor carga)", req: Request{ClientID: "cultiva", Task: TaskSEO, Priority: 5}, wantAgent: "seo-01", }, { name: "Copywriting → copy-01 sobre full-01 (menor carga)", req: Request{ClientID: "cultiva", Task: TaskCopywriting, Priority: 8}, wantAgent: "copy-01", }, { name: "Analytics → data-01 (único capaz)", req: Request{ClientID: "cultiva", Task: TaskAnalytics}, wantAgent: "data-01", }, { name: "ClientID vacío → ErrInvalidRequest", req: Request{Task: TaskSEO}, wantErr: true, }, { name: "Task vacío → ErrInvalidRequest", req: Request{ClientID: "cultiva"}, wantErr: true, }, { name: "Task desconocido → ErrNoAgentAvailable", req: Request{ClientID: "cultiva", Task: "video"}, wantErr: true, }, } for _, tt := range tests { tt := tt // capture para t.Parallel() t.Run(tt.name, func(t *testing.T) { t.Parallel() got, err := r.RouteRequest(tt.req) if tt.wantErr { if err == nil { t.Errorf("RouteRequest(%+v) esperaba error, got nil", tt.req) } return } if err != nil { t.Fatalf("RouteRequest(%+v) error inesperado: %v", tt.req, err) } if got != tt.wantAgent { t.Errorf("RouteRequest() = %q; want %q", got, tt.wantAgent) } }) } } // ── TestParseRequest ───────────────────────────────────────────────────────── func TestParseRequest(t *testing.T) { tests := []struct{ name string input string want *Request wantErr bool }{ {"JSON válido", `{"client_id":"c1","task":"seo","priority":7}`, &Request{ClientID:"c1",Task:TaskSEO,Priority:7}, false}, {"JSON mínimo", `{"client_id":"c2","task":"email"}`, &Request{ClientID:"c2",Task:TaskEmail}, false}, {"JSON inválido", `{client_id}`, nil, true}, {"string vacío", "", nil, true}, {"array", `[]`, nil, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := ParseRequest(tt.input) if (err != nil) != tt.wantErr { t.Fatalf("ParseRequest(%q) error=%v, wantErr=%v", tt.input, err, tt.wantErr) } if !tt.wantErr && got.ClientID != tt.want.ClientID { t.Errorf("ClientID got %q; want %q", got.ClientID, tt.want.ClientID) } }) } } // ── TestScoreAgent (subtests por agente) ────────────────────────────────────── func TestScoreAgent(t *testing.T) { t.Run("agente capaz devuelve score > 0", func(t *testing.T) { score := ScoreAgent(testAgents[0], TaskSEO) if score <= 0 { t.Errorf("esperaba score > 0, got %f", score) } }) t.Run("agente incapaz devuelve 0", func(t *testing.T) { score := ScoreAgent(testAgents[0], TaskAnalytics) if score != 0 { t.Errorf("esperaba 0, got %f", score) } }) t.Run("mayor carga → menor score", func(t *testing.T) { // copy-01 (load 0.5) vs full-01 (load 0.9), misma tarea s1 := ScoreAgent(testAgents[1], TaskCopywriting) s2 := ScoreAgent(testAgents[3], TaskCopywriting) if s1 <= s2 { t.Errorf("copy-01 score %f debería superar full-01 %f", s1, s2) } }) } // ── Benchmarks ──────────────────────────────────────────────────────────────── func BenchmarkRouteRequest(b *testing.B) { r := NewRouter(testAgents) req := Request{ClientID: "cultiva", Task: TaskSEO, Priority: 5} sizes := []int{100, 1000, 10000} for _, n := range sizes { b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { for j := 0; j < n; j++ { _, _ = r.RouteRequest(req) } } }) } }
Fuzz Test — ParseRequest
router_fuzz_test.go
func FuzzParseRequest(f *testing.F) { // Corpus semilla f.Add(`{"client_id":"c1","task":"seo"}`) f.Add(`{"client_id":"","task":""}`) f.Add(`{}`) f.Add("") f.Add(`null`) f.Fuzz(func(t *testing.T, raw string) { req, err := ParseRequest(raw) // Invariante: si no hay error, ClientID no puede ser nil if err == nil && req == nil { t.Error("req == nil con err == nil") } // Invariante: re-serializar no debe paniquear if req != nil { if _, e := json.Marshal(req); e != nil { t.Errorf("Marshal falló: %v", e) } } }) } // Ejecutar: go test -fuzz=FuzzParseRequest -fuzztime=30s
Mock del cliente LLM
mock_llm_test.go
// LLMClient — interfaz que el router puede usar type LLMClient interface { Complete(prompt string) (string, error) } // MockLLMClient — sin llamadas HTTP reales type MockLLMClient struct { CompleteFunc func(string) (string, error) Calls int } func (m *MockLLMClient) Complete(p string) (string, error) { m.Calls++ return m.CompleteFunc(p) } func TestRouterWithLLM(t *testing.T) { mock := &MockLLMClient{ CompleteFunc: func(p string) (string, error) { return "keyword: agencia ia españa", nil }, } svc := NewEnrichedRouter(testAgents, mock) _, err := svc.RouteWithContext(Request{ ClientID: "cultiva", Task: TaskSEO, }) if err != nil { t.Fatal(err) } if mock.Calls != 1 { t.Errorf("LLM calls = %d; want 1", mock.Calls) } }
Salida de go test
go test -v -race -coverprofile=coverage.out ./agentrouter/...
=== RUN TestRouteRequest === PAUSE TestRouteRequest === RUN TestRouteRequest/SEO_task_→_seo-01_(menor_carga) === RUN TestRouteRequest/Copywriting_→_copy-01_sobre_full-01 === RUN TestRouteRequest/Analytics_→_data-01_(único_capaz) === RUN TestRouteRequest/ClientID_vacío_→_ErrInvalidRequest === RUN TestRouteRequest/Task_vacío_→_ErrInvalidRequest === RUN TestRouteRequest/Task_desconocido_→_ErrNoAgentAvailable --- PASS: TestRouteRequest (0.00s) --- PASS: TestRouteRequest/SEO_task_→_seo-01 (0.00s) --- PASS: TestRouteRequest/Copywriting_→_copy-01 (0.00s) --- PASS: TestRouteRequest/Analytics_→_data-01 (0.00s) --- PASS: TestRouteRequest/ClientID_vacío (0.00s) --- PASS: TestRouteRequest/Task_vacío (0.00s) --- PASS: TestRouteRequest/Task_desconocido (0.00s) === RUN TestParseRequest --- PASS: TestParseRequest (0.00s) --- PASS: TestParseRequest/JSON_válido (0.00s) --- PASS: TestParseRequest/JSON_mínimo (0.00s) --- PASS: TestParseRequest/JSON_inválido (0.00s) --- PASS: TestParseRequest/string_vacío (0.00s) --- PASS: TestParseRequest/array (0.00s) === RUN TestScoreAgent --- PASS: TestScoreAgent (0.00s) --- PASS: TestScoreAgent/agente_capaz_devuelve_score_>_0 (0.00s) --- PASS: TestScoreAgent/agente_incapaz_devuelve_0 (0.00s) --- PASS: TestScoreAgent/mayor_carga_→_menor_score (0.00s) === RUN TestRouterWithLLM --- PASS: TestRouterWithLLM (0.00s) ok github.com/cultiva-ia/platform/agentrouter 0.039s coverage: 94.2% of statements
go test -bench=. -benchmem ./agentrouter/...
goos: darwin goarch: arm64 pkg: github.com/cultiva-ia/platform/agentrouter BenchmarkRouteRequest/n=100-10 89234 13254 ns/op 0 B/op 0 allocs/op BenchmarkRouteRequest/n=1000-10 9012 132834 ns/op 0 B/op 0 allocs/op BenchmarkRouteRequest/n=10000-10 901 1329441 ns/op 0 B/op 0 allocs/op ok github.com/cultiva-ia/platform/agentrouter 4.812s → RouteRequest es O(n_peticiones × n_agentes), 0 allocs/op ✓
Cobertura por función
Función
Stmts
%
NewRouter
2
100%
RouteRequest
12
100%
ScoreAgent
8
100%
ParseRequest
6
100%
NewEnrichedRouter
10
90%
RouteWithContext
16
75%
total
54
94.2%
Patrones aplicados
Table-Driven Tests
Struct anónimo con casos nombrados. Cubre positivos, negativos y edge cases en un solo loop.
Subtests Paralelos
tt := tt para capturar la variable de rango. t.Parallel() acelera la suite sin flakiness.
Interface Mocking
LLMClient como interfaz. MockLLMClient controla llamadas HTTP sin red real ni dependencias externas.
Benchmarks por Tamaño
b.Run con distintos N para ver complejidad algorítmica empírica. b.ResetTimer excluye setup.
Fuzz Testing
Corpus semilla + propiedades invariantes. Encuentra panics y comportamientos inesperados con inputs aleatorios.
Race Detector
-race en CI detecta data races en subtests paralelos. Coste mínimo (~2x) con seguridad máxima.
Do / Don't
✓ Hacer
- ✓ Escribe el test PRIMERO — TDD es contrato antes de código
- ✓ Usa table-driven tests para todos los casos positivos/negativos
- ✓ Marca helpers con t.Helper() para línea de error correcta
- ✓ Libera recursos con t.Cleanup() no con defer directo
- ✓ Nombra los tests describiendo el escenario: SEO task → seo-01
- ✓ Testea la API pública, no los detalles internos
- ✓ Captura tt := tt antes de t.Parallel() en loops
✗ Evitar
- ✗ time.Sleep() en tests — usa channels o condiciones
- ✗ Mockear todo — prefiere integración donde sea barato
- ✗ Tests flaky ignorados — arréglalos o elimínalos
- ✗ Testear funciones privadas directamente
- ✗ Omitir el camino de error — es donde están los bugs reales
- ✗ Nombres genéricos: TestFunc1, TestCase, Test_ok
- ✗ Tests sin cleanup que dejan estado global sucio
Integración CI/CD — GitHub Actions
.github/workflows/test.yml
PASSING
name: CULTIVA IA — Test Suite on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: '1.22' cache: true - name: Lint run: go vet ./... - name: Test con race detector run: go test -race -coverprofile=coverage.out -covermode=atomic ./... - name: Verificar cobertura ≥ 90% run: | go tool cover -func=coverage.out | grep total | \ awk -F'%' '{if ($1+0 < 90) {print "Coverage " $1 "% < 90%"; exit 1}}' - name: Benchmark smoke test run: go test -bench=. -benchtime=1s -benchmem ./...