API-DD

Guía práctica

El test de contraste para verificar código generado por IA

Un método con Go para comprobar que un test hace fallar una alternativa incorrecta pero plausible, en lugar de limitarse a repetir la implementación generada.

Por API-DD

Un agente recibe esta tarea:

Añade un búfer con capacidad limitada.

Implementa Push, añade tests y deja todo verde. En review parece terminado.

Sin embargo, la tarea permitía al menos dos comportamientos incompatibles cuando el búfer se llena:

Los dos son razonables. Solo uno puede ser el contrato del módulo.

Si los tests pasan con ambos, no verifican esa decisión. Verifican algo menos importante, como que el primer elemento puede insertarse.

Este artículo propone una comprobación concreta:

Un test especifica una decisión solo si hace fallar una alternativa incorrecta pero plausible.

Llamaremos test de contraste al caso diseñado para separar esas dos alternativas.

El test verde que no decide nada#

Supongamos esta API en Go:

subject, _ := buffer.New(1)
err := subject.Push("first")

El agente genera este test:

func TestPushStoresAnItem(t *testing.T) {
    subject, err := buffer.New(1)
    if err != nil {
        t.Fatalf("new buffer: %v", err)
    }

    if err := subject.Push("first"); err != nil {
        t.Fatalf("push: %v", err)
    }

    got := subject.Items()
    if len(got) != 1 || got[0] != "first" {
        t.Fatalf("expected first item, got %v", got)
    }
}

El test es correcto, pero no cubre la decisión arriesgada. Pasaría tanto con una implementación que rechaza al llenarse como con otra que sobrescribe el elemento anterior.

No falta “más coverage” en abstracto. Falta un caso que distinga dos semánticas.

Escribe primero las dos alternativas#

Antes de añadir otro test, describe el contraste sin código:

Capacidad: 1
Estado inicial: [first]
Acción: Push(second)

Contrato elegido:
  devuelve Full
  estado final: [first]

Alternativa que queremos rechazar:
  devuelve nil
  estado final: [second]

Este pequeño cuadro hace dos cosas.

Primero, obliga a confirmar una decisión que la tarea original no contenía. Si no existe en el ticket, la documentación o un consumidor actual, no debemos dejar que el agente la elija en silencio.

Segundo, muestra exactamente qué observaciones necesita el test: el resultado de Push y el contenido posterior. No necesitamos inspeccionar índices, llamadas internas ni la estructura usada por el búfer.

El test de contraste#

El caso puede escribirse así:

func TestFullBufferRejectsNewItemAndKeepsExistingOne(t *testing.T) {
    subject, err := buffer.New(1)
    if err != nil {
        t.Fatalf("new buffer: %v", err)
    }

    if err := subject.Push("first"); err != nil {
        t.Fatalf("push first item: %v", err)
    }

    err = subject.Push("second")
    if !errors.Is(err, buffer.ErrFull) {
        t.Fatalf("expected full error, got %v", err)
    }

    got := subject.Items()
    if len(got) != 1 || got[0] != "first" {
        t.Fatalf("expected first item to remain, got %v", got)
    }
}

El nombre declara la diferencia. Las dos aserciones son necesarias:

Juntas protegen la garantía: el rechazo es visible y no altera el contenido existente.

Comprueba el test con una mutación#

Después de obtener el verde, introduce durante una sola ejecución la alternativa que quieres rechazar.

La implementación correcta podría contener:

func (b *Buffer) Push(item string) error {
    if len(b.items) == b.capacity {
        return ErrFull
    }
    b.items = append(b.items, item)
    return nil
}

Sustitúyela temporalmente por el comportamiento contrario:

func (b *Buffer) Push(item string) error {
    if len(b.items) == b.capacity {
        b.items = append(b.items[1:], item)
        return nil
    }
    b.items = append(b.items, item)
    return nil
}

Ejecuta solo el caso:

go test ./buffer -run TestFullBufferRejectsNewItemAndKeepsExistingOne

El resultado esperado es un fallo semántico:

expected full error, got <nil>

Restaura la implementación y vuelve a ejecutar.

Esta mutación manual responde una pregunta que el porcentaje de cobertura no puede contestar: ¿el test detecta la decisión equivocada que nos preocupa?

No hace falta mutar cada línea. Basta con probar las alternativas que eran plausibles antes de conocer la implementación.

No derives la alternativa del diff#

El contraste debe proceder de la tarea, de los consumidores o de una decisión explícita. Si lo inventamos después de leer el código generado, corremos el riesgo de validar solamente la forma elegida por el agente.

Una secuencia útil es:

Por ejemplo, otros contrastes posibles serían:

Cada diferencia merece un caso si importa al consumidor. No merece tres casos con datos distintos si todos rechazan exactamente la misma alternativa.

Tres preguntas para revisar un test generado#

Usa estas preguntas en el PR:

Si no podemos responder la primera, el test probablemente demuestra una trivialidad. Si falla la segunda, código y prueba pueden estar repitiendo la misma invención. Si falla la tercera, el test está fijando el interior en vez del contrato.

Prompt breve para obtener el contraste#

Antes de pedir tests o implementación, añade:

Para cada decisión observable de esta tarea:

1. Propón dos comportamientos incompatibles pero plausibles.
2. Indica cuál está confirmado por el repositorio o la documentación.
3. Si ninguno está confirmado, formula una pregunta y no decidas por probabilidad.
4. Para el comportamiento confirmado, escribe el caso mínimo que haga fallar la alternativa.
5. Explica qué mutación concreta debe hacer fallar ese test.

No uses detalles de la implementación propuesta para definir el contraste.

El objetivo no es producir más tests. Es conseguir que cada test nuevo pueda nombrar la decisión que protege y la alternativa que rechaza.

Verde no significa verificado. Primero demuestra que el caso sabe reconocer una decisión equivocada.