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
| Capa | Cobertura Minima | Justificacion |
|---|---|---|
| Domain | 90% | Logica de negocio critica, calculos financieros |
| Application | 80% | Handlers, validaciones, orquestacion |
| Infrastructure | 60% | 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).
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
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
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
[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)
[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)
[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
[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
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
// 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:
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
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
// 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
| Dependencia | Razon |
|---|---|
| Repositorios (en unit tests) | Aislar logica de dominio/aplicacion |
Message bus (IPublishEndpoint) | No publicar eventos reales |
| HTTP clients externos | No depender de servicios terceros |
Clock (IDateTimeProvider) | Tests deterministas |
Identity context (IIdentityContext) | Controlar tenant/user en tests |
Que NO mockear
| Elemento | Razon |
|---|---|
| Entidades de dominio | Son el SUT (system under test) |
| Value objects | Son inmutables y deterministas |
| Validators (FluentValidation) | Testear con input real |
| Mappers/Extensions | Testear la transformacion real |
| EF Core DbContext (en integration) | Usar Testcontainers |
Ejemplo — Mock correcto vs incorrecto
// 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 estoFrontend — Mocking con MSW
Para tests de integracion en frontend, usamos Mock Service Worker (MSW) para interceptar llamadas HTTP sin mockear la implementacion interna:
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
# 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:coverageCI/CD Pipeline
Los tests se ejecutan en el pipeline de GitHub Actions en este orden:
- Unit tests — siempre, en cada PR
- Integration tests — siempre, requieren Docker-in-Docker
- Contract tests — cuando cambian schemas de eventos
- E2E tests — solo en merge a
main, contra ambiente de staging