TDD · Go 1.22 · stdlib only

Testing AgentRouter con Go

Implementación completa de tests idiomáticos para el módulo de enrutamiento de agentes IA de CULTIVA: table-driven tests, subtests paralelos, benchmarks, fuzzing y mocks sin dependencias externas.

📦github.com/cultiva-ia/platform
🔵Go 1.22
🧪17 tests · 3 benchmarks · 1 fuzz
go test -race ./...
Cobertura total
94%
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
IMPL
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
TEST BENCH
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
FUZZ
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
MOCK
// 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.
[]struct{...} t.Run t.Parallel
🔀
Subtests Paralelos
tt := tt para capturar la variable de rango. t.Parallel() acelera la suite sin flakiness.
capture var t.Parallel()
🎭
Interface Mocking
LLMClient como interfaz. MockLLMClient controla llamadas HTTP sin red real ni dependencias externas.
interface stdlib only CallCount
Benchmarks por Tamaño
b.Run con distintos N para ver complejidad algorítmica empírica. b.ResetTimer excluye setup.
b.ResetTimer -benchmem 0 allocs
🌀
Fuzz Testing
Corpus semilla + propiedades invariantes. Encuentra panics y comportamientos inesperados con inputs aleatorios.
f.Add corpus f.Fuzz Go 1.18+
🔍
Race Detector
-race en CI detecta data races en subtests paralelos. Coste mínimo (~2x) con seguridad máxima.
-race -cover CI/CD
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 ./...