Wednesday, July 1, 2026

Estrategia de testing en arquitectura hexagonal: qué testear, dónde y con qué herramienta

La arquitectura hexagonal separa el dominio de la infraestructura mediante puertos y adaptadores. Esa separación es exactamente lo que necesitamos para dejar de improvisar con los tests: cada capa tiene una responsabilidad distinta, así que cada capa necesita un tipo de test distinto. El problema aparece cuando esa correspondencia no está explícita. Un desarrollador —o un agente de IA generando código— termina rellenando todo con mocks, escribiendo integration tests para lógica pura, o cubriendo con e2e cosas que un unit test resolvería en milisegundos.

Este artículo es el primero de una serie sobre testing y TDD asistido por agentes. Antes de hablar de TDD inside-out o de construir un backend completo con IA como copiloto, hace falta fijar las reglas del juego: qué se testea en cada capa, con qué tipo de test, y con qué herramienta del ecosistema .NET.

Repaso rápido: las capas de la arquitectura hexagonal

Para que la estrategia de testing tenga sentido, conviene tener clara la estructura. En hexagonal trabajamos con tres capas concéntricas:

Dominio. Entidades, value objects, agregados y domain services. Contiene las reglas de negocio puras. No conoce Entity Framework, no conoce HTTP, no conoce nada externo. Es C# puro.

Aplicación. Los casos de uso (application services) que orquestan el dominio para cumplir una intención del usuario. Esta capa define puertos: interfaces como IOrderRepository o IPaymentGateway que describen qué necesita el caso de uso, sin saber cómo se implementa.

Adaptadores. La implementación concreta de los puertos: repositorios con EF Core, clientes HTTP hacia APIs externas, controllers que exponen la aplicación por REST, consumidores de colas, etc. Aquí vive todo lo que toca el mundo exterior.

La regla de dependencia es siempre hacia adentro: los adaptadores dependen de la aplicación, la aplicación depende del dominio, y el dominio no depende de nada. Esa misma dirección es la que vamos a usar para decidir qué tipo de test corresponde a cada capa.

La pirámide de testing mapeada a las capas hexagonales

Dominio: unit tests puros, sin mocks

El dominio es el lugar donde los unit tests rinden al máximo: son rápidos, deterministas, y no necesitan ningún doble de prueba porque no hay dependencias externas que sustituir. Si sientes la necesidad de mockear algo dentro de un test de dominio, es una señal de que esa clase tiene una responsabilidad que no le corresponde (probablemente está tocando infraestructura).

public class Order
{
    private readonly List<OrderLine> _lines = new();

    public OrderStatus Status { get; private set; } = OrderStatus.Draft;

    public void AddLine(ProductId productId, int quantity, Money unitPrice)
    {
        if (Status != OrderStatus.Draft)
            throw new InvalidOperationException("No se pueden agregar líneas a una orden confirmada.");

        _lines.Add(new OrderLine(productId, quantity, unitPrice));
    }

    public void Confirm()
    {
        if (!_lines.Any())
            throw new InvalidOperationException("Una orden no puede confirmarse sin líneas.");

        Status = OrderStatus.Confirmed;
    }
}

[Fact]
public void Confirm_deberia_fallar_si_la_orden_no_tiene_lineas()
{
    var order = new Order();

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

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

Sin mocks, sin base de datos, sin frameworks de infraestructura. Solo xUnit (o NUnit) y, opcionalmente, FluentAssertions para legibilidad. Este tipo de test debería representar la mayor parte de tu suite: es el más barato de escribir, ejecutar y mantener.

Aplicación: unit tests con dobles de los puertos

Los casos de uso orquestan el dominio, pero dependen de puertos (interfaces) para hablar con el exterior. Aquí sí usamos dobles de prueba, pero con un límite claro: solo mockeamos los puertos que el propio caso de uso define, nunca detalles internos del dominio.

public class PlaceOrderUseCase
{
    private readonly IOrderRepository _repository;
    private readonly IClock _clock;

    public PlaceOrderUseCase(IOrderRepository repository, IClock clock)
    {
        _repository = repository;
        _clock = clock;
    }

    public async Task Handle(PlaceOrderCommand command)
    {
        var order = Order.Create(command.CustomerId, _clock.UtcNow);
        foreach (var item in command.Items)
            order.AddLine(item.ProductId, item.Quantity, item.UnitPrice);

        order.Confirm();

        await _repository.Save(order);
    }
}

[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 OrderItem(...) });

    await useCase.Handle(command);

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

Con NSubstitute (o Moq si es lo que ya usas en el proyecto) sustituimos IOrderRepository e IClock porque son puertos: fronteras explícitas de la aplicación. No estamos testeando cómo se guarda la orden en la base de datos, solo que el caso de uso invoca correctamente al puerto con el estado correcto.

Adaptadores: integration tests contra la tecnología real

Un adaptador solo demuestra que funciona cuando se lo prueba contra la tecnología que envuelve. Un test unitario de OrderRepositoryEfCore con un mock de DbContext no te dice nada útil: lo que puede fallar en un repositorio son los detalles de mapeo, las queries, los índices, la serialización. Eso solo se descubre con infraestructura real o casi real.

public class OrderRepositoryEfCoreTests : IClassFixture<PostgresContainerFixture>
{
    private readonly AppDbContext _context;

    public OrderRepositoryEfCoreTests(PostgresContainerFixture fixture)
    {
        _context = fixture.CreateContext();
    }

    [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);
    }
}

En .NET, la herramienta natural para esto es Testcontainers: levanta un Postgres, SQL Server o Redis real en Docker durante el test y lo destruye al terminar. Es más lento que un unit test, pero infinitamente más confiable que un mock de DbContext. Para adaptadores de salida hacia APIs externas, el equivalente es WireMock.NET: simula el servicio remoto sin depender de que esté disponible durante el test.

Puertos de entrada: integration tests con WebApplicationFactory

Los controllers (o minimal API endpoints) son también adaptadores, pero de entrada. Aquí el objetivo es verificar que el pipeline completo —routing, model binding, validación, serialización— funciona de punta a punta, usando la aplicación real pero sustituyendo únicamente lo que cruza el borde del proceso (por ejemplo, la base de datos, si aún no queremos pagar ese costo).

public class OrdersControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public OrdersControllerTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.CreateClient();
    }

    [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);
    }
}

End-to-end: pocos, y solo para flujos críticos

Los e2e recorren el sistema completo, con la infraestructura real (o un ambiente de staging) y sin sustituir nada. Son los más lentos, los más frágiles y los más caros de mantener, así que se reservan para los tres o cuatro flujos de negocio que, si se rompen, cuestan dinero de verdad: crear una orden y cobrarla, por ejemplo. No se usan para cubrir casos límite; para eso ya tienes el dominio.

Tabla de decisión

Capa Qué se testea Tipo de test Herramientas .NET
Dominio Entidades, value objects, invariantes Unitario, sin dobles xUnit, FluentAssertions
Aplicación Orquestación de casos de uso Unitario, con dobles solo de puertos xUnit, NSubstitute/Moq
Adaptadores de salida Repositorios, gateways, clientes externos Integración, tecnología real Testcontainers, WireMock.NET
Adaptadores de entrada Controllers, endpoints, pipeline HTTP Integración, proceso completo WebApplicationFactory
Sistema completo Flujos de negocio críticos End-to-end Playwright, entorno de staging

Cómo saber qué tipo de test escribir en cada capa

Esto importa especialmente cuando quien escribe el test es un agente de IA: sin una regla explícita, un agente tiende a resolver por el camino más fácil, que casi siempre es mockear todo. La buena noticia es que la arquitectura hexagonal ya te da las señales necesarias para decidir automáticamente, sin depender del criterio subjetivo de cada sesión.

La primera señal es la ubicación en el código. Si la carpeta o namespace es Domain/, la clase no debería tener ninguna dependencia externa: eso es un unit test sin dobles, sin excepción. Si es Application/, la clase depende de interfaces (puertos) definidas en esa misma capa: eso es un unit test con dobles limitados exactamente a esas interfaces. Si es Infrastructure/ o Adapters/, la clase implementa un puerto usando una tecnología concreta: eso es un integration test.

La segunda señal, más robusta que la carpeta, es de qué depende la clase. Si el constructor no recibe ninguna interfaz que cruce el borde del proceso (I/O, red, reloj del sistema, generador de GUIDs), es candidata a unit test puro. Si recibe interfaces pero ninguna implementación concreta de infraestructura, es candidata a unit test con dobles de esas interfaces. Si la clase misma es la que implementa esa interfaz usando DbContext, HttpClient o un SDK externo, ya no hay nada que mockear: es integration test por definición, porque el propósito del test es justamente validar esa integración.

Una tercera señal útil, sobre todo para prompts o instrucciones a un agente, es preguntarse: si este test falla, ¿qué se rompió? Si la respuesta es "una regla de negocio", el test pertenece al dominio. Si es "la orquestación entre pasos", pertenece a la aplicación. Si es "la forma en que hablamos con Postgres o con una API externa", pertenece a los adaptadores. Formular esta pregunta como regla explícita —en un archivo de convenciones del repositorio o en el system prompt del agente— evita el problema más común: agentes que generan un mock de DbContext para testear un caso de uso, cuando lo correcto era mockear el repositorio a nivel de puerto.

Errores comunes

El más frecuente es el over-mocking del dominio: tests de entidades que reciben dobles de otras entidades o value objects, cuando en realidad esas colaboraciones deberían resolverse con objetos reales porque son baratos de construir y no tienen efectos secundarios. Si necesitas mockear algo dentro del dominio, generalmente el problema es de diseño, no de testing.

El segundo es la pirámide invertida: demasiados e2e y pocos unit tests. Sucede cuando el equipo no confía en que los mocks de los puertos reflejen el comportamiento real, así que termina probando todo contra el sistema completo. La solución no es eliminar los e2e, sino invertir en integration tests de adaptadores que sí verifiquen el contrato real de esos puertos.

El tercero es escribir integration tests disfrazados de unitarios: tests que instancian un DbContext real con SQLite in-memory y lo llaman "unit test" porque corre rápido. Es una decisión válida como estrategia intermedia, pero conviene ser honesto en la clasificación: si toca infraestructura, es integración, aunque sea rápida.

Cierre

La arquitectura hexagonal no solo organiza el código, también organiza la estrategia de testing: cada capa impone naturalmente qué tipo de test le corresponde, si sabemos leer las señales correctas (ubicación, dependencias, y qué se rompe cuando el test falla). Esta claridad es la base para el siguiente paso de la serie: adaptar el ciclo clásico de TDD —rojo, verde, refactor— a un flujo inside-out que evite que un agente de IA llene el proyecto de mocks innecesarios, empezando siempre por el dominio.

No comments:

Post a Comment