AI in Production -- Parte 7

Testeando Funcionalidades de IA: Tests Fiables para un Componente que No lo Es

#ai #architecture #testing #dotnet #quality

El Problema

Has escrito la funcionalidad de IA. Quieres escribir tests. Abres un nuevo archivo de test y tecleas:

var result = await _ai.SummarizeAsync(document.Body);
Assert.Equal("This document is about...", result);

Y entonces paras. Porque ese test va a fallar. No porque el código esté mal — sino porque el modelo va a devolver un texto diferente cada vez. Diferente redacción, diferente longitud, quizás una estructura distinta. Así funcionan los modelos de lenguaje. Misma entrada, salida diferente.

Entonces, ¿qué testeas?

A developer stares at their screen showing Assert.Equal with a giant red X. On a whiteboard behind them: "same input ≠ same output" with arrows going in multiple directions. Confused expression, scratching their head.

La mayoría de los equipos acaban en uno de dos sitios malos. O no testean la capa de IA en absoluto (“es no determinista, ¿para qué?”) — y encuentran bugs en producción. O hacen mock de todo tan agresivamente que sus tests nunca tocan el comportamiento real de la IA — y siguen encontrando bugs en producción, solo que diferentes.

La respuesta correcta son tres capas separadas de testing, cada una con un objetivo distinto:

Capa 1 — Tests unitarios. Haz mock de la IA por completo. Testea la lógica de tu aplicación. ¿El controller maneja bien una respuesta null? ¿El enricher salta los documentos ya enriquecidos? ¿El middleware de consentimiento bloquea las peticiones correctas? Estos tests son rápidos, no llaman a ningún servicio externo, y cubren el código alrededor de la IA.

Capa 2 — Tests de integración. Testea los caminos de fallo con fake HTTP handlers. ¿El circuit breaker se abre tras 5 fallos? ¿El fallback devuelve null en vez de lanzar una excepción? Estos tests validan el comportamiento de resiliencia del artículo 2, sin necesitar un proveedor de IA real.

Capa 3 — Tests de eval. Llama al modelo real con un conjunto fijo de entradas. No hagas assert de la salida exacta — haz assert de criterios de calidad. ¿La respuesta no está vacía? ¿Cumple una longitud mínima? ¿Sigue la estructura esperada? ¿Evita patrones de rechazo? Se ejecutan con menos frecuencia, cuestan tokens reales, y detectan regresiones cuando el modelo cambia.

Cada capa responde a una pregunta diferente. Juntas, te dan cobertura real.

Three stacked layers like a building: bottom layer "UNIT TESTS" with a developer holding a puppet AI, middle layer "INTEGRATION TESTS" with a tripped circuit breaker, top layer "EVAL TESTS" with a robot grading papers with a rubric. Each has a clock showing increasing time.

Ejecución

Capa 1 — Tests unitarios con un servicio de IA mockeado

La interfaz del artículo 2 (IAiSummaryService) hace esto fácil. Puedes reemplazar la implementación real con un fake controlado.

Usando NSubstitute (funciona con cualquier librería de mocking):

[Fact]
public async Task GetSummary_WhenAiReturnsNull_ReturnsAiUnavailable()
{
    // Arrange
    var ai = Substitute.For<IAiSummaryService>();
    ai.SummarizeAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
        .Returns((string?)null);

    var documents = Substitute.For<IDocumentRepository>();
    documents.GetByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
        .Returns(new Document { Id = Guid.NewGuid(), Title = "Test", Body = "Body text" });

    var controller = new DocumentsController(documents, ai);

    // Act
    var result = await controller.GetSummary(Guid.NewGuid(), CancellationToken.None);

    // Assert
    var ok = Assert.IsType<OkObjectResult>(result);
    var body = Assert.IsAssignableFrom<dynamic>(ok.Value);
    Assert.False((bool)body.AiAvailable);
}

[Fact]
public async Task GetSummary_WhenAiReturnsText_ReturnsSummary()
{
    // Arrange
    var ai = Substitute.For<IAiSummaryService>();
    ai.SummarizeAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
        .Returns("A brief summary of the document.");

    var documents = Substitute.For<IDocumentRepository>();
    documents.GetByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
        .Returns(new Document { Id = Guid.NewGuid(), Title = "Test", Body = "Body text" });

    var controller = new DocumentsController(documents, ai);

    // Act
    var result = await controller.GetSummary(Guid.NewGuid(), CancellationToken.None);

    // Assert
    var ok = Assert.IsType<OkObjectResult>(result);
    var body = Assert.IsAssignableFrom<dynamic>(ok.Value);
    Assert.True((bool)body.AiAvailable);
    Assert.NotNull(body.Summary);
}

Estos tests nunca llaman a la IA. Testean tu código — el controller, el manejo de nulls, la forma de la respuesta. Se ejecutan en milisegundos.

Capa 2 — Tests de integración para caminos de fallo

Del artículo 2, tienes un AlwaysFailHandler. Úsalo para verificar que el circuit breaker y el fallback funcionan correctamente:

public class AlwaysFailHandler : DelegatingHandler
{
    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken) =>
        Task.FromResult(new HttpResponseMessage(HttpStatusCode.ServiceUnavailable));
}

[Fact]
public async Task SummarizeAsync_WhenProviderReturns503_ReturnsNull()
{
    // Arrange
    var handler = new AlwaysFailHandler
    {
        InnerHandler = new HttpClientHandler()
    };
    var client = new HttpClient(handler) { BaseAddress = new Uri("http://test") };

    var logger = NullLogger<AiSummaryService>.Instance;
    var service = new AiSummaryService(client, logger);

    // Act
    var result = await service.SummarizeAsync("some text");

    // Assert — must return null, not throw
    Assert.Null(result);
}

[Fact]
public async Task SummarizeAsync_WhenCircuitOpens_StopsCallingProvider()
{
    // Arrange — trigger enough failures to open the circuit
    var callCount = 0;
    var countingHandler = new CountingFailHandler(ref callCount);
    var client = new HttpClient(countingHandler) { BaseAddress = new Uri("http://test") };

    var service = new AiSummaryService(client, NullLogger<AiSummaryService>.Instance);

    // Act — exhaust the circuit breaker (MinimumThroughput = 5 from article 2)
    for (var i = 0; i < 10; i++)
    {
        await service.SummarizeAsync("some text");
    }

    // Assert — circuit opened, later calls don't reach the handler
    Assert.True(callCount < 10, "Circuit should have opened before all 10 calls");
}

Estos tests validan que tu configuración de resiliencia funciona de verdad. Un circuit breaker mal configurado (umbrales incorrectos, tipos de excepción que faltan) fallaría aquí — no en producción a las 11 de la noche.

Capa 3 — Tests de eval contra el modelo real

Los tests de eval llaman a la IA real. Son lentos y cuestan tokens, así que los ejecutas por separado de tu suite de tests normal — en un schedule, o antes de un release.

La diferencia clave con los tests tradicionales: no haces assert de la salida exacta. Haces assert de criterios de calidad.

// Mark these as a separate category — don't run in normal CI
[Trait("Category", "Eval")]
public class AiSummaryEvalTests
{
    private readonly IAiSummaryService _ai;

    public AiSummaryEvalTests()
    {
        // Real service — reads API key from environment
        var client = new HttpClient
        {
            BaseAddress = new Uri(Environment.GetEnvironmentVariable("AI_BASE_URL")!)
        };
        client.DefaultRequestHeaders.Add(
            "Authorization",
            $"Bearer {Environment.GetEnvironmentVariable("AI_API_KEY")}");

        _ai = new AiSummaryService(client, NullLogger<AiSummaryService>.Instance);
    }

    [Theory]
    [MemberData(nameof(EvalCases))]
    public async Task Summarize_MeetsQualityCriteria(EvalCase eval)
    {
        // Act
        var result = await _ai.SummarizeAsync(eval.Input);

        // Assert quality criteria — not exact output
        Assert.NotNull(result);
        Assert.True(result.Length >= eval.MinLength,
            $"Summary too short: {result.Length} chars, expected >= {eval.MinLength}");
        Assert.True(result.Length <= eval.MaxLength,
            $"Summary too long: {result.Length} chars, expected <= {eval.MaxLength}");
        Assert.DoesNotContain("I'm sorry", result, StringComparison.OrdinalIgnoreCase);
        Assert.DoesNotContain("I cannot", result, StringComparison.OrdinalIgnoreCase);

        // Optional: check that key concepts from the input appear in the summary
        foreach (var keyword in eval.ExpectedKeywords)
        {
            Assert.Contains(keyword, result, StringComparison.OrdinalIgnoreCase);
        }
    }

    public static IEnumerable<object[]> EvalCases() =>
    [
        [new EvalCase(
            Input: "The quarterly report shows revenue growth of 12% driven by enterprise customers. Operating costs increased by 3% due to infrastructure investments. Net margin improved to 18%.",
            MinLength: 50,
            MaxLength: 300,
            ExpectedKeywords: ["revenue", "margin"]
        )],
        [new EvalCase(
            Input: "The system uses a microservices architecture with 14 independent services communicating over gRPC. Each service owns its database. Deployments use blue-green with automatic rollback.",
            MinLength: 50,
            MaxLength: 300,
            ExpectedKeywords: ["microservices", "deployment"]
        )]
    ];
}

public record EvalCase(
    string Input,
    int MinLength,
    int MaxLength,
    string[] ExpectedKeywords
);

Ejecuta los tests de eval con un schedule — semanal, o antes de cada release. Cuando el proveedor actualice el modelo, lo sabrás inmediatamente si la calidad bajó. No por quejas de usuarios. Por un eval que falla.

Mantener los tests de eval separados

En tu pipeline de CI:

# .github/workflows/ci.yml

- name: Unit and integration tests
  run: dotnet test --filter "Category!=Eval"

# Eval tests run separately — on schedule or manually
- name: Eval tests
  if: github.event_name == 'schedule'
  run: dotnet test --filter "Category=Eval"
  env:
    AI_BASE_URL: ${{ secrets.AI_BASE_URL }}
    AI_API_KEY: ${{ secrets.AI_API_KEY }}

Los tests unitarios y de integración se ejecutan en cada push, en segundos. Los tests de eval se ejecutan semanalmente, en minutos, con costes reales de API.

Cómo son buenos criterios de eval

La parte más difícil del testing de eval es definir los criterios correctos. Empieza simple:

CriterioQué comprobarCódigo
No vacíoLa respuesta existe y tiene contenidoAssert.NotNull + comprobación de longitud
Longitud correctaNo demasiado corta (rechazo) ni demasiado larga (verbosa)Límites mín/máx
Sin rechazoEl modelo no rechazó la peticiónBuscar “I’m sorry”, “I cannot”
Conceptos claveTérminos importantes de la entrada aparecenPresencia de keywords
EstructuraSi esperas JSON, que se parsee correctamenteJsonDocument.Parse

A quality inspector robot sits at a desk checking AI documents against a rubric: "non-empty ✓", "right length ✓", "no refusals ✓", "keywords present ✓". A rejected pile has documents labeled "I'm sorry" and "[empty]".

No intentes hacer assert del significado. No puedes. Enfócate en estructura y proxies de calidad. Si el resumen menciona “revenue” cuando la entrada era sobre revenue, eso es una señal con sentido. Si tiene 12 caracteres de largo, algo va mal.

Checklist

  • ¿Tus tests unitarios hacen mock de IAiSummaryService y testean el código alrededor de la IA?
  • ¿Tus tests de integración verifican que se devuelve null (no una excepción) cuando la IA falla?
  • ¿Hay un test que abre el circuit breaker y confirma que las llamadas siguientes se saltan?
  • ¿Tienes casos de eval con entradas reales y criterios de calidad (no salida exacta)?
  • ¿Los tests de eval están separados de tu suite principal y se ejecutan con un schedule?
  • ¿Tus tests de eval comprueban patrones de rechazo y longitud mínima de respuesta?

El objetivo no es 100% de cobertura de lo que dice el modelo. Es tener confianza en que tu código maneja la IA correctamente — y que la IA no ha empeorado silenciosamente.

Antes del Siguiente Artículo

Seis artículos sobre problemas concretos. Uno sobre integración en sistemas reales. Uno sobre testing.

El último artículo lo une todo. No es otro patrón ni otra técnica — es un checklist de preparación para producción. Las preguntas que respondes antes de que cualquier funcionalidad de IA salga a producción, cubriendo todo desde los artículos 1 al 7. Una referencia única que puedes dar a un equipo y decir: si puedes marcar cada casilla, tu funcionalidad de IA está lista para producción.

Ese es el artículo 8.


Si esta serie te resulta útil, considera invitarme a un café.

Este es el artículo 7 de la serie AI in Production. Siguiente: Production Readiness — el checklist completo antes de que tu funcionalidad de IA salga a producción.

Comments

Loading comments...