AI in Production -- Parte 7
Testeando Funcionalidades de IA: Tests Fiables para un Componente que No lo Es
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?

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.

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:
| Criterio | Qué comprobar | Código |
|---|---|---|
| No vacío | La respuesta existe y tiene contenido | Assert.NotNull + comprobación de longitud |
| Longitud correcta | No demasiado corta (rechazo) ni demasiado larga (verbosa) | Límites mín/máx |
| Sin rechazo | El modelo no rechazó la petición | Buscar “I’m sorry”, “I cannot” |
| Conceptos clave | Términos importantes de la entrada aparecen | Presencia de keywords |
| Estructura | Si esperas JSON, que se parsee correctamente | JsonDocument.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]".](/images/eval.jpg)
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
IAiSummaryServicey 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.
Loading comments...