Skip to content

Estandares de Testing — Plataforma Imagy

Piramide de Testing

La estrategia de testing sigue la piramide clasica: muchos tests unitarios rapidos en la base, tests de integracion en el medio, y pocos tests end-to-end en la cima. Esta distribucion optimiza el feedback loop y minimiza la fragilidad del suite.

Cobertura Minima por Capa

CapaCobertura MinimaJustificacion
Domain90%Logica de negocio critica, calculos financieros
Application80%Handlers, validaciones, orquestacion
Infrastructure60%Repositorios, integraciones externas
API (Controllers)70%Mapeo y respuestas HTTP
Frontend (Components)75%Interacciones de usuario

Tests Unitarios

Backend (.NET) — xUnit + FluentAssertions

Los tests unitarios validan logica de dominio y handlers de aplicacion de forma aislada. Solo se mockean las dependencias externas (repositorios, message bus, servicios HTTP).

csharp
public class CreditProductTests
{
    [Fact]
    public void Create_WithValidParameters_ShouldReturnProduct()
    {
        // Arrange
        var tenantId = Guid.NewGuid();

        // Act
        var product = CreditProduct.Create(
            code: "microcredito-30d",
            name: "Microcredito 30 dias",
            minAmount: 100m,
            maxAmount: 5000m,
            interestRate: 0.05m,
            minTermDays: 15,
            maxTermDays: 30,
            tenantId: tenantId);

        // Assert
        product.Should().NotBeNull();
        product.Code.Should().Be("microcredito-30d");
        product.TenantId.Should().Be(tenantId);
        product.IsActive.Should().BeTrue();
    }

    [Theory]
    [InlineData(0)]
    [InlineData(-100)]
    public void Create_WithInvalidMinAmount_ShouldThrowDomainException(decimal minAmount)
    {
        // Act
        var act = () => CreditProduct.Create(
            "code", "name", minAmount, 5000m, 0.05m, 15, 30, Guid.NewGuid());

        // Assert
        act.Should().Throw<DomainException>()
            .WithMessage("*MinAmount*");
    }
}

Frontend — Vitest + Testing Library

typescript
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { CreditProductCard } from './CreditProductCard';
import { buildCreditProduct } from '@/tests/builders/creditProduct.builder';

describe('CreditProductCard', () => {
  it('should render product name and code', () => {
    const product = buildCreditProduct({ name: 'Microcredito', code: 'mc-30d' });

    render(<CreditProductCard product={product} onEdit={vi.fn()} onToggleActive={vi.fn()} />);

    expect(screen.getByText('Microcredito')).toBeInTheDocument();
    expect(screen.getByText('mc-30d')).toBeInTheDocument();
  });

  it('should call onEdit when edit button is clicked', async () => {
    const product = buildCreditProduct();
    const onEdit = vi.fn();

    render(<CreditProductCard product={product} onEdit={onEdit} onToggleActive={vi.fn()} />);
    fireEvent.click(screen.getByRole('button', { name: /editar/i }));

    expect(onEdit).toHaveBeenCalledWith(product.id);
  });
});

Tests de Integracion — Testcontainers

Los tests de integracion verifican que la infraestructura funciona correctamente con dependencias reales. Usamos Testcontainers para levantar PostgreSQL, RabbitMQ y Valkey en contenedores efimeros.

Configuracion Base

csharp
public class IntegrationTestFixture : IAsyncLifetime
{
    private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
        .WithImage("postgres:16-alpine")
        .WithDatabase("imagy_test")
        .Build();

    private readonly RabbitMqContainer _rabbitmq = new RabbitMqBuilder()
        .WithImage("rabbitmq:3.13-management-alpine")
        .Build();

    private readonly RedisContainer _valkey = new RedisBuilder()
        .WithImage("valkey/valkey:8-alpine")
        .Build();

    public string PostgresConnectionString => _postgres.GetConnectionString();
    public string RabbitMqConnectionString => _rabbitmq.GetConnectionString();
    public string ValkeyConnectionString => _valkey.GetConnectionString();

    public async Task InitializeAsync()
    {
        await Task.WhenAll(
            _postgres.StartAsync(),
            _rabbitmq.StartAsync(),
            _valkey.StartAsync());

        // Ejecutar migraciones contra el contenedor
        await RunMigrationsAsync(PostgresConnectionString);
    }

    public async Task DisposeAsync()
    {
        await Task.WhenAll(
            _postgres.DisposeAsync().AsTask(),
            _rabbitmq.DisposeAsync().AsTask(),
            _valkey.DisposeAsync().AsTask());
    }
}

Test de Repositorio

csharp
[Collection("Integration")]
public class CreditProductRepositoryTests : IClassFixture<IntegrationTestFixture>
{
    private readonly IntegrationTestFixture _fixture;

    [Fact]
    public async Task AddAsync_ShouldPersistProduct()
    {
        // Arrange
        await using var context = _fixture.CreateDbContext();
        var repository = new CreditProductRepository(context);
        var product = CreditProductBuilder.Build();

        // Act
        await repository.AddAsync(product);
        await context.SaveChangesAsync();

        // Assert
        var persisted = await repository.GetByIdAsync(product.Id);
        persisted.Should().NotBeNull();
        persisted!.Code.Should().Be(product.Code);
    }

    [Fact]
    public async Task GetByIdAsync_WithRLS_ShouldOnlyReturnOwnTenantData()
    {
        // Arrange
        var tenantA = Guid.NewGuid();
        var tenantB = Guid.NewGuid();
        var productA = CreditProductBuilder.Build(tenantId: tenantA);
        var productB = CreditProductBuilder.Build(tenantId: tenantB);

        await using var context = _fixture.CreateDbContext(tenantA);
        var repository = new CreditProductRepository(context);

        // Act
        var result = await repository.GetByIdAsync(productB.Id);

        // Assert — RLS impide ver datos de otro tenant
        result.Should().BeNull();
    }
}

Tests de Contrato — Verificacion de Eventos

Los tests de contrato garantizan que los esquemas de eventos publicados por un productor son compatibles con lo que los consumidores esperan. Esto previene roturas silenciosas cuando un servicio evoluciona su esquema.

Productor (publica el contrato)

csharp
[Fact]
public void CreditProductCreated_ShouldMatchContractSchema()
{
    // Arrange
    var @event = new CreditProductCreated
    {
        EventId = Guid.NewGuid(),
        EventType = "lending.product.created",
        TenantId = Guid.NewGuid(),
        Source = "imagy-lending",
        ActorId = Guid.NewGuid(),
        OccurredAt = DateTime.UtcNow,
        Data = new { Id = Guid.NewGuid(), Code = "mc-30d", Name = "Microcredito" }
    };

    // Act & Assert — serializar y validar contra JSON Schema
    var json = JsonSerializer.Serialize(@event);
    var schema = LoadContractSchema("lending.product.created.v1.json");
    var result = schema.Validate(json);

    result.IsValid.Should().BeTrue(
        because: "el evento debe cumplir el contrato publicado");
}

Consumidor (valida compatibilidad)

csharp
[Fact]
public void Consumer_ShouldDeserialize_CreditProductCreated_V1()
{
    // Arrange — payload del contrato publicado
    var contractPayload = LoadSamplePayload("lending.product.created.v1.sample.json");

    // Act
    var @event = JsonSerializer.Deserialize<CreditProductCreatedConsumerDto>(contractPayload);

    // Assert — el consumidor puede leer los campos que necesita
    @event.Should().NotBeNull();
    @event!.Data.Id.Should().NotBeEmpty();
    @event.Data.Code.Should().NotBeNullOrEmpty();
    @event.TenantId.Should().NotBeEmpty();
}

Property-Based Testing — Calculos Financieros

Para calculos financieros donde los edge cases son dificiles de enumerar manualmente, usamos property-based testing. Esto genera cientos de inputs aleatorios y verifica que las propiedades matematicas se mantienen.

Backend — FsCheck

csharp
[Property]
public Property InterestCalculation_ShouldNeverBeNegative()
{
    return Prop.ForAll(
        Arb.From<decimal>().Filter(amount => amount > 0 && amount <= 1_000_000),
        Arb.From<decimal>().Filter(rate => rate >= 0 && rate <= 1),
        Arb.From<int>().Filter(days => days > 0 && days <= 3650),
        (amount, rate, days) =>
        {
            var interest = InterestCalculator.CalculateSimple(amount, rate, days);
            return interest >= 0;
        });
}

[Property]
public Property AmortizationSchedule_TotalPayments_ShouldEqualPrincipalPlusInterest()
{
    return Prop.ForAll(
        Arb.From<decimal>().Filter(p => p >= 100 && p <= 1_000_000),
        Arb.From<decimal>().Filter(r => r > 0 && r <= 0.5m),
        Arb.From<int>().Filter(n => n >= 1 && n <= 360),
        (principal, rate, periods) =>
        {
            var schedule = AmortizationCalculator.GenerateFrench(principal, rate, periods);
            var totalPayments = schedule.Sum(p => p.Payment);
            var totalInterest = schedule.Sum(p => p.Interest);

            // La suma de pagos debe ser >= principal (siempre se paga interes)
            return totalPayments >= principal
                && Math.Abs(totalPayments - (principal + totalInterest)) < 0.01m;
        });
}

Frontend — fast-check

typescript
import fc from 'fast-check';
import { calculateMonthlyPayment } from '@/lib/financial';

describe('calculateMonthlyPayment - properties', () => {
  it('should always return a positive value for valid inputs', () => {
    fc.assert(
      fc.property(
        fc.double({ min: 100, max: 1_000_000, noNaN: true }),
        fc.double({ min: 0.001, max: 0.5, noNaN: true }),
        fc.integer({ min: 1, max: 360 }),
        (principal, annualRate, months) => {
          const payment = calculateMonthlyPayment(principal, annualRate, months);
          return payment > 0;
        }
      )
    );
  });

  it('should increase when principal increases (all else equal)', () => {
    fc.assert(
      fc.property(
        fc.double({ min: 100, max: 500_000, noNaN: true }),
        fc.double({ min: 0.01, max: 0.3, noNaN: true }),
        fc.integer({ min: 1, max: 360 }),
        (principal, rate, months) => {
          const payment1 = calculateMonthlyPayment(principal, rate, months);
          const payment2 = calculateMonthlyPayment(principal * 2, rate, months);
          return payment2 > payment1;
        }
      )
    );
  });
});

Que Testear por Capa

Domain Layer

  • Creacion de entidades con parametros validos e invalidos
  • Transiciones de estado (state machines)
  • Calculos de negocio (interes, amortizacion, comisiones)
  • Value objects (igualdad, validacion)
  • Domain events emitidos correctamente
  • Invariantes de agregados

Application Layer

  • Handlers ejecutan el flujo correcto (happy path)
  • Handlers retornan errores apropiados (validation, not found, conflict)
  • Validadores rechazan input invalido
  • Eventos publicados despues de persistir
  • Idempotencia de commands cuando aplica

Infrastructure Layer

  • Repositorios persisten y recuperan entidades
  • RLS filtra correctamente por tenant
  • Queries Dapper retornan DTOs correctos
  • Consumers procesan eventos correctamente
  • Cache se invalida cuando corresponde

API Layer

  • Status codes correctos (201, 400, 401, 403, 404, 409)
  • Formato de respuesta consistente (envelope)
  • Autorizacion por roles
  • Paginacion funciona correctamente
  • ETags y concurrencia optimista

Frontend

  • Componentes renderizan datos correctamente
  • Formularios validan y envian datos
  • Hooks manejan loading, error, success
  • Navegacion entre rutas
  • Accesibilidad (roles ARIA, keyboard navigation)

Convenciones de Nombres para Tests

Backend (.NET)

El formato es: MetodoOClase_Escenario_ResultadoEsperado

csharp
// Entidades de dominio
public class CreditProductTests
{
    public void Create_WithValidParameters_ShouldReturnActiveProduct() { }
    public void Create_WithNegativeAmount_ShouldThrowDomainException() { }
    public void Deactivate_WhenAlreadyInactive_ShouldThrowInvalidOperationException() { }
}

// Handlers
public class CreateCreditProductHandlerTests
{
    public void Handle_WithValidCommand_ShouldPersistAndPublishEvent() { }
    public void Handle_WithDuplicateCode_ShouldReturnConflictError() { }
}

// Validators
public class CreateCreditProductValidatorTests
{
    public void Validate_WithEmptyCode_ShouldHaveValidationError() { }
    public void Validate_WithAmountExceedingMax_ShouldHaveValidationError() { }
}

// Integration
public class CreditProductRepositoryTests
{
    public void AddAsync_ShouldPersistProduct() { }
    public void GetByIdAsync_WithRLS_ShouldOnlyReturnOwnTenantData() { }
}

Frontend (Vitest)

El formato usa describe para el componente/hook y it con lenguaje natural:

typescript
describe('CreditProductCard', () => {
  it('should render product name and code');
  it('should call onEdit when edit button is clicked');
  it('should show inactive badge when product is not active');
});

describe('useCreditProducts', () => {
  it('should return loading state initially');
  it('should return products on success');
  it('should handle error state');
});

Test Data Builders

Los builders crean objetos de test con valores por defecto sensatos. Permiten sobreescribir solo los campos relevantes para cada test, reduciendo el ruido.

Backend — Builder Pattern

csharp
public class CreditProductBuilder
{
    private string _code = "test-product";
    private string _name = "Test Product";
    private decimal _minAmount = 100m;
    private decimal _maxAmount = 10000m;
    private decimal _interestRate = 0.05m;
    private int _minTermDays = 15;
    private int _maxTermDays = 365;
    private Guid _tenantId = Guid.NewGuid();

    public static CreditProductBuilder New() => new();

    public CreditProductBuilder WithCode(string code) { _code = code; return this; }
    public CreditProductBuilder WithName(string name) { _name = name; return this; }
    public CreditProductBuilder WithAmountRange(decimal min, decimal max)
    {
        _minAmount = min;
        _maxAmount = max;
        return this;
    }
    public CreditProductBuilder WithInterestRate(decimal rate) { _interestRate = rate; return this; }
    public CreditProductBuilder WithTermRange(int min, int max)
    {
        _minTermDays = min;
        _maxTermDays = max;
        return this;
    }
    public CreditProductBuilder ForTenant(Guid tenantId) { _tenantId = tenantId; return this; }

    public CreditProduct Build() =>
        CreditProduct.Create(_code, _name, _minAmount, _maxAmount,
            _interestRate, _minTermDays, _maxTermDays, _tenantId);

    // Shortcut estatico con valores por defecto
    public static CreditProduct Build(
        string? code = null,
        Guid? tenantId = null) =>
        New()
            .WithCode(code ?? $"product-{Guid.NewGuid():N}"[..20])
            .ForTenant(tenantId ?? Guid.NewGuid())
            .Build();
}

Frontend — Factory Functions

typescript
// tests/builders/creditProduct.builder.ts
import { CreditProductDto } from '@/types/lending';

const defaults: CreditProductDto = {
  id: crypto.randomUUID(),
  code: 'test-product',
  name: 'Test Product',
  minAmount: 100,
  maxAmount: 10000,
  interestRate: 0.05,
  minTermDays: 15,
  maxTermDays: 365,
  isActive: true,
  createdAt: new Date().toISOString(),
};

export function buildCreditProduct(
  overrides: Partial<CreditProductDto> = {}
): CreditProductDto {
  return {
    ...defaults,
    id: crypto.randomUUID(), // ID unico por cada llamada
    ...overrides,
  };
}

export function buildCreditProductList(
  count: number,
  overrides: Partial<CreditProductDto> = {}
): CreditProductDto[] {
  return Array.from({ length: count }, (_, i) =>
    buildCreditProduct({ code: `product-${i}`, ...overrides })
  );
}

Estrategia de Mocking

La regla fundamental: solo se mockean las fronteras externas del sistema. Nunca se mockea logica interna.

Que SI mockear

DependenciaRazon
Repositorios (en unit tests)Aislar logica de dominio/aplicacion
Message bus (IPublishEndpoint)No publicar eventos reales
HTTP clients externosNo depender de servicios terceros
Clock (IDateTimeProvider)Tests deterministas
Identity context (IIdentityContext)Controlar tenant/user en tests

Que NO mockear

ElementoRazon
Entidades de dominioSon el SUT (system under test)
Value objectsSon inmutables y deterministas
Validators (FluentValidation)Testear con input real
Mappers/ExtensionsTestear la transformacion real
EF Core DbContext (en integration)Usar Testcontainers

Ejemplo — Mock correcto vs incorrecto

csharp
// CORRECTO — mockear la frontera (repositorio)
[Fact]
public async Task Handle_WithDuplicateCode_ShouldReturnConflict()
{
    var repo = Substitute.For<ICreditProductRepository>();
    repo.GetByCodeAsync("existing-code", Arg.Any<CancellationToken>())
        .Returns(CreditProductBuilder.Build(code: "existing-code"));

    var handler = new CreateCreditProductHandler(repo, _unitOfWork, _bus, _identity);
    var command = new CreateCreditProductCommand("existing-code", "Name", 100, 5000, 0.05m, 15, 30);

    var result = await handler.Handle(command, CancellationToken.None);

    result.IsSuccess.Should().BeFalse();
    result.Errors.Should().Contain(e => e.Code == "DUPLICATE_CODE");
}

// INCORRECTO — no mockear la entidad de dominio
// var product = Substitute.For<CreditProduct>(); ← NUNCA hacer esto

Frontend — Mocking con MSW

Para tests de integracion en frontend, usamos Mock Service Worker (MSW) para interceptar llamadas HTTP sin mockear la implementacion interna:

typescript
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

const server = setupServer(
  http.get('/api/v1/credit-products', () => {
    return HttpResponse.json({
      data: buildCreditProductList(3),
      pagination: { page: 1, pageSize: 20, total: 3 },
    });
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Ejecucion de Tests

Comandos

bash
# Backend — todos los tests
dotnet test Imagy.Lending.Tests/

# Backend — solo unit tests
dotnet test Imagy.Lending.Tests/ --filter "Category=Unit"

# Backend — solo integration (requiere Docker)
dotnet test Imagy.Lending.Tests/ --filter "Category=Integration"

# Frontend — todos los tests
pnpm test

# Frontend — watch mode
pnpm test:watch

# Frontend — coverage
pnpm test:coverage

CI/CD Pipeline

Los tests se ejecutan en el pipeline de GitHub Actions en este orden:

  1. Unit tests — siempre, en cada PR
  2. Integration tests — siempre, requieren Docker-in-Docker
  3. Contract tests — cuando cambian schemas de eventos
  4. E2E tests — solo en merge a main, contra ambiente de staging

Reimagine Tech LLC — Documentacion Interna