---
name: csharp-testing
description: >
  Testing C# con xUnit, NSubstitute, FluentAssertions y WebApplicationFactory.
  Cubre IAsyncLifetime, TestContainers, Bogus para datos falsos, coverlet y
  snapshot testing con Verify. Cargar cuando se escriban tests en .NET o se
  configure el pipeline de testing para proyectos C#.
version: "1.0.0"
herramientasPermitidas: [Read, Grep]
exclusiones:
  - "No cargar para implementar el código C# de producción — primero implementar con `csharp-experto`, luego escribir tests."
  - "No cargar para diseñar patrones C# (CQRS, MediatR, Result<T>) — para diseño cargar `csharp-patrones`."
  - "No cargar para errores de compilación C# en los tests — para build errors cargar `build-errors-csharp`."
  - "No cargar para tests de carga o rendimiento — NUnit/xUnit con Benchmark.NET requieren configuración distinta no cubierta en este skill."
evolvable: true  # default para skill estandar
---
# C# Testing — xUnit, NSubstitute, FluentAssertions

## Cuándo NO cargar

- El código C# de producción aún no existe — primero implementar con `csharp-experto`, luego escribir tests.
- La pregunta es sobre diseñar patrones C# (CQRS, MediatR) — cargar `csharp-patrones`.
- Los errores son de compilación C# en los archivos de test — cargar `build-errors-csharp`.
- La pregunta es sobre tests de carga o rendimiento con BenchmarkDotNet — no está cubierto en este skill.

## Lifecycle de xUnit

```csharp
// IAsyncLifetime para setup/teardown async
public class FacturasServiceTests : IAsyncLifetime
{
    private AppDbContext _db = null!;

    public async Task InitializeAsync()
    {
        // Se ejecuta antes del primer test de la clase
        _db = await TestDbContextFactory.CreateAsync();
    }

    public async Task DisposeAsync()
    {
        await _db.DisposeAsync();
    }
}

// IClassFixture<T> — fixture compartido entre todos los tests de la clase
// Solo para recursos costosos de inicializar (contenedor de BD, servidor HTTP)
public class IntegrationTests(WebAppFixture fixture)
    : IClassFixture<WebAppFixture>
{
    private readonly HttpClient _client = fixture.Client;
}
```

---

## Naming — Should_When

```
NombreMetodo_Debe_ResultadoEsperado_CuandoCondicion
Ej: Crear_DebeRetornarFactura_CuandoDatosValidos
    Crear_DebeLanzarValidacion_CuandoFolioVacio
    ObtenerTodas_DebeRetornarVacia_CuandoNoHayFacturas
```

---

## NSubstitute — Mocks

```csharp
// Crear sustituto
var repo = Substitute.For<IFacturasRepository>();

// Configurar respuesta
repo.ObtenerAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
    .Returns(new Factura { Id = Guid.NewGuid(), Folio = "F-001" });

// Configurar respuesta async con Task
repo.CrearAsync(Arg.Any<Factura>())
    .Returns(Task.FromResult(new Factura()));

// Verificar llamada
await repo.Received(1).ObtenerAsync(
    Arg.Is<Guid>(id => id == facturaId),
    Arg.Any<CancellationToken>());

// Verificar que NO se llamó
repo.DidNotReceive().EliminarAsync(Arg.Any<Guid>());

// Capturar argumento
Factura? facturaCapturada = null;
await repo.CrearAsync(Arg.Do<Factura>(f => facturaCapturada = f));
```

---

## FluentAssertions

```csharp
// Objetos
resultado.Should().NotBeNull();
resultado.Should().BeOfType<FacturaDto>();
resultado.Id.Should().Be(esperadoId);

// Colecciones
facturas.Should().HaveCount(3);
facturas.Should().Contain(f => f.Folio == "F-001");
facturas.Should().BeInAscendingOrder(f => f.FechaEmision);
facturas.Should().AllSatisfy(f => f.EmpresaId.Should().Be(empresaId));

// Excepciones
var act = async () => await svc.CrearAsync(null!);
await act.Should().ThrowAsync<ArgumentNullException>()
    .WithMessage("*factura*");

// Strings
mensaje.Should().StartWith("Error").And.Contain("validación");

// Fechas
factura.FechaEmision.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
```

---

## WebApplicationFactory — Tests de integración

```csharp
public class WebAppFixture : WebApplicationFactory<Program>, IAsyncLifetime
{
    private readonly PostgreSqlContainer _pg = new PostgreSqlBuilder()
        .WithImage("postgres:16-alpine")
        .Build();

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            // Reemplazar DbContext con la BD del contenedor
            services.RemoveAll<DbContextOptions<AppDbContext>>();
            services.AddDbContext<AppDbContext>(o =>
                o.UseNpgsql(_pg.GetConnectionString()));
        });
    }

    public async Task InitializeAsync()
    {
        await _pg.StartAsync();
        // Aplicar migraciones a la BD de test
        using var scope = Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        await db.Database.MigrateAsync();
    }

    public new async Task DisposeAsync()
    {
        await _pg.StopAsync();
    }
}

// Uso en test
public class FacturasApiTests(WebAppFixture fixture)
    : IClassFixture<WebAppFixture>
{
    [Fact]
    public async Task PostFactura_Debe201_CuandoDatosValidos()
    {
        var client = fixture.CreateClient();
        var body = new { Folio = "F-001", EmpresaId = Guid.NewGuid() };

        var response = await client.PostAsJsonAsync("/api/v1/facturas", body);

        response.StatusCode.Should().Be(HttpStatusCode.Created);
        var dto = await response.Content.ReadFromJsonAsync<FacturaDto>();
        dto!.Folio.Should().Be("F-001");
    }
}
```

---

## Bogus — Datos falsos

```csharp
// Centralizar factories en Tests/Factories/
public static class FacturaFaker
{
    private static readonly Faker<Factura> _faker = new Faker<Factura>("es")
        .RuleFor(f => f.Id, f => f.Random.Guid())
        .RuleFor(f => f.Folio, f => $"F-{f.Random.Int(1, 999):D3}")
        .RuleFor(f => f.Total, f => f.Finance.Amount(100, 50000))
        .RuleFor(f => f.FechaEmision, f => f.Date.Recent(30))
        .RuleFor(f => f.Estatus, f => f.PickRandom("borrador", "emitida"));

    public static Factura Generar() => _faker.Generate();
    public static List<Factura> GenerarLista(int n = 5) => _faker.Generate(n);
}
```

---

## Coverlet — Configuración

```xml
<!-- MiApi.Tests.csproj -->
<PackageReference Include="coverlet.collector" Version="6.*" />
<PackageReference Include="coverlet.msbuild" Version="6.*" />
```

```bash
# Ejecutar con cobertura mínima 80%
dotnet test --collect:"XPlat Code Coverage" \
  /p:Threshold=80 \
  /p:ThresholdType=line \
  /p:ExcludeByAttribute="GeneratedCodeAttribute"

# Generar reporte HTML
reportgenerator -reports:"**/coverage.cobertura.xml" \
                -targetdir:"coveragereport" -reporttypes:Html
```

---

## Verify — Snapshot Testing

```csharp
// Útil para DTOs complejos, HTML generado, respuestas de API
[Fact]
public async Task ObtenerReporte_DebeMatchSnapshot()
{
    var reporte = await _svc.GenerarReporteAsync(empresaId);

    // Primera ejecución: crea el archivo .verified.txt
    // Siguientes: compara contra el snapshot
    await Verify(reporte)
        .ScrubMember<ReporteDto>(r => r.FechaGeneracion); // excluir campos volátiles
}
```

---

## Anti-patrones

### Sleep en tests — NUNCA

```csharp
// MAL — flaky e innecesario
await Task.Delay(1000);
Assert.True(resultado.Procesado);

// BIEN — usar Polly o un mecanismo de espera explícito
await Policy.Handle<AssertionException>()
            .WaitAndRetryAsync(5, _ => TimeSpan.FromMilliseconds(200))
            .ExecuteAsync(async () =>
            {
                var r = await _svc.ObtenerAsync(id);
                r.Procesado.Should().BeTrue();
            });
```

### Tests que dependen del orden — NUNCA

```csharp
// MAL — test 2 asume que test 1 ya insertó datos
// BIEN — cada test crea sus propios datos con Bogus/factories
```

---

## Gotchas / Errores comunes no obvios

**`WebApplicationFactory` con `IClassFixture<T>` comparte el servidor entre todos los tests de la clase — los datos de un test contaminan el siguiente**: el servidor y la BD del `WebApplicationFactory` son compartidos dentro de un `IClassFixture`. Si un test crea una entidad, el siguiente test la puede encontrar. Fix: usar `IAsyncLifetime` en la clase de test para truncar tablas en `InitializeAsync`, o envolver cada test en una transacción con rollback.

**NSubstitute `.Returns()` en un método que retorna `Task` sin `await` no suspende realmente el test**: `sub.ObtenerAsync().Returns(Task.FromResult(entidad))` funciona, pero `sub.ObtenerAsync().Returns(entidad)` puede no compilar o retornar un `Task` ya completado que nunca se awaita. Fix: siempre usar `.Returns(Task.FromResult(valor))` o `.Returns(x => Task.FromResult(entidad))` para métodos async en NSubstitute.

**`Bogus.Faker<T>` genera los mismos valores si se crea sin semilla diferente por test**: por defecto `new Faker<T>()` usa una semilla aleatoria diferente en cada ejecución — esto hace que los tests sean no reproducibles cuando fallan. Fix: en tests que requieren reproducibilidad, usar `new Faker<T>().UseSeed(42)` con semilla fija, o guardar la semilla en el output del test fallido.

**FluentAssertions `BeEquivalentTo` compara propiedades por nombre y no detecta diferencias en tipos distintos con mismos nombres**: `expected.Should().BeEquivalentTo(actual)` puede pasar aunque `expected` sea un DTO y `actual` sea la entidad, porque las propiedades homónimas coinciden. Fix: usar `.BeOfType<T>()` antes de `BeEquivalentTo` cuando se quiere verificar que el tipo exacto también coincide.

## Checklist de tests

- [ ] Naming sigue Should_When (o Debe_Cuando en español)
- [ ] Sin Task.Delay/Thread.Sleep en tests
- [ ] Cada test crea sus datos (no depende de otro test)
- [ ] WebApplicationFactory usa TestContainers para BD real
- [ ] Cobertura >= 80% verificada con coverlet en CI
- [ ] Factories de datos en Tests/Factories/, no inline
