Wednesday, July 1, 2026

Inside-out TDD con agentes: cómo evitar que la IA llene tu backend de mocks

En el artículo anterior definimos qué tipo de test corresponde a cada capa de una arquitectura hexagonal: unitarios sin dobles para el dominio, unitarios con dobles de puertos para los casos de uso, e integración real para los adaptadores. Esa clasificación resuelve el "qué" y el "dónde". Falta el "en qué orden" se escribe todo eso cuando el código lo produce, total o parcialmente, un agente de IA. Ahí es donde entra el TDD inside-out.

La elección del orden no es un detalle metodológico menor. Es la diferencia entre terminar con un dominio real, probado y expresivo, o terminar con una suite verde llena de mocks que no verifica nada de negocio.

Por qué el TDD clásico necesita adaptarse

El TDD siempre tuvo dos escuelas. La escuela outside-in (o "mockista", asociada a Londres) empieza por el borde del sistema: se escribe un test de aceptación o de un caso de uso, y a medida que se descubren colaboradores necesarios, se los mockea para poder avanzar sin implementarlos todavía. El diseño emerge de afuera hacia adentro. La escuela inside-out (o "clásica", asociada a Detroit) empieza por el dominio: se construyen y prueban primero los objetos de negocio con sus reglas, y recién después se ensamblan en casos de uso y adaptadores.

Con un desarrollador humano experimentado, ambas escuelas funcionan razonablemente bien porque la persona tiene criterio para saber cuándo un mock es una decisión de diseño legítima y cuándo es un atajo para evitar pensar el dominio. Un agente de IA no tiene ese criterio por defecto: su objetivo inmediato es hacer pasar el test lo más rápido posible. Si le permites empezar por afuera —por el controller o el caso de uso— y le das libertad para mockear lo que haga falta, va a mockear todo lo que le resulte conveniente, incluida lógica de negocio que todavía no existe. El resultado es una suite en verde que no prueba nada real: el "sistema bajo prueba" termina siendo, en gran parte, los propios mocks.

Inside-out resuelve esto por construcción. Si obligas al agente a empezar por el dominio, no hay nada que mockear todavía: las entidades y value objects no tienen dependencias externas. El agente se ve forzado a diseñar y probar comportamiento real antes de que exista ningún puerto que sustituir. Los mocks solo entran en escena cuando genuinamente hay una frontera —un puerto— que cruzar, y ese momento llega varios pasos después, no en el primer test.

Las reglas de inside-out TDD para evitar que la IA llene todo de mocks

Estas reglas funcionan mejor cuando se dejan explícitas en las instrucciones del agente (por ejemplo, en un archivo de convenciones del repositorio), no como un criterio implícito que se espera que el agente adivine.

Regla 1: no hay mocks sin un puerto. Un test solo puede usar un doble de prueba si sustituye una interfaz definida como puerto en la capa de aplicación. Si el agente propone mockear una clase del dominio, una entidad, o un value object, la respuesta es no: eso indica que falta terminar de diseñar el dominio, no que haga falta un mock.

Regla 2: el dominio se prueba y queda en verde antes de escribir el primer test de aplicación. No se permite empezar un caso de uso mientras las entidades que necesita todavía no tienen su comportamiento cubierto. Esto evita que el agente "salte" al nivel de orquestación y compense con mocks lo que debería ser lógica de dominio real.

Regla 3: un mock representa un puerto, no un detalle de implementación. Se mockea IOrderRepository, nunca DbContext. Se mockea IPaymentGateway, nunca HttpClient. Si el agente necesita mockear algo que no es una interfaz propia de la capa de aplicación, probablemente está intentando testear un adaptador con las herramientas equivocadas.

Regla 4: un test, un puerto sustituido (como máximo dos). Si un caso de uso necesita cuatro o cinco mocks para que el test pase, es una señal de que el caso de uso está haciendo demasiado. La respuesta correcta no es aceptar el test con cinco dobles, sino dividir el caso de uso o mover lógica al dominio.

Regla 5: todo mock debe poder justificarse señalando la interfaz que sustituye. Es una regla simple de aplicar como revisión: si no puedes nombrar el puerto que ese mock representa, el mock no debería existir.

En la práctica, estas reglas se pueden resumir en una instrucción corta para el agente: "Antes de escribir un test con un doble de prueba, verifica que estás en la capa de aplicación y que el doble sustituye una interfaz de puerto ya definida. Si no se cumple, escribe primero el test de dominio correspondiente."

Ciclo práctico: dominio → casos de uso → adaptadores

Vale más ver el ciclo completo con un ejemplo que enumerarlo en abstracto. Retomamos el caso de "crear una orden" del artículo anterior y lo construimos paso a paso, capa por capa, con rojo–verde–refactor en cada una.

Paso 1 — Dominio: la primera regla de negocio

Empezamos sin ningún puerto, sin ninguna interfaz, sin nada que mockear. El primer test describe una regla del dominio antes de que exista el código que la cumple.

// Rojo: Order todavía no existe
[Fact]
public void AddLine_deberia_agregar_una_linea_a_una_orden_en_borrador()
{
    var order = Order.Create(CustomerId.New(), DateTime.UtcNow);

    order.AddLine(ProductId.New(), quantity: 2, unitPrice: new Money(10, "USD"));

    order.Lines.Should().ContainSingle();
}

El agente implementa lo mínimo para pasar a verde: la clase Order, el método Create, el método AddLine. Nada de repositorios, nada de infraestructura. En el refactor se limpia el diseño si hace falta, pero el test sigue siendo el mismo: cero dobles de prueba.

// Rojo → Verde: se agrega la siguiente regla de negocio
[Fact]
public void Confirm_deberia_fallar_si_la_orden_no_tiene_lineas()
{
    var order = Order.Create(CustomerId.New(), DateTime.UtcNow);

    var action = () => order.Confirm();

    action.Should().Throw<InvalidOperationException>();
}

Este ciclo se repite tantas veces como reglas de negocio tenga la orden: líneas duplicadas, límites de cantidad, cálculo del total. Recién cuando el dominio cubre el comportamiento que el caso de uso va a necesitar, se sube a la siguiente capa.

Paso 2 — Aplicación: aquí aparece el primer mock legítimo

Con Order ya probado, escribimos el primer test que sí necesita un doble de prueba, porque el caso de uso depende de un puerto real: persistir la orden.

// Rojo: PlaceOrderUseCase todavía no existe
[Fact]
public async Task Handle_deberia_guardar_la_orden_confirmada()
{
    var repository = Substitute.For<IOrderRepository>();
    var clock = Substitute.For<IClock>();
    clock.UtcNow.Returns(new DateTime(2026, 7, 1));

    var useCase = new PlaceOrderUseCase(repository, clock);
    var command = new PlaceOrderCommand(
        CustomerId.New(),
        new[] { new OrderItemDto(ProductId.New(), 2, new Money(10, "USD")) });

    await useCase.Handle(command);

    await repository.Received(1).Save(Arg.Is<Order>(o => o.Status == OrderStatus.Confirmed));
}

Los dos dobles, IOrderRepository e IClock, son puertos definidos por la aplicación: cumplen la regla 3. No hay tercer mock porque el caso de uso no necesita nada más: cumple la regla 4. El agente implementa PlaceOrderUseCase usando el Order ya construido en el paso anterior; no vuelve a decidir reglas de negocio aquí, solo orquesta.

Paso 3 — Adaptadores: se valida el puerto contra la tecnología real

El caso de uso quedó en verde confiando en que IOrderRepository.Save hace lo que promete. Ese contrato todavía no se probó de verdad. El siguiente ciclo rojo–verde ocurre contra infraestructura real, no contra un doble.

// Rojo: OrderRepositoryEfCore todavía no existe
[Fact]
public async Task Save_deberia_persistir_la_orden_con_sus_lineas()
{
    var repository = new OrderRepositoryEfCore(_context);
    var order = Order.Create(CustomerId.New(), DateTime.UtcNow);
    order.AddLine(ProductId.New(), 2, new Money(10, "USD"));

    await repository.Save(order);

    var stored = await _context.Orders.Include(o => o.Lines).FirstAsync(o => o.Id == order.Id);
    stored.Lines.Should().HaveCount(1);
}

Aquí no hay ningún mock: es integración real contra Postgres levantado con Testcontainers, como vimos en el artículo anterior. El agente implementa el mapeo de EF Core, resuelve las particularidades del ORM, y recién en este punto el puerto IOrderRepository queda validado de extremo a extremo, no solo asumido.

Paso 4 — Cerrando el ciclo: el puerto de entrada

El último tramo conecta todo con el mundo exterior: un test de integración sobre el controller, usando WebApplicationFactory, que ejercita el pipeline completo de HTTP hacia el caso de uso ya probado.

[Fact]
public async Task POST_orders_deberia_devolver_201_con_una_orden_valida()
{
    var response = await _client.PostAsJsonAsync("/api/orders", new
    {
        customerId = Guid.NewGuid(),
        items = new[] { new { productId = Guid.NewGuid(), quantity = 1, unitPrice = 25.0 } }
    });

    response.StatusCode.Should().Be(HttpStatusCode.Created);
}

Notá el recorrido completo: dominio probado sin dobles, aplicación probada con dobles de puertos, adaptadores probados contra tecnología real, y solo al final un test de extremo a extremo del endpoint. Cada capa se apoyó en la anterior ya verificada, en lugar de asumirla con un mock adicional. Ese es precisamente el efecto que buscamos: que el agente construya el sistema de adentro hacia afuera, con evidencia real en cada paso, en vez de simular todo desde el borde.

Cierre

Inside-out TDD no es una preferencia estilística: es la disciplina que evita que un agente de IA resuelva "hacer pasar el test" con el camino de menor resistencia, que casi siempre es mockear. Empezar por el dominio, subir a los casos de uso solo cuando el dominio está probado, y reservar los mocks exclusivamente para puertos, produce un backend donde cada capa fue verificada con la evidencia que le corresponde. En el próximo artículo de la serie llevamos este ciclo a un backend completo, con el agente actuando como copiloto dentro del propio ciclo rojo–verde–refactor.

No comments:

Post a Comment